From 26edc88d8b8f72f68f0b7e9b73aec6873e29c961 Mon Sep 17 00:00:00 2001 From: markos-ttl <311951577+markos-ttl@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:44:23 -0700 Subject: [PATCH] feat(kilo): add provider and model management --- src-tauri/src/commands/acp.rs | 175 ++- src-tauri/src/lib.rs | 1 + src-tauri/src/web/handlers/acp.rs | 16 +- src-tauri/src/web/router.rs | 4 + .../chat/kilo-chat-model-manager.tsx | 268 ++++ src/components/chat/message-input.tsx | 11 + .../chat/model-option-list.test.tsx | 13 + src/components/chat/model-option-list.tsx | 80 +- src/components/chat/model-option-picker.tsx | 31 +- .../chat/session-config-selector.tsx | 147 +- .../settings/acp-agent-settings.tsx | 1375 ++++++++++------- .../settings/kilo-provider-model-dialog.tsx | 139 ++ .../settings/opencode-connect-dialog.tsx | 34 +- src/i18n/messages/ar.json | 47 +- src/i18n/messages/de.json | 47 +- src/i18n/messages/en.json | 47 +- src/i18n/messages/es.json | 47 +- src/i18n/messages/fr.json | 47 +- src/i18n/messages/ja.json | 47 +- src/i18n/messages/ko.json | 47 +- src/i18n/messages/pt.json | 47 +- src/i18n/messages/zh-CN.json | 47 +- src/i18n/messages/zh-TW.json | 47 +- src/lib/api.ts | 11 + src/lib/kilo-model-config.test.ts | 143 ++ src/lib/kilo-model-config.ts | 118 ++ src/lib/types.ts | 10 + 27 files changed, 2372 insertions(+), 674 deletions(-) create mode 100644 src/components/chat/kilo-chat-model-manager.tsx create mode 100644 src/components/settings/kilo-provider-model-dialog.tsx create mode 100644 src/lib/kilo-model-config.test.ts create mode 100644 src/lib/kilo-model-config.ts diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 5cdd88e0d..bc392ffb0 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -2669,8 +2669,55 @@ fn opencode_auth_json_path() -> PathBuf { crate::parsers::opencode::resolve_opencode_base_dir().join("auth.json") } -fn load_opencode_auth_json_raw() -> Option { - fs::read_to_string(opencode_auth_json_path()).ok() +fn is_kilo_agent(agent_type: AgentType) -> bool { + agent_type.custom_id() == Some("kilo") +} + +fn is_opencode_family_agent(agent_type: AgentType) -> bool { + agent_type == AgentType::OpenCode || is_kilo_agent(agent_type) +} + +fn kilo_config_dir() -> PathBuf { + crate::acp::opencode_plugins::xdg_config_home() + .unwrap_or_else(|| home_dir_or_default().join(".config")) + .join("kilo") +} + +/// Prefer the file Kilo is already using: kilo.jsonc > kilo.json > +/// leftover opencode.json[c] from a migration. New installs write kilo.json. +fn resolve_kilo_config_path() -> PathBuf { + let dir = kilo_config_dir(); + for name in ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"] { + let candidate = dir.join(name); + if candidate.exists() { + return candidate; + } + } + dir.join("kilo.json") +} + +fn kilo_auth_json_path() -> PathBuf { + std::env::var_os("XDG_DATA_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".local").join("share"))) + .unwrap_or_else(|| PathBuf::from(".")) + .join("kilo") + .join("auth.json") +} + +fn opencode_family_auth_json_path(agent_type: AgentType) -> Option { + if agent_type == AgentType::OpenCode { + Some(opencode_auth_json_path()) + } else if is_kilo_agent(agent_type) { + Some(kilo_auth_json_path()) + } else { + None + } +} + +fn load_opencode_family_auth_json_raw(agent_type: AgentType) -> Option { + fs::read_to_string(opencode_family_auth_json_path(agent_type)?).ok() } // --------------------------------------------------------------------------- @@ -4201,21 +4248,23 @@ fn set_or_remove_grok_number( Ok(()) } -fn persist_opencode_auth_json(raw_auth: &str) -> Result<(), AcpError> { +fn persist_opencode_family_auth_json(agent_type: AgentType, raw_auth: &str) -> Result<(), AcpError> { let parsed = serde_json::from_str::(raw_auth) - .map_err(|e| AcpError::protocol(format!("invalid opencode auth.json: {e}")))?; + .map_err(|e| AcpError::protocol(format!("invalid {agent_type} auth.json: {e}")))?; if !parsed.is_object() { - return Err(AcpError::protocol( - "invalid opencode auth.json: root must be a JSON object", - )); + return Err(AcpError::protocol(format!( + "invalid {agent_type} auth.json: root must be a JSON object" + ))); } - let path = opencode_auth_json_path(); + let path = opencode_family_auth_json_path(agent_type).ok_or_else(|| { + AcpError::protocol(format!("{agent_type} has no auth.json path")) + })?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) - .map_err(|e| AcpError::protocol(format!("create opencode directory failed: {e}")))?; + .map_err(|e| AcpError::protocol(format!("create {agent_type} directory failed: {e}")))?; } fs::write(&path, format!("{raw_auth}\n")) - .map_err(|e| AcpError::protocol(format!("write opencode auth.json failed: {e}")))?; + .map_err(|e| AcpError::protocol(format!("write {agent_type} auth.json failed: {e}")))?; Ok(()) } @@ -5067,13 +5116,10 @@ pub(crate) async fn acp_update_kimi_code_config_and_refresh( .await) } -/// Validate an API key + endpoint by listing the account's models. GETs -/// `/models` with the key as a Bearer token and returns the model ids -/// (OpenAI-compatible `{ "data": [{ "id": ... }] }`). Surfaces the provider's -/// own error message on failure. Lets the settings panel populate a model picker -/// and doubles as a one-click connection test — directly preventing the -/// "Not found the model ..." trap of typing a model the account can't access. -pub(crate) async fn acp_fetch_kimi_models_core( +/// Validate an API key + OpenAI-compatible endpoint by listing its models. +/// Surfaces the provider's own error message on failure and returns sorted, +/// unique model ids for the settings model picker. +async fn fetch_openai_compatible_models_core( base_url: &str, api_key: &str, ) -> Result, AcpError> { @@ -5124,6 +5170,20 @@ pub(crate) async fn acp_fetch_kimi_models_core( Ok(ids) } +pub(crate) async fn acp_fetch_kimi_models_core( + base_url: &str, + api_key: &str, +) -> Result, AcpError> { + fetch_openai_compatible_models_core(base_url, api_key).await +} + +pub(crate) async fn acp_fetch_kilo_provider_models_core( + base_url: &str, + api_key: &str, +) -> Result, AcpError> { + fetch_openai_compatible_models_core(base_url, api_key).await +} + // --------------------------------------------------------------------------- // Pi config helpers // @@ -7401,6 +7461,9 @@ fn reconcile_hermes_runtime_env_in(home: &Path) -> Result<(), AcpError> { } fn agent_local_config_path(agent_type: AgentType) -> Option { + if is_kilo_agent(agent_type) { + return Some(resolve_kilo_config_path()); + } match agent_type { AgentType::ClaudeCode => Some(home_dir_or_default().join(".claude").join("settings.json")), AgentType::Gemini => Some(home_dir_or_default().join(".gemini").join("settings.json")), @@ -7492,7 +7555,7 @@ fn persist_agent_local_config_json( )); } - if agent_type == AgentType::OpenCode { + if is_opencode_family_agent(agent_type) { let serialized = serde_json::to_string_pretty(&patch) .map_err(|e| AcpError::protocol(format!("serialize config_json failed: {e}")))?; if let Some(parent) = path.parent() { @@ -8851,7 +8914,9 @@ fn cascade_update_agent_config( persist_codex_native_config_files(Some(&auth_str), Some(&toml_str))?; } AgentType::OpenCode => { - let auth_path = opencode_auth_json_path(); + let auth_path = opencode_family_auth_json_path(agent_type).ok_or_else(|| { + AcpError::protocol(format!("{agent_type} has no auth.json path")) + })?; let mut auth_obj = if auth_path.exists() { fs::read_to_string(&auth_path) .ok() @@ -8866,7 +8931,7 @@ fn cascade_update_agent_config( } let auth_str = serde_json::to_string_pretty(&auth_obj) .map_err(|e| AcpError::protocol(e.to_string()))?; - persist_opencode_auth_json(&auth_str)?; + persist_opencode_family_auth_json(agent_type, &auth_str)?; let patch = serde_json::json!({ "apiBaseUrl": api_url }); let patch_str = @@ -9788,8 +9853,8 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result) -> Option { /// explicitly empty auth payload truncates `auth.json` to `{}`; `None` leaves /// each file untouched. fn persist_opencode_native_config( + agent_type: AgentType, opencode_auth_json: Option<&str>, config_json: Option<&str>, ) -> Result<(), AcpError> { if let Some(auth) = opencode_auth_payload_to_write(opencode_auth_json) { - persist_opencode_auth_json(&auth)?; + persist_opencode_family_auth_json(agent_type, &auth)?; } if let Some(raw) = config_json { - persist_agent_local_config_json(AgentType::OpenCode, Some(raw))?; + persist_agent_local_config_json(agent_type, Some(raw))?; } Ok(()) } @@ -10459,8 +10526,9 @@ pub(crate) async fn acp_update_agent_config_core( return Ok(()); } - if agent_type == AgentType::OpenCode { + if is_opencode_family_agent(agent_type) { persist_opencode_native_config( + agent_type, opencode_auth_json.as_deref(), config_json.as_deref(), )?; @@ -10661,6 +10729,16 @@ pub async fn acp_fetch_kimi_models( acp_fetch_kimi_models_core(&base_url, &api_key).await } +/// List the models available to a Kilo custom provider. +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_fetch_kilo_provider_models( + base_url: String, + api_key: String, +) -> Result, AcpError> { + acp_fetch_kilo_provider_models_core(&base_url, &api_key).await +} + /// Apply a structured Pi config update, writing pi's native `settings.json` /// (provider/model/thinking level) and `auth.json` (when an API key is given). /// Desktop command; the web handler calls `acp_update_pi_config_core` directly. @@ -13817,7 +13895,8 @@ mod tests { // Disconnecting the last provider sends an empty auth payload: it // must truncate auth.json to {}, not strand the stale credential. - persist_opencode_native_config(Some(""), None).expect("persist"); + persist_opencode_native_config(AgentType::OpenCode, Some(""), None) + .expect("persist"); assert_eq!(fs::read_to_string(&auth_path).unwrap().trim(), "{}"); }, @@ -13839,13 +13918,53 @@ mod tests { fs::write(&auth_path, original).expect("seed"); // No auth payload supplied → file untouched. - persist_opencode_native_config(None, None).expect("persist"); + persist_opencode_native_config(AgentType::OpenCode, None, None).expect("persist"); assert_eq!(fs::read_to_string(&auth_path).unwrap(), original); }, ); } + #[test] + fn custom_kilo_uses_its_native_config_and_auth_files() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_home = tmp.path().join("config"); + let data_home = tmp.path().join("data"); + temp_env::with_vars( + [ + ("HOME", Some(tmp.path())), + ("XDG_CONFIG_HOME", Some(config_home.as_path())), + ("XDG_DATA_HOME", Some(data_home.as_path())), + ], + || { + let kilo = AgentType::custom("kilo").expect("valid custom agent"); + persist_opencode_native_config( + kilo, + Some(r#"{"openai":{"type":"api","key":"secret"}}"#), + Some(r#"{"provider":{"openai":{"models":{}}}}"#), + ) + .expect("persist Kilo config"); + + let config_path = config_home.join("kilo").join("kilo.json"); + let auth_path = data_home.join("kilo").join("auth.json"); + assert_eq!(agent_local_config_path(kilo), Some(config_path.clone())); + assert_eq!(opencode_family_auth_json_path(kilo), Some(auth_path.clone())); + assert!(config_path.exists()); + assert!(auth_path.exists()); + assert!(load_agent_local_config_json(kilo) + .expect("load Kilo config") + .contains("provider")); + assert!(load_opencode_family_auth_json_raw(kilo) + .expect("load Kilo auth") + .contains("secret")); + + let other = AgentType::custom("other").expect("valid custom agent"); + assert_eq!(agent_local_config_path(other), None); + assert_eq!(opencode_family_auth_json_path(other), None); + }, + ); + } + #[test] fn opencode_config_path_falls_back_when_xdg_config_home_empty() { // An empty XDG_CONFIG_HOME must fall back to /.config, not resolve diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f780ebbb0..2945301e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1221,6 +1221,7 @@ mod tauri_app { acp_commands::acp_update_hermes_config, acp_commands::acp_update_kimi_code_config, acp_commands::acp_fetch_kimi_models, + acp_commands::acp_fetch_kilo_provider_models, acp_commands::acp_update_pi_config, acp_commands::acp_load_pi_config, acp_commands::acp_validate_pi_command, diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 234aeee0c..570803b09 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -818,13 +818,13 @@ pub async fn acp_update_kimi_code_config( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub struct AcpFetchKimiModelsParams { +pub struct AcpFetchProviderModelsParams { pub base_url: String, pub api_key: String, } pub async fn acp_fetch_kimi_models( - Json(params): Json, + Json(params): Json, ) -> Result>, AppCommandError> { let models = acp_commands::acp_fetch_kimi_models_core(¶ms.base_url, ¶ms.api_key) .await @@ -832,6 +832,18 @@ pub async fn acp_fetch_kimi_models( Ok(Json(models)) } +pub async fn acp_fetch_kilo_provider_models( + Json(params): Json, +) -> Result>, AppCommandError> { + let models = acp_commands::acp_fetch_kilo_provider_models_core( + ¶ms.base_url, + ¶ms.api_key, + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(models)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpUpdatePiConfigParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index faae6d2cb..68ad33700 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -732,6 +732,10 @@ pub fn build_router( "/acp_fetch_kimi_models", post(handlers::acp::acp_fetch_kimi_models), ) + .route( + "/acp_fetch_kilo_provider_models", + post(handlers::acp::acp_fetch_kilo_provider_models), + ) .route( "/acp_update_pi_config", post(handlers::acp::acp_update_pi_config), diff --git a/src/components/chat/kilo-chat-model-manager.tsx b/src/components/chat/kilo-chat-model-manager.tsx new file mode 100644 index 000000000..2c16d075b --- /dev/null +++ b/src/components/chat/kilo-chat-model-manager.tsx @@ -0,0 +1,268 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { acpListAgents, acpUpdateAgentConfig } from "@/lib/api" +import { isKiloAgentType, type AgentType } from "@/lib/types" +import { + addKiloModel, + kiloModelsFromConfig, + setKiloModelReasoning, + setKiloModelVariantEnabled, + type KiloModelEntry, +} from "@/lib/kilo-model-config" + +interface KiloChatModelManagerProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +const KILO_AGENT_TYPE: AgentType = "custom:kilo" + +export function KiloChatModelManager({ + open, + onOpenChange, +}: KiloChatModelManagerProps) { + const t = useTranslations("Folder.chat.kiloModelManager") + const [config, setConfig] = useState>({}) + const [providerId, setProviderId] = useState("") + const [modelId, setModelId] = useState("") + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!open) return + acpListAgents() + .then((agents) => { + const raw = agents.find((agent) => + isKiloAgentType(agent.agent_type) + )?.config_json + setConfig(raw ? JSON.parse(raw) : {}) + }) + .catch((error) => { + toast.error(t("loadFailed"), { + description: error instanceof Error ? error.message : String(error), + }) + }) + }, [open, t]) + + const save = async (next: Record) => { + setSaving(true) + try { + await acpUpdateAgentConfig(KILO_AGENT_TYPE, { + config_json: JSON.stringify(next, null, 2), + }) + setConfig(next) + } catch (error) { + toast.error(t("saveFailed"), { + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setSaving(false) + } + } + + const addModel = () => { + const provider = providerId.trim() + const model = modelId.trim() + if (!provider || !model) return + void save(addKiloModel(config, provider, model)) + setModelId("") + } + + const setReasoning = (entry: KiloModelEntry, reasoning: boolean) => { + void save(setKiloModelReasoning(config, entry, reasoning)) + } + + const models = kiloModelsFromConfig(config) + + return ( + + + + {t("title")} + {t("description")} + +
+ setProviderId(event.target.value)} + placeholder={t("providerPlaceholder")} + /> + setModelId(event.target.value)} + placeholder={t("modelPlaceholder")} + /> + +
+
+ {models.map((entry) => ( +
+ + {entry.providerId}/{entry.modelId} + + + {t("reasoning")} + + setReasoning(entry, value)} + /> +
+ ))} + {models.length === 0 && ( +

+ {t("empty")} +

+ )} +
+
+
+ ) +} + +export function KiloReasoningVariantsButton({ + modelValue, +}: { + modelValue: string +}) { + const t = useTranslations("Folder.chat.kiloModelManager") + const [open, setOpen] = useState(false) + const [config, setConfig] = useState>({}) + const [customLevel, setCustomLevel] = useState("") + const [saving, setSaving] = useState(false) + const [providerId, modelId] = modelValue.split(/\/(.*)/) + const load = () => + acpListAgents() + .then((agents) => { + const raw = agents.find((agent) => + isKiloAgentType(agent.agent_type) + )?.config_json + setConfig(raw ? JSON.parse(raw) : {}) + }) + .catch((error) => { + toast.error(t("loadFailed"), { + description: error instanceof Error ? error.message : String(error), + }) + }) + const update = async (level: string, enabled: boolean) => { + if (!providerId || !modelId) return + const next = setKiloModelVariantEnabled( + config, + providerId, + modelId, + level, + enabled + ) + setSaving(true) + try { + await acpUpdateAgentConfig(KILO_AGENT_TYPE, { + config_json: JSON.stringify(next, null, 2), + }) + setConfig(next) + } catch (error) { + toast.error(t("saveFailed"), { + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setSaving(false) + } + } + const model = ( + ( + (config.provider as Record)?.[providerId] as Record< + string, + unknown + > + )?.models as Record + )?.[modelId] as Record | undefined + const variants = + (model?.variants as Record> | undefined) ?? + {} + const levels = Array.from( + new Set(["low", "high", "max", ...Object.keys(variants)]) + ) + return ( + { + setOpen(value) + if (value) void load() + }} + > + + + + {t("reasoningLevelsTitle")} + + {t("reasoningLevelsDescription", { model: modelValue })} + + +
+ {levels.map((level) => ( +
+ {level} + void update(level, enabled)} + /> +
+ ))} +
+
+ setCustomLevel(event.target.value)} + placeholder={t("customLevel")} + /> + +
+
+
+ ) +} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 73da0539f..2dd73a8b4 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -634,6 +634,14 @@ export function MessageInput({ [configOptions] ) const hasConfigOptions = availableConfigOptions.length > 0 + const currentModelValue = useMemo(() => { + const modelOption = availableConfigOptions.find((option) => + isModelConfigOption(option) + ) + return modelOption?.kind.type === "select" + ? modelOption.kind.current_value + : undefined + }, [availableConfigOptions]) const hasModes = availableModes.length > 0 const effectiveModeId = useMemo(() => { @@ -1356,6 +1364,7 @@ export function MessageInput({ key={option.id} option={option} groups={listGroups} + agentType={agentType} onSelect={(configId, valueId) => onConfigOptionChange?.(configId, valueId) } @@ -1367,6 +1376,8 @@ export function MessageInput({ key={option.id} option={option} derivedGroups={deriveModelGroups(option)} + agentType={agentType} + modelValue={currentModelValue} onSelect={(configId, valueId) => onConfigOptionChange?.(configId, valueId) } diff --git a/src/components/chat/model-option-list.test.tsx b/src/components/chat/model-option-list.test.tsx index 74182c4f4..8a84ce736 100644 --- a/src/components/chat/model-option-list.test.tsx +++ b/src/components/chat/model-option-list.test.tsx @@ -112,6 +112,19 @@ describe("ModelOptionList", () => { expect(screen.queryByText("anthropic")).toBeNull() }) + it("keeps providers visible and expands their models on click", async () => { + const user = userEvent.setup() + renderList({ collapsibleGroups: true }) + const provider = screen.getByRole("button", { name: "openai" }) + expect(provider).toHaveAttribute("aria-expanded", "false") + expect(screen.queryByRole("option", { name: /gpt-4o/ })).toBeNull() + + await user.click(provider) + + expect(provider).toHaveAttribute("aria-expanded", "true") + expect(screen.getByRole("option", { name: /gpt-4o/ })).toBeInTheDocument() + }) + it("shows the empty label when nothing matches", async () => { const user = userEvent.setup() renderList() diff --git a/src/components/chat/model-option-list.tsx b/src/components/chat/model-option-list.tsx index ed7d113d7..a53160573 100644 --- a/src/components/chat/model-option-list.tsx +++ b/src/components/chat/model-option-list.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useId, useMemo, useRef, useState } from "react" -import { Check, Search } from "lucide-react" +import { Check, ChevronDown, ChevronRight, Search } from "lucide-react" import { Virtualizer, type VirtualizerHandle } from "virtua" import { useImeGuard } from "@/hooks/use-ime-guard" import { cn } from "@/lib/utils" @@ -23,6 +23,8 @@ interface ModelOptionListProps { emptyLabel: string /** Focus the search box on mount (the wide popover opens straight into it). */ autoFocus?: boolean + /** Collapse inactive provider groups until explicitly expanded. */ + collapsibleGroups?: boolean } // Coarse per-row viewport estimate (headers are shorter, two-line options @@ -48,10 +50,23 @@ export function ModelOptionList({ listAriaLabel, emptyLabel, autoFocus = false, + collapsibleGroups = false, }: ModelOptionListProps) { const ime = useImeGuard() const [query, setQuery] = useState("") const [activeIndex, setActiveIndex] = useState(0) + // Show the active provider's models immediately; all other providers remain + // visible as compact, expandable rows until the user opens one. + const [expandedGroupKeys, setExpandedGroupKeys] = useState>( + () => + new Set( + groups + .filter((group) => + group.options.some((option) => option.value === currentValue) + ) + .map((group) => group.key) + ) + ) const virtualizerRef = useRef(null) // virtua scrolls the real OverlayScrollbars viewport (surfaced by ScrollArea's // `onViewportRef` once OS initializes). We keep both a ref (for the Virtualizer @@ -70,10 +85,28 @@ export function ModelOptionList({ [baseId] ) - const rows = useMemo( - () => flattenModelGroups(filterModelGroups(groups, query)), + const filteredGroups = useMemo( + () => filterModelGroups(groups, query), [groups, query] ) + const searching = query.trim().length > 0 + const rows = useMemo( + () => + flattenModelGroups( + filteredGroups.map((group) => + collapsibleGroups && + group.name !== null && + !searching && + !expandedGroupKeys.has(group.key) + ? { ...group, options: [] } + : group + ) + ), + [collapsibleGroups, expandedGroupKeys, filteredGroups, searching] + ) + const hasMatchingOptions = filteredGroups.some( + (group) => group.options.length > 0 + ) // Flat row indices that are options (skipping headers) — the keyboard cursor // walks these, and they map an option position back to its flat row index. const optionRowIndices = useMemo( @@ -191,7 +224,7 @@ export function ModelOptionList({ /> - {optionCount === 0 ? ( + {!hasMatchingOptions ? (
{emptyLabel}
@@ -222,14 +255,43 @@ export function ModelOptionList({ > {rows.map((row, flatIndex) => { if (row.kind === "header") { + if (!collapsibleGroups) { + return ( +
+ {row.name} +
+ ) + } + const expanded = + searching || + expandedGroupKeys.has(row.key.replace(/^header:/, "")) return ( -
{ + const groupKey = row.key.replace(/^header:/, "") + setExpandedGroupKeys((keys) => { + const next = new Set(keys) + if (next.has(groupKey)) next.delete(groupKey) + else next.add(groupKey) + return next + }) + }} + className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground" > - {row.name} -
+ {expanded ? ( + + ) : ( + + )} + {row.name} + ) } const optionIndex = optionIndexByRow.get(flatIndex) ?? 0 diff --git a/src/components/chat/model-option-picker.tsx b/src/components/chat/model-option-picker.tsx index e3cf39637..4d4743e8f 100644 --- a/src/components/chat/model-option-picker.tsx +++ b/src/components/chat/model-option-picker.tsx @@ -1,7 +1,7 @@ "use client" import { useMemo, useState } from "react" -import { ChevronDown } from "lucide-react" +import { ChevronDown, Settings2 } from "lucide-react" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { @@ -10,9 +10,14 @@ import { PopoverTrigger, } from "@/components/ui/popover" import { ModelOptionList } from "@/components/chat/model-option-list" +import { KiloChatModelManager } from "@/components/chat/kilo-chat-model-manager" import { useScrollbarSafeDismiss } from "@/hooks/use-scrollbar-safe-dismiss" import type { ModelOptionGroup } from "@/lib/model-config-groups" -import type { SessionConfigOptionInfo } from "@/lib/types" +import { + isKiloAgentType, + type AgentType, + type SessionConfigOptionInfo, +} from "@/lib/types" interface ModelOptionPickerProps { option: SessionConfigOptionInfo @@ -20,6 +25,7 @@ interface ModelOptionPickerProps { * headerless group for a long flat list). */ groups: ModelOptionGroup[] onSelect: (configId: string, valueId: string) => void + agentType?: AgentType | null } // Wide-form model picker for LONG model lists: a trigger button opening a @@ -35,9 +41,11 @@ export function ModelOptionPicker({ option, groups, onSelect, + agentType, }: ModelOptionPickerProps) { const t = useTranslations("Folder.chat.messageInput") const [open, setOpen] = useState(false) + const [manageOpen, setManageOpen] = useState(false) const { contentRef, onPointerDownOutside, onFocusOutside } = useScrollbarSafeDismiss() const kind = option.kind.type === "select" ? option.kind : null @@ -89,8 +97,27 @@ export function ModelOptionPicker({ listAriaLabel={t("modelListLabel")} emptyLabel={t("noModels")} autoFocus + collapsibleGroups={Boolean(agentType && isKiloAgentType(agentType))} /> + {agentType && isKiloAgentType(agentType) && ( +
+ +
+ )} + ) } diff --git a/src/components/chat/session-config-selector.tsx b/src/components/chat/session-config-selector.tsx index 57ec1afc8..005e56a70 100644 --- a/src/components/chat/session-config-selector.tsx +++ b/src/components/chat/session-config-selector.tsx @@ -14,8 +14,13 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content" +import { KiloReasoningVariantsButton } from "@/components/chat/kilo-chat-model-manager" import type { ModelOptionGroup } from "@/lib/model-config-groups" -import type { SessionConfigOptionInfo } from "@/lib/types" +import { + isKiloAgentType, + type AgentType, + type SessionConfigOptionInfo, +} from "@/lib/types" interface SessionConfigSelectorProps { option: SessionConfigOptionInfo @@ -27,12 +32,16 @@ interface SessionConfigSelectorProps { * means "no grouping" — fall back to server groups, else the flat list. */ derivedGroups?: ModelOptionGroup[] | null + agentType?: AgentType | null + modelValue?: string } export function InlineSessionConfigSelector({ option, onSelect, derivedGroups, + agentType, + modelValue, }: SessionConfigSelectorProps) { if (option.kind.type !== "select") return null @@ -62,71 +71,79 @@ export function InlineSessionConfigSelector({ const currentLabel = selected?.name ?? option.kind.current_value return ( - - - - - - onSelect(option.id, value)} +
+ + + + + - {renderGroups - ? renderGroups.map((group, index) => ( - - {index > 0 && } - {group.name !== null && ( - {group.name} - )} - {group.options.map((item) => ( - - - - ))} - - )) - : option.kind.options.map((item) => ( - - - - ))} - - - + onSelect(option.id, value)} + > + {renderGroups + ? renderGroups.map((group, index) => ( + + {index > 0 && } + {group.name !== null && ( + {group.name} + )} + {group.options.map((item) => ( + + + + ))} + + )) + : option.kind.options.map((item) => ( + + + + ))} + + + + {agentType && + isKiloAgentType(agentType) && + modelValue && + /reasoning|thinking|effort/i.test(option.name + option.id) && ( + + )} +
) } diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 5dfb598b1..03d08a47a 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -84,7 +84,6 @@ import { ComboboxGroup, ComboboxInput, ComboboxItem, - ComboboxLabel, ComboboxList, } from "@/components/ui/combobox" import { cn, copyTextToClipboard, randomUUID } from "@/lib/utils" @@ -92,6 +91,7 @@ import { acpClearBinaryCache, acpDetectAgentLocalVersion, acpDownloadAgentBinary, + acpFetchKiloProviderModels, acpInstallUvTool, acpGetAgentStatus, acpListAgents, @@ -124,6 +124,7 @@ import type { OpenCodeCatalogProvider, PreflightResult, } from "@/lib/types" +import { isKiloAgentType, isOpenCodeFamily } from "@/lib/types" import { HERMES_PROVIDERS, parseClaudeProviderModel, @@ -136,6 +137,7 @@ import { OpenCodeConnectDialog, OpenCodeCustomProviderDialog, } from "@/components/settings/opencode-connect-dialog" +import { KiloProviderModelDialog } from "@/components/settings/kilo-provider-model-dialog" import { OpenCodePermissionsSection } from "@/components/settings/opencode-permissions-section" import { AgentDiagnosticsDialog } from "@/components/settings/agent-diagnostics-dialog" import { @@ -150,6 +152,7 @@ import { } from "@/lib/opencode-connect" import { toErrorMessage } from "@/lib/app-error" import { getInstallErrorHintKey } from "@/lib/agent-install-error" +import { addKiloModels } from "@/lib/kilo-model-config" import { useAgentInstallStream } from "@/hooks/use-agent-install-stream" import { OpencodePluginsModal } from "./opencode-plugins-modal" import { CodeBuddyConfigPanel } from "./codebuddy-config-panel" @@ -1428,6 +1431,14 @@ function OpenCodeModelCombobox({ placeholder: string }) { const inputRef = useRef(null) + const [query, setQuery] = useState("") + const [expandedProviderIds, setExpandedProviderIds] = useState>( + () => { + const providerId = value.split("/", 1)[0] + return providerId ? new Set([providerId]) : new Set() + } + ) + const searching = query.trim().length > 0 const handleSelect = useCallback( (next: string | null) => { @@ -1446,7 +1457,12 @@ function OpenCodeModelCombobox({ }, [onValueChange, value]) return ( - + - {groups.map((group) => ( - - {group.label} - {group.models.map((model) => { - const contextLabel = - typeof model.context === "number" - ? formatContextWindow(model.context) - : "" - return ( - - {model.value} - {(model.reasoning || contextLabel) && ( - - {model.reasoning && ( - - {acpText("openCode.reasoningBadge", "reasoning")} - - )} - {contextLabel && ( - - {contextLabel} - - )} - - )} - - ) - })} - - ))} + {groups.map((group) => { + const expanded = + searching || expandedProviderIds.has(group.providerId) + return ( + + + {expanded + ? group.models.map((model) => { + const contextLabel = + typeof model.context === "number" + ? formatContextWindow(model.context) + : "" + return ( + + {model.value} + {(model.reasoning || contextLabel) && ( + + {model.reasoning && ( + + {acpText( + "openCode.reasoningBadge", + "reasoning" + )} + + )} + {contextLabel && ( + + {contextLabel} + + )} + + )} + + ) + }) + : null} + + ) + })} {acpText("openCode.noMatchingModels", "No matching models")} @@ -3382,7 +3429,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { ? codexImportant.model : agent.agent_type === "gemini" ? geminiImportant.model - : agent.agent_type === "open_code" + : isOpenCodeFamily(agent.agent_type) ? openCodeImportant.model : important.model, claudeAuthMode: @@ -3916,6 +3963,40 @@ function AgentReorderItem({ ) } +function CustomProviderReorderItem({ + providerId, + children, +}: { + providerId: string + children: ( + startDrag: (event: PointerEvent) => void + ) => ReactNode +}) { + const dragControls = useDragControls() + const startDrag = useCallback( + (event: PointerEvent) => { + event.preventDefault() + event.stopPropagation() + dragControls.start(event) + }, + [dragControls] + ) + + return ( + + {children(startDrag)} + + ) +} + export function AcpAgentSettings() { const ime = useImeGuard() const locale = useLocale() @@ -3997,6 +4078,13 @@ export function AcpAgentSettings() { >({}) const [openCodeModelConfigExpanded, setOpenCodeModelConfigExpanded] = useState>({}) + const [openCodeModelsFetching, setOpenCodeModelsFetching] = useState< + Record + >({}) + const [openCodeFetchedModelPicker, setOpenCodeFetchedModelPicker] = useState<{ + providerId: string + modelIds: string[] + } | null>(null) const [openCodeDeleteProviderId, setOpenCodeDeleteProviderId] = useState< string | null >(null) @@ -4355,13 +4443,15 @@ export function AcpAgentSettings() { } } let normalizedConfig = normalizeConfigText(configText) - if (agentType === "open_code" && normalizedConfig) { + if (isOpenCodeFamily(agentType) && normalizedConfig) { normalizedConfig = ensureOpenCodeProviderNpm(normalizedConfig) } // For agents using merge strategy, mark removed keys as null // so the backend merge_json_values can delete them from disk. let configForPersist = - agentType === "open_code" && !normalizedConfig ? "{}" : normalizedConfig + isOpenCodeFamily(agentType) && !normalizedConfig + ? "{}" + : normalizedConfig const usesMerge = agentType === "claude_code" || agentType === "gemini" || @@ -5113,7 +5203,8 @@ export function AcpAgentSettings() { const hermesCanUseNativeSetup = isDesktop() && getActiveRemoteConnectionId() === null const selectedOpenCodeConfig = useMemo(() => { - if (selectedAgentKind !== "open_code" || !locale) return null + if (!selectedAgentKind || !isOpenCodeFamily(selectedAgentKind) || !locale) + return null return extractOpenCodeConfigValues( selectedConfigText, selectedOpenCodeAuthJsonText @@ -5125,7 +5216,7 @@ export function AcpAgentSettings() { selectedOpenCodeAuthJsonText, ]) const openCodeConnected = useMemo(() => { - if (selectedAgentKind !== "open_code") return [] + if (!selectedAgentKind || !isOpenCodeFamily(selectedAgentKind)) return [] return buildConnectedProviders({ configText: selectedConfigText, authJsonText: selectedOpenCodeAuthJsonText, @@ -5176,7 +5267,7 @@ export function AcpAgentSettings() { // only on `selectedAgentKind` — depending on the loading flag we set here // would re-run the effect and cancel its own in-flight request. useEffect(() => { - if (selectedAgentKind !== "open_code") return + if (!selectedAgentKind || !isOpenCodeFamily(selectedAgentKind)) return if (openCodeCatalogRequestedRef.current) return openCodeCatalogRequestedRef.current = true setOpenCodeCatalogLoading(true) @@ -5269,7 +5360,7 @@ export function AcpAgentSettings() { return } - if (selectedAgent.agent_type === "open_code") { + if (isOpenCodeFamily(selectedAgent.agent_type)) { const openCode = extractOpenCodeConfigValues( nextText, selectedDraft.openCodeAuthJsonText @@ -6135,7 +6226,7 @@ export function AcpAgentSettings() { if ( !selectedAgent || !selectedDraft || - selectedAgent.agent_type !== "open_code" + !isOpenCodeFamily(selectedAgent.agent_type) ) return const nextConfig = patchOpenCodeConfigText( @@ -6182,7 +6273,7 @@ export function AcpAgentSettings() { next: { configText: string; authJsonText: string }, providerId: string ) => { - if (!selectedAgent || selectedAgent.agent_type !== "open_code") return + if (!selectedAgent || !isOpenCodeFamily(selectedAgent.agent_type)) return const parsed = extractOpenCodeConfigValues( next.configText, next.authJsonText @@ -6193,9 +6284,12 @@ export function AcpAgentSettings() { openCodeAuthJsonText: next.authJsonText, model: parsed.model, })) - setConfigErrors((prev) => ({ ...prev, open_code: null })) + setConfigErrors((prev) => ({ + ...prev, + [selectedAgent.agent_type]: null, + })) try { - await persistConfig("open_code", next.configText, { + await persistConfig(selectedAgent.agent_type, next.configText, { openCodeAuthJsonText: next.authJsonText, }) toast.success(t("toasts.providerConnected", { providerId }), { @@ -6217,7 +6311,7 @@ export function AcpAgentSettings() { if ( !selectedAgent || !selectedDraft || - selectedAgent.agent_type !== "open_code" + !isOpenCodeFamily(selectedAgent.agent_type) ) return const next = disconnectProvider({ @@ -6237,7 +6331,7 @@ export function AcpAgentSettings() { model: parsed.model, })) try { - await persistConfig("open_code", next.configText, { + await persistConfig(selectedAgent.agent_type, next.configText, { openCodeAuthJsonText: next.authJsonText, }) toast.success(t("toasts.providerDisconnected", { providerId })) @@ -6256,7 +6350,7 @@ export function AcpAgentSettings() { if ( !selectedAgent || !selectedDraft || - selectedAgent.agent_type !== "open_code" + !isOpenCodeFamily(selectedAgent.agent_type) ) return const nextConfig = setProviderEnabled({ @@ -6269,7 +6363,7 @@ export function AcpAgentSettings() { configText: nextConfig, })) try { - await persistConfig("open_code", nextConfig, { + await persistConfig(selectedAgent.agent_type, nextConfig, { openCodeAuthJsonText: selectedDraft.openCodeAuthJsonText, }) } catch (err) { @@ -6303,7 +6397,7 @@ export function AcpAgentSettings() { if ( !selectedAgent || !selectedDraft || - selectedAgent.agent_type !== "open_code" + !isOpenCodeFamily(selectedAgent.agent_type) ) { return null } @@ -6421,6 +6515,36 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, t] ) + const handleOpenCodeReorderCustomProviders = useCallback( + (nextCustomIds: string[]) => { + if (!selectedOpenCodeConfig) return + const customIds = selectedOpenCodeConfig.providerIds.filter( + (id) => !openCodeCatalogIds.has(id) + ) + if ( + nextCustomIds.length !== customIds.length || + nextCustomIds.some((id) => !customIds.includes(id)) + ) { + return + } + handleOpenCodeConfigPatch((config) => { + const providers = asObjectRecord(config.provider) ?? {} + const catalogEntries = Object.entries(providers).filter(([id]) => + openCodeCatalogIds.has(id) + ) + const customEntries = nextCustomIds.flatMap((id) => { + const value = providers[id] + return typeof value === "undefined" ? [] : [[id, value] as const] + }) + config.provider = Object.fromEntries([ + ...catalogEntries, + ...customEntries, + ]) + }) + }, + [handleOpenCodeConfigPatch, openCodeCatalogIds, selectedOpenCodeConfig] + ) + const confirmOpenCodeProviderDelete = useCallback(() => { const providerId = openCodeDeleteProviderId?.trim() if (!providerId) return @@ -6429,7 +6553,7 @@ export function AcpAgentSettings() { if ( !removed || !selectedAgent || - selectedAgent.agent_type !== "open_code" + !isOpenCodeFamily(selectedAgent.agent_type) ) { return } @@ -6521,7 +6645,8 @@ export function AcpAgentSettings() { // The API key is a secret: it goes ONLY into auth.json, never into // opencode.json. setProviderApiKey also scrubs any stale options.apiKey. if (key === "apiKey") { - if (!selectedDraft) return + const selectedAgentType = selectedAgent?.agent_type + if (!selectedDraft || !selectedAgentType) return const next = setProviderApiKey({ configText: selectedDraft.configText, authJsonText: selectedDraft.openCodeAuthJsonText, @@ -6532,7 +6657,10 @@ export function AcpAgentSettings() { next.configText, next.authJsonText ) - setConfigErrors((prev) => ({ ...prev, open_code: null })) + setConfigErrors((prev) => ({ + ...prev, + [selectedAgentType]: null, + })) updateSelectedDraft((current) => ({ ...current, configText: next.configText, @@ -6575,7 +6703,12 @@ export function AcpAgentSettings() { } }) }, - [handleOpenCodeConfigPatch, selectedDraft, updateSelectedDraft] + [ + handleOpenCodeConfigPatch, + selectedAgent?.agent_type, + selectedDraft, + updateSelectedDraft, + ] ) const handleOpenCodeModelDraftChange = useCallback( @@ -6620,6 +6753,7 @@ export function AcpAgentSettings() { } modelsRoot[nextModelId] = { name: nextModelId, + reasoning: true, } }) setOpenCodeNewModelIds((prev) => ({ @@ -6630,6 +6764,81 @@ export function AcpAgentSettings() { [handleOpenCodeConfigPatch, openCodeNewModelIds, selectedOpenCodeConfig, t] ) + const handleOpenCodeFetchModels = useCallback( + async (providerId: string) => { + const targetProviderId = providerId.trim() + const provider = selectedOpenCodeConfig?.providers[targetProviderId] + if (!provider) return + if (!provider.baseUrl.trim() || !provider.apiKey.trim()) { + toast.error(t("openCode.fetchModelsCredentialsRequired")) + return + } + setOpenCodeModelsFetching((prev) => ({ + ...prev, + [targetProviderId]: true, + })) + try { + const modelIds = await acpFetchKiloProviderModels({ + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + }) + const newIds = modelIds.filter( + (modelId) => !provider.modelIds.includes(modelId) + ) + if (newIds.length === 0) { + toast.success(t("openCode.fetchModelsAlreadyConfigured")) + return + } + setOpenCodeFetchedModelPicker({ + providerId: targetProviderId, + modelIds: newIds, + }) + } catch (error) { + toast.error(t("openCode.fetchModelsFailed"), { + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setOpenCodeModelsFetching((prev) => ({ + ...prev, + [targetProviderId]: false, + })) + } + }, + [selectedOpenCodeConfig, t] + ) + + const handleOpenCodeAddFetchedModels = useCallback( + (modelIds: string[]) => { + const picker = openCodeFetchedModelPicker + if (!picker || !selectedOpenCodeConfig) return + const provider = selectedOpenCodeConfig.providers[picker.providerId] + if (!provider) return + const selectedIds = modelIds.filter( + (modelId) => + picker.modelIds.includes(modelId) && + !provider.modelIds.includes(modelId) + ) + if (selectedIds.length === 0) { + setOpenCodeFetchedModelPicker(null) + return + } + handleOpenCodeConfigPatch((config) => { + Object.assign( + config, + addKiloModels(config, picker.providerId, selectedIds) + ) + }) + toast.success(t("openCode.modelsAdded", { count: selectedIds.length })) + setOpenCodeFetchedModelPicker(null) + }, + [ + handleOpenCodeConfigPatch, + openCodeFetchedModelPicker, + selectedOpenCodeConfig, + t, + ] + ) + const handleOpenCodeRemoveModel = useCallback( (providerId: string, modelId: string) => { const targetProviderId = providerId.trim() @@ -8674,14 +8883,18 @@ supports_websockets = true`} - ) : selectedAgent.agent_type === "open_code" ? ( + ) : isOpenCodeFamily(selectedAgent.agent_type) ? (

- {t("openCode.configDescription")} + {isKiloAgentType(selectedAgent.agent_type) + ? t("openCode.kiloConfigurationDescription") + : t("openCode.configDescription")}

@@ -8867,6 +9080,11 @@ supports_websockets = true`} configText={selectedDraft.configText} authJsonText={selectedDraft.openCodeAuthJsonText} editProviderId={openCodeEditProviderId} + agentName={ + isKiloAgentType(selectedAgent.agent_type) + ? "Kilo Code" + : undefined + } onConnect={applyOpenCodeConnect} /> @@ -8879,6 +9097,11 @@ supports_websockets = true`} catalogIds={openCodeCatalog.map((p) => p.id)} configText={selectedDraft.configText} authJsonText={selectedDraft.openCodeAuthJsonText} + agentName={ + isKiloAgentType(selectedAgent.agent_type) + ? "Kilo Code" + : undefined + } onConnect={applyOpenCodeConnect} /> @@ -8915,7 +9138,13 @@ supports_websockets = true`} {t("openCode.emptyProvider")}
) : ( -
+ {openCodeCustomProviderIds.map((providerId) => { if (!selectedOpenCodeConfig) return null const provider = @@ -8932,507 +9161,607 @@ supports_websockets = true`} providerId )) return ( - { - setOpenCodeProviderId(open ? providerId : "") - }} + providerId={providerId} > -
-
- -
- - {isDisabled - ? t("status.disabled") - : t("status.enabled")} - - { - handleOpenCodeProviderStatusChange( - providerId, - checked - ) - }} - aria-label={t( - "openCode.providerEnabledState", - { providerId } - )} - title={ - isDisabled - ? t("actions.clickEnable", { - name: providerId, - }) - : t("actions.clickDisable", { - name: providerId, - }) - } - /> - -
-
- - -
-
- - { - handleOpenCodeProviderFieldChange( - providerId, - "name", - event.target.value - ) - }} - placeholder="My Provider" - /> -
-
- - -
-
- - { - handleOpenCodeProviderFieldChange( - providerId, - "api", - event.target.value - ) - }} - placeholder="openai.responses" - /> -
-
- - { - handleOpenCodeProviderFieldChange( + + + {providerId} + + + {t("openCode.modelCount", { + count: provider.modelCount, + })} + + +
+
-
- -
- { - handleOpenCodeProviderFieldChange( + })} + aria-label={t( + "openCode.dragProvider", + { providerId } + )} + onPointerDown={startDrag} + onClick={(event) => { + event.stopPropagation() + }} + > + + + + {isDisabled + ? t("status.disabled") + : t("status.enabled")} + + { + handleOpenCodeProviderStatusChange( providerId, - "apiKey", - event.target.value + checked ) }} - placeholder="sk-..." + aria-label={t( + "openCode.providerEnabledState", + { providerId } + )} + title={ + isDisabled + ? t("actions.clickEnable", { + name: providerId, + }) + : t("actions.clickDisable", { + name: providerId, + }) + } />
-
- { - setOpenCodeModelConfigExpanded( - (prev) => ({ - ...prev, - [providerId]: open, - }) - ) - }} - > -
- - -

- {t("openCode.modelDescription")} -

-
+ +
+
+ { + handleOpenCodeProviderFieldChange( + providerId, + "name", + event.target.value + ) + }} + placeholder="My Provider" + /> +
+
+ + +
+
+ + { + handleOpenCodeProviderFieldChange( + providerId, + "api", + event.target.value + ) + }} + placeholder="openai.responses" + /> +
+
+ + { - handleOpenCodeModelDraftChange( + handleOpenCodeProviderFieldChange( providerId, + "baseURL", event.target.value ) }} - className="w-[240px]" - placeholder="new-model-id" + placeholder="https://api.example.com/v1" /> - +
+
+
+ { + setOpenCodeModelConfigExpanded( + (prev) => ({ + ...prev, + [providerId]: open, + }) + ) + }} + > +
+ -
+
+ + + {t( + "openCode.modelManagement" + )} + +
+ + {t("openCode.modelCount", { + count: provider.modelCount, + })} + + + +

+ {t("openCode.modelDescription")} +

+ +
+ {isKiloAgentType( + selectedAgent.agent_type + ) && ( + + )} + { + handleOpenCodeModelDraftChange( + providerId, + event.target.value + ) + }} + className="w-[240px]" + placeholder={t( + "openCode.newModelIdPlaceholder" + )} + /> + +
- {provider.modelIds.length === 0 ? ( -
- {t("openCode.emptyModel")} -
- ) : ( -
-
-
- {t("openCode.modelId")} -
-
- {t("openCode.modelName")} + {openCodeFetchedModelPicker?.providerId === + providerId && ( + { + if (!open) { + setOpenCodeFetchedModelPicker( + null + ) + } + }} + onAdd={ + handleOpenCodeAddFetchedModels + } + /> + )} + + {provider.modelIds.length === + 0 ? ( +
+ {t("openCode.emptyModel")}
-
-
- {provider.modelIds.map( - (modelId) => { - const model = - provider.models[modelId] - if (!model) return null - const modelDraftKey = `${providerId}:${modelId}` - return ( -
- { - handleOpenCodeModelIdDraftChange( - providerId, - modelId, - event.target.value - ) - }} - onBlur={() => { - handleOpenCodeModelIdCommit( - providerId, - modelId - ) - }} - {...ime.props} - onKeyDown={(event) => { - if ( - ime.isComposing( + ) : ( +
+
+
+ {t("openCode.modelId")} +
+
+ {t("openCode.modelName")} +
+
+
+ {provider.modelIds.map( + (modelId) => { + const model = + provider.models[modelId] + if (!model) return null + const modelDraftKey = `${providerId}:${modelId}` + return ( +
+ { + handleOpenCodeModelIdDraftChange( + providerId, + modelId, + event.target + .value + ) + }} + onBlur={() => { + handleOpenCodeModelIdCommit( + providerId, + modelId + ) + }} + {...ime.props} + onKeyDown={( event - ) - ) - return - if ( - event.key === - "Enter" - ) { - event.preventDefault() - handleOpenCodeModelIdCommit( - providerId, - modelId - ) - event.currentTarget.blur() - return - } - if ( - event.key === - "Escape" - ) { - setOpenCodeModelIdDrafts( - (prev) => { - if ( - typeof prev[ - modelDraftKey - ] === - "undefined" - ) { - return prev - } - const next = { - ...prev, - } - delete next[ - modelDraftKey - ] - return next + ) => { + if ( + ime.isComposing( + event + ) + ) + return + if ( + event.key === + "Enter" + ) { + event.preventDefault() + handleOpenCodeModelIdCommit( + providerId, + modelId + ) + event.currentTarget.blur() + return } - ) - event.currentTarget.blur() - } - }} - className="h-8 min-w-0 flex-1" - placeholder="model.id" - /> - { - handleOpenCodeModelFieldChange( - providerId, - modelId, - event.target.value - ) - }} - className="h-8 min-w-0 flex-1" - placeholder="model.name" - /> - -
- ) - } + if ( + event.key === + "Escape" + ) { + setOpenCodeModelIdDrafts( + (prev) => { + if ( + typeof prev[ + modelDraftKey + ] === + "undefined" + ) { + return prev + } + const next = + { + ...prev, + } + delete next[ + modelDraftKey + ] + return next + } + ) + event.currentTarget.blur() + } + }} + className="h-8 min-w-0 flex-1" + placeholder="model.id" + /> + { + handleOpenCodeModelFieldChange( + providerId, + modelId, + event.target + .value + ) + }} + className="h-8 min-w-0 flex-1" + placeholder="model.name" + /> + +
+ ) + } + )} +
)} -
- )} - -
- -
-
+ +
+ + .then(() => { + toast.success( + t("toasts.providerSaved", { + providerId, + }), + { + description: `${t("toasts.openCodeConfigSynced")} ${t("toasts.configSavedHint")}`, + } + ) + }) + .catch((err) => { + console.error( + "[Settings] save opencode provider failed:", + err + ) + const message = + err instanceof Error + ? err.message + : String(err) + toast.error( + t( + "toasts.saveProviderFailed", + { + providerId, + } + ), + { + description: message, + } + ) + }) + }} + disabled={selectedIsSavingConfig} + > + {selectedIsSavingConfig ? ( + <> + + {t("actions.saving")} + + ) : ( + <> + + {t( + "actions.saveCurrentProvider" + )} + + )} + +
+
- -
-
+ + )} + ) })} -
+ )}
@@ -9451,7 +9780,9 @@ supports_websockets = true`}