"use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { Card, Button, ModelSelectModal } from "@/shared/components"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig"; import { useTheme } from "@/shared/hooks/useTheme"; import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; import ProviderIcon from "@/shared/components/ProviderIcon"; export default function DefaultToolCard({ toolId, tool, isExpanded = false, onToggle = () => {}, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false, batchStatus, }) { const t = useTranslations("cliTools"); const translateOrFallback = useCallback( (key, fallback, values = undefined) => { try { return t(key, values); } catch { return fallback; } }, [t] ); const [copiedField, setCopiedField] = useState(null); const [showModelModal, setShowModelModal] = useState(false); const [modelValue, setModelValue] = useState(""); const [modelValues, setModelValues] = useState([]); const [runtimeStatus, setRuntimeStatus] = useState(null); const [message, setMessage] = useState(null); const [saving, setSaving] = useState(false); const runtimeFetchStartedRef = useRef(false); const { isDark } = useTheme(); // (#523) Initialize state with key *id* instead of masked key string const [selectedApiKeyId, setSelectedApiKeyId] = useState(() => apiKeys?.length > 0 ? apiKeys[0].id : "" ); const isMultiModelTool = tool.modelSelectionMode === "multiple"; const usesOpenCodePreview = tool.previewConfigMode === "opencode"; const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId); const resolveApiKeyValue = useCallback( () => selectedKeyObj?.rawKey || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder")), [cloudEnabled, selectedKeyObj?.rawKey, t] ); const getSelectedModelEntries = useCallback(() => { const selectedValues = isMultiModelTool ? modelValues.length > 0 ? modelValues : modelValue ? [modelValue] : [] : modelValue ? [modelValue] : []; const availableModels = Array.isArray(activeProviders) ? activeProviders.flatMap((provider) => provider?.models || []) : []; const modelMap = new Map( availableModels.filter((model) => model?.value).map((model) => [model.value, model]) ); return selectedValues.map((value) => { const matched = modelMap.get(value); return { value, label: matched?.name || matched?.label || value, }; }); }, [activeProviders, isMultiModelTool, modelValue, modelValues]); const getSelectedModelLabels = useCallback( () => getSelectedModelEntries().map((entry) => entry.label), [getSelectedModelEntries] ); const getSelectedModelLabelMap = useCallback( () => Object.fromEntries(getSelectedModelEntries().map((entry) => [entry.value, entry.label])), [getSelectedModelEntries] ); const normalizedBaseUrl = baseUrl || DEFAULT_DISPLAY_BASE_URL; const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`; // Persist and restore model selection per tool via localStorage useEffect(() => { const savedModel = localStorage.getItem(`omniroute-cli-model-${toolId}`); if (savedModel) { if (isMultiModelTool) { try { const parsed = JSON.parse(savedModel); if (Array.isArray(parsed)) { const normalized = parsed.map((value) => String(value || "").trim()).filter(Boolean); setModelValues(normalized); setModelValue(normalized[0] || ""); } else { setModelValue(savedModel); setModelValues([savedModel]); } } catch { setModelValue(savedModel); setModelValues([savedModel]); } } else { setModelValue(savedModel); } } const savedKey = localStorage.getItem(`omniroute-cli-key-${toolId}`); // (#523) localStorage may contain a masked key string from before the fix — // match by prefix/suffix against known keys to find the id. if (savedKey && apiKeys?.length > 0) { const prefix = savedKey.slice(0, 8); const suffix = savedKey.slice(-4); const matchedKey = apiKeys.find( (k) => (k.rawKey && k.rawKey.startsWith(prefix) && k.rawKey.endsWith(suffix)) || (k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix)) ); if (matchedKey) setSelectedApiKeyId(matchedKey.id); } }, [toolId, apiKeys, isMultiModelTool]); const handleModelChange = useCallback( (value) => { setModelValue(value); if (value) { localStorage.setItem(`omniroute-cli-model-${toolId}`, value); } else { localStorage.removeItem(`omniroute-cli-model-${toolId}`); } }, [toolId] ); const handleModelValuesChange = useCallback( (values) => { const normalized = Array.isArray(values) ? [...new Set(values.map((value) => String(value || "").trim()).filter(Boolean))] : []; setModelValues(normalized); setModelValue(normalized[0] || ""); if (normalized.length > 0) { localStorage.setItem(`omniroute-cli-model-${toolId}`, JSON.stringify(normalized)); } else { localStorage.removeItem(`omniroute-cli-model-${toolId}`); } }, [toolId] ); const handleApiKeyChange = useCallback( (value) => { setSelectedApiKeyId(value); if (value) { // (#523) Store the key id in localStorage for persistence localStorage.setItem(`omniroute-cli-key-${toolId}`, value); } }, [toolId] ); useEffect(() => { if (!isExpanded || runtimeStatus || runtimeFetchStartedRef.current) return; runtimeFetchStartedRef.current = true; fetch(`/api/cli-tools/runtime/${toolId}`) .then((res) => res.json()) .then((data) => setRuntimeStatus(data)) .catch((error) => setRuntimeStatus({ error: error?.message || t("runtimeCheckFailed") })); }, [isExpanded, runtimeStatus, t, toolId]); const replaceVars = useCallback( (text) => { const keyToUse = resolveApiKeyValue(); return text .replace(/\{\{baseUrl\}\}/g, baseUrlWithV1) .replace(/\{\{apiKey\}\}/g, keyToUse) .replace(/\{\{model\}\}/g, getSelectedModelLabels()[0] || t("modelPlaceholder")); }, [baseUrl, getSelectedModelLabels, resolveApiKeyValue, t] ); const handleCopy = async (text, field) => { await copyToClipboard(replaceVars(text)); setCopiedField(field); setTimeout(() => setCopiedField(null), 2000); }; const getSelectedModels = useCallback(() => { if (!isMultiModelTool) return modelValue ? [modelValue] : []; return modelValues.length > 0 ? modelValues : modelValue ? [modelValue] : []; }, [isMultiModelTool, modelValue, modelValues]); const getRenderedCodeBlock = useCallback(() => { if (!tool.codeBlock?.code) return ""; if (!usesOpenCodePreview) return replaceVars(tool.codeBlock.code); const keyToUse = resolveApiKeyValue(); return JSON.stringify( buildOpenCodeConfigDocument({ baseUrl: baseUrlWithV1, apiKey: keyToUse, models: getSelectedModels(), model: getSelectedModels()[0], modelLabels: getSelectedModelLabelMap(), }), null, 2 ); }, [ baseUrl, getSelectedModels, getSelectedModelLabelMap, replaceVars, resolveApiKeyValue, tool.codeBlock?.code, usesOpenCodePreview, ]); const handleSelectModel = (model) => { if (!isMultiModelTool) { handleModelChange(model.value); return; } if (!model) { handleModelValuesChange([]); return; } if (modelValues.includes(model.value)) { handleModelValuesChange(modelValues.filter((value) => value !== model.value)); return; } handleModelValuesChange([...modelValues, model.value]); }; const hasActiveProviders = activeProviders.length > 0; const checkingRuntime = isExpanded && runtimeStatus === null; // Save config to file (for tools that support it, like Continue) const handleSaveConfig = async () => { setSaving(true); setMessage(null); try { // (#523) Prefer keyId lookup so the backend writes the real key to disk. const selectedKeyId = selectedApiKeyId?.trim() || null; const res = await fetch(`/api/cli-tools/guide-settings/${toolId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseUrl: baseUrlWithV1, apiKey: !cloudEnabled ? "sk_omniroute" : null, keyId: selectedKeyId, model: modelValue, models: isMultiModelTool ? getSelectedModels() : undefined, modelLabels: getSelectedModelLabelMap(), }), }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: data.message || t("configurationSaved") }); } else { setMessage({ type: "error", text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failedToSave"), }); } } catch (error) { setMessage({ type: "error", text: error.message }); } finally { setSaving(false); } }; // Check if this tool supports direct config file write const supportsDirectSave = ["continue", "opencode", "qwen"].includes(toolId); const renderApiKeySelector = () => { return (
{apiKeys && apiKeys.length > 0 ? ( <> ) : ( {cloudEnabled ? t("noApiKeysCreateOne") : "sk_omniroute"} )}
); }; const renderModelSelector = () => { const displayValue = isMultiModelTool ? getSelectedModelLabels().join(", ") : getSelectedModelLabels()[0] || ""; return (
isMultiModelTool ? handleModelValuesChange( e.target.value .split(",") .map((value) => value.trim()) .filter(Boolean) ) : handleModelChange(e.target.value) } placeholder={t("modelPlaceholder")} className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" /> {displayValue && ( <> )}
); }; const renderNotes = () => { if (!tool.notes || tool.notes.length === 0) return null; return (
{tool.notes.map((note, index) => { if (note.type === "cloudCheck" && cloudEnabled) return null; const isWarning = note.type === "warning"; const isError = note.type === "cloudCheck" && !cloudEnabled; let bgClass = "bg-blue-500/10 border-blue-500/30"; let textClass = "text-blue-600 dark:text-blue-400"; let iconClass = "text-blue-500"; let icon = "info"; if (isWarning) { bgClass = "bg-yellow-500/10 border-yellow-500/30"; textClass = "text-yellow-600 dark:text-yellow-400"; iconClass = "text-yellow-500"; icon = "warning"; } else if (isError) { bgClass = "bg-red-500/10 border-red-500/30"; textClass = "text-red-600 dark:text-red-400"; iconClass = "text-red-500"; icon = "error"; } return (
{icon}

{translateOrFallback(`guides.${toolId}.notes.${index}`, note.text)}

); })}
); }; const canShowGuide = () => { if (tool.requiresCloud && !cloudEnabled) return false; return true; }; const renderGuideSteps = () => { if (!tool.guideSteps) return

{t("comingSoon")}

; return (
{checkingRuntime && (
progress_activity {t("checkingRuntime")}
)} {!checkingRuntime && runtimeStatus && !runtimeStatus.error && (
{runtimeStatus.reason === "not_required" ? "info" : runtimeStatus.installed && runtimeStatus.runnable ? "check_circle" : "warning"}

{runtimeStatus.reason === "not_required" ? t("guideOnlyIntegration") : runtimeStatus.installed && runtimeStatus.runnable ? t("cliRuntimeDetected") : runtimeStatus.installed ? t("cliFoundNotRunnable", { reason: runtimeStatus.reason ? `: ${runtimeStatus.reason}` : "", }) : t("cliRuntimeNotDetected")}

{runtimeStatus.commandPath && (

{t("binary")}:{" "} {runtimeStatus.commandPath}

)} {runtimeStatus.configPath && (

{t("configPath")}:{" "} {runtimeStatus.configPath}

)}
)} {!checkingRuntime && runtimeStatus?.error && (
error

{t("failedCheckRuntimeStatus")}

)} {renderNotes()} {canShowGuide() && tool.guideSteps.map((item) => (
{item.step}

{translateOrFallback(`guides.${toolId}.steps.${item.step}.title`, item.title)}

{item.desc && (

{translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc, { baseUrl: baseUrlWithV1, })}

)} {item.type === "apiKeySelector" && renderApiKeySelector()} {item.type === "modelSelector" && renderModelSelector()} {item.value && (
{replaceVars(item.value)} {item.copyable && ( )}
)}
))} {canShowGuide() && tool.codeBlock && (
{tool.codeBlock.language}
              {getRenderedCodeBlock()}
            
)} {/* Save / Action buttons */} {canShowGuide() && (
{message && (
{message.type === "success" ? "check_circle" : "error"} {message.text}
)}
{supportsDirectSave && ( )} {tool.codeBlock && ( )} {(isMultiModelTool ? getSelectedModels().length > 0 : !!modelValue) && ( check_circle {t("selectionSaved")} )}
)}
); }; const renderIcon = () => { if (tool.image) { return ( {tool.name} { (e.currentTarget as HTMLElement).style.display = "none"; }} /> ); } if (tool.imageLight || tool.imageDark) { const themedSrc = isDark ? tool.imageDark || tool.imageLight : tool.imageLight || tool.imageDark; return ( {tool.name} { (e.currentTarget as HTMLElement).style.display = "none"; }} /> ); } if (tool.icon) { return ( {tool.icon} ); } return ; }; return (
{renderIcon()}

{tool.name}

{(() => { // Use runtime status if available (after expanding), otherwise use batch status const rs = runtimeStatus; const bs = batchStatus; const isGuide = rs?.reason === "not_required" || tool.configType === "guide"; const isDetected = rs ? rs.installed && rs.runnable : bs?.installed && bs?.runnable; const isInstalled = rs ? rs.installed : bs?.installed; if (isGuide) { return ( {t("guide")} ); } if (isDetected) { return ( {t("detected")} ); } if (isInstalled === false && (rs || bs)) { return ( {t("notInstalled")} ); } if (isInstalled && !isDetected && (rs || bs)) { return ( {t("notReady")} ); } return null; })()}

{translateOrFallback(`toolDescriptions.${toolId}`, tool.description)}

expand_more
{isExpanded &&
{renderGuideSteps()}
} setShowModelModal(false)} onSelect={handleSelectModel} selectedModel={modelValue} selectedModels={isMultiModelTool ? getSelectedModels() : []} activeProviders={activeProviders} title={t("selectModel")} multiSelect={isMultiModelTool} showCombos={!tool.hideComboModels} />
); }