Files
KimiCodeSwitch/src/components/ProviderEdit.tsx
T
KimiSwitch Dev ebf2dd28d5 feat: v0.4.0 - provider icon system, settings modal & polish
- Add provider icon system with extracted icon library (cc-switch based)

- Persist icon and icon_color in SQLite and config.toml

- Add Settings modal (theme / language / update check / silent download)

- Add useTheme and useUpdateCheck hooks

- Add provider icon picker (IconPicker) with search and grid

- Fix icon frame 2-layer color difference in ProviderEdit

- Dashboard heatmap and daily usage improvements

- Sessions page polish and adaptive layout

- Bump version to 0.4.0
2026-07-28 16:01:11 +08:00

822 lines
30 KiB
TypeScript

import { useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
import { getIconMetadata } from "../icons/extracted/metadata";
import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [
"thinking",
"always_thinking",
"image_in",
"video_in",
"tool_use",
] as const;
const CAPABILITY_LABELS: Record<
(typeof KNOWN_CAPABILITIES)[number],
TranslationKey
> = {
thinking: "capThinking",
always_thinking: "capAlwaysThinking",
image_in: "capImageIn",
video_in: "capVideoIn",
tool_use: "capToolUse",
};
const PROVIDER_TYPES: ProviderType[] = [
"openai",
"openai_responses",
"anthropic",
"google-genai",
"vertexai",
"kimi",
];
function defaultBaseUrl(agent: Agent, type: ProviderType): string {
if (agent === "kimi_code") {
switch (type) {
case "kimi":
return "https://api.kimi.com/coding/v1";
case "openai":
case "openai_responses":
return "https://api.openai.com/v1";
case "google-genai":
return "https://generativelanguage.googleapis.com";
case "anthropic":
case "vertexai":
return "";
}
}
// Pi defaults
switch (type) {
case "kimi":
return "https://api.moonshot.ai/v1";
case "openai":
case "openai_responses":
return "https://api.openai.com/v1";
case "google-genai":
return "https://generativelanguage.googleapis.com";
case "anthropic":
case "vertexai":
return "";
}
}
interface ProviderEditProps {
agent: Agent;
provider: Provider;
models: Model[];
defaultModel: string | null;
rawOther: unknown;
onRawOtherChange: (nextRawOther: unknown) => void;
onBack: () => void;
onChange: (provider: Provider) => void;
onDelete: () => void;
onModelChange: (model: Model) => void;
onModelDelete: (alias: string) => void;
onModelAdd: () => void;
onBulkAdd: (models: Model[]) => void;
onSetDefault: (alias: string) => void;
onApplyJson: (provider: Provider, models: Model[]) => void;
onSave: () => void;
}
export function ProviderEdit({
agent,
provider,
models,
defaultModel,
rawOther,
onRawOtherChange,
onBack,
onChange,
onDelete,
onModelChange,
onModelDelete,
onModelAdd,
onBulkAdd,
onSetDefault,
onApplyJson,
onSave,
}: ProviderEditProps) {
const { t } = useTranslation();
const nameId = useId();
const noteId = useId();
const officialUrlId = useId();
const apiKeyId = useId();
const baseUrlId = useId();
const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic");
const [showApiKey, setShowApiKey] = useState(false);
const [showIconPicker, setShowIconPicker] = useState(false);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return;
if (!provider.base_url) {
onChange({ ...provider, base_url: def });
}
}, [provider.provider_type, agent]);
return (
<div className="h-full flex flex-col bg-app">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-panel">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
← {t("back")}
</button>
<h2 className="font-semibold text-lg">{t("editProvider")}</h2>
<div className="flex-1" />
<button
type="button"
onClick={onSave}
className="px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded focus:ring-2 focus:ring-green-500 focus:outline-none"
>
{t("saveConfig")}
</button>
<button
type="button"
onClick={onDelete}
className="px-3 py-1.5 text-sm border border-red-500/30 rounded hover:bg-red-900/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
>
{t("delete")}
</button>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 px-4 border-b border-border bg-panel">
{[
{ key: "basic", label: t("basicInfo") },
{ key: "models", label: t("modelMapping") },
{ key: "json", label: t("configJson") },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key as typeof activeTab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none ${
activeTab === tab.key
? "border-blue-500 text-blue-400"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{activeTab === "basic" && (
<div className="space-y-6">
{/* Basic info */}
<section className="bg-panel border border-border rounded-xl p-5">
<h3 className="font-medium mb-4 text-content-primary">{t("basicInfo")}</h3>
{/* Icon selector */}
<div className="flex flex-col items-center gap-2 mb-5">
<button
type="button"
onClick={() => setShowIconPicker(true)}
className="w-20 h-20 p-3 rounded-xl border-2 border-border hover:border-blue-500 transition-colors cursor-pointer flex items-center justify-center focus:ring-2 focus:ring-blue-500 focus:outline-none"
title={provider.icon ? t("clickToChangeIcon") : t("clickToSelectIcon")}
>
<ProviderIcon
name={provider.name}
icon={provider.icon}
color={provider.icon_color}
size={48}
/>
</button>
{provider.icon && (
<button
type="button"
onClick={() =>
onChange({ ...provider, icon: null, icon_color: null })
}
className="text-xs text-content-muted hover:text-content-primary underline"
>
{t("clearIcon")}
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor={nameId} className="block text-sm text-content-muted mb-1.5">
{t("providerName")}
</label>
<input
id={nameId}
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.name}
onChange={(e) => onChange({ ...provider, name: e.target.value })}
/>
</div>
<div>
<label htmlFor={noteId} className="block text-sm text-content-muted mb-1.5">
{t("note")}
</label>
<input
id={noteId}
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.note || ""}
placeholder={t("notePlaceholder")}
onChange={(e) =>
onChange({ ...provider, note: e.target.value || null })
}
/>
</div>
<div className="col-span-1 md:col-span-2">
<label htmlFor={officialUrlId} className="block text-sm text-content-muted mb-1.5">
{t("officialUrl")}
</label>
<input
id={officialUrlId}
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.official_url || ""}
onChange={(e) =>
onChange({ ...provider, official_url: e.target.value || null })
}
/>
</div>
<div className="col-span-1 md:col-span-2 flex items-center gap-2">
<input
id="managed"
type="checkbox"
checked={provider.managed || false}
onChange={(e) =>
onChange({ ...provider, managed: e.target.checked })
}
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="managed" className="text-sm text-content-muted">
{t("managedProvider")}
</label>
</div>
</div>
</section>
{/* API settings */}
<section className="bg-panel border border-border rounded-xl p-5">
<h3 className="font-medium mb-4 text-content-primary">{t("apiSettings")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-content-muted mb-1.5">
{t("apiFormat")}
</label>
<select
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.provider_type}
onChange={(e) =>
onChange({ ...provider, provider_type: e.target.value as ProviderType })
}
>
{PROVIDER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
</div>
{!provider.managed && (
<div>
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
{t("apiKey")}
</label>
<div className="relative">
<input
id={apiKeyId}
type={showApiKey ? "text" : "password"}
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key || ""}
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
</div>
)}
<div>
<label htmlFor={baseUrlId} className="block text-sm text-content-muted mb-1.5">
{t("requestUrl")}
</label>
<input
id={baseUrlId}
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.base_url || ""}
placeholder={defaultBaseUrl(agent, provider.provider_type)}
onChange={(e) =>
onChange({ ...provider, base_url: e.target.value || null })
}
/>
<div className="mt-2 text-xs text-orange-600/80 dark:text-orange-600 dark:text-orange-400/80 bg-orange-100 dark:bg-orange-900/20 border border-orange-300 dark:border-orange-500/20 rounded-lg px-3 py-2">
{t("baseUrlHint", { type: provider.provider_type })}
</div>
</div>
</div>
</section>
</div>
)}
{activeTab === "models" && (
<>
<ModelMapping
agent={agent}
provider={provider}
models={models}
defaultModel={defaultModel}
onModelChange={onModelChange}
onModelDelete={onModelDelete}
onModelAdd={onModelAdd}
onBulkAdd={onBulkAdd}
onSetDefault={onSetDefault}
/>
{agent === "kimi_code" && (
<AgentSettingsPanel
rawOther={rawOther}
onChange={onRawOtherChange}
/>
)}
</>
)}
{activeTab === "json" && (
<JsonPreview
provider={provider}
models={models}
onApply={onApplyJson}
/>
)}
</div>
{showIconPicker &&
createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={() => setShowIconPicker(false)}
role="dialog"
aria-modal="true"
>
<div
className="w-full max-w-4xl rounded-xl border border-border bg-panel shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="text-lg font-semibold text-content-primary">
{t("selectIcon")}
</h2>
<button
type="button"
onClick={() => setShowIconPicker(false)}
className="rounded-md p-1 text-content-muted hover:bg-hover hover:text-content-primary transition-colors"
aria-label={t("close")}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5">
<IconPicker
value={provider.icon}
onValueChange={(iconName) => {
const meta = getIconMetadata(iconName);
onChange({
...provider,
icon: iconName,
icon_color: meta?.defaultColor ?? null,
});
setShowIconPicker(false);
}}
/>
</div>
</div>
</div>,
document.body
)}
</div>
);
}
function ModelMapping({
agent,
provider,
models,
defaultModel,
onModelChange,
onModelDelete,
onModelAdd,
onBulkAdd,
onSetDefault,
}: {
agent: Agent;
provider: Provider;
models: Model[];
defaultModel: string | null;
onModelChange: (model: Model) => void;
onModelDelete: (alias: string) => void;
onModelAdd: () => void;
onBulkAdd: (models: Model[]) => void;
onSetDefault: (alias: string) => void;
}) {
const { t } = useTranslation();
const [discovering, setDiscovering] = useState(false);
const [discovered, setDiscovered] = useState<DiscoveredModel[] | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [fetchThinking, setFetchThinking] = useState(true);
const handleDiscover = async () => {
setDiscovering(true);
setDiscoverError(null);
setDiscovered(null);
setSelected(new Set());
try {
const result = await invoke<DiscoveredModel[]>("list_provider_models", {
provider,
});
setDiscovered(result);
} catch (err) {
setDiscoverError(err instanceof Error ? err.message : String(err));
} finally {
setDiscovering(false);
}
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddSelected = () => {
if (!discovered || selected.size === 0) return;
const toAdd: Model[] = [];
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
const safeProvider = provider.name.replace(/\//g, "-");
const safeModelId = dm.id.replace(/\//g, "-");
const alias = `${safeProvider}/${safeModelId}`;
const ref = getModelRef(dm.id);
// Priority: API-provided value > models.dev reference > regex fallback
const max_context_size =
dm.max_context_size ?? ref?.context ?? getDefaultMaxContextSize(dm.id);
toAdd.push({
alias,
provider: provider.name,
model: dm.id,
max_context_size,
display_name: dm.display_name ?? ref?.name ?? null,
supports_1m: max_context_size >= 1_000_000,
// Reference data wins when available; otherwise keep the old
// fetchThinking-checkbox behavior as the fallback.
capabilities: ref
? capabilitiesFromRef(ref)
: fetchThinking
? ["thinking"]
: [],
});
}
onBulkAdd(toAdd);
setDiscovered(null);
setSelected(new Set());
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-content-primary">{t("modelMapping")}</h3>
<p className="text-xs text-content-muted mt-1">{t("modelMappingDesc")}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onModelAdd}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("oneClickSetup")}
</button>
<button
type="button"
onClick={handleDiscover}
disabled={discovering || provider.managed}
className="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{discovering ? t("fetchingModels") : t("fetchModels")}
</button>
</div>
</div>
{discoverError && (
<div className="text-red-400 text-sm bg-red-900/20 border border-red-500/20 p-3 rounded-lg">
{discoverError}
</div>
)}
{discovered && (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<div className="text-sm font-medium">
{t("discoveredModels", { count: discovered.length })}
</div>
<label className="flex items-center gap-2 text-sm text-content-primary cursor-pointer">
<input
type="checkbox"
checked={fetchThinking}
onChange={(e) => setFetchThinking(e.target.checked)}
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{t("fetchEnableThinking")}
</label>
<div className="max-h-48 overflow-auto space-y-1">
{discovered.map((m) => (
<label
key={m.id}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded"
>
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => toggleSelect(m.id)}
/>
<span className="font-mono text-xs text-content-primary">{m.id}</span>
{m.display_name && (
<span className="text-content-muted">({m.display_name})</span>
)}
</label>
))}
</div>
<button
type="button"
onClick={handleAddSelected}
disabled={selected.size === 0}
className="px-4 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded"
>
{t("addSelected")} {selected.size > 0 ? `(${selected.size})` : ""}
</button>
</div>
)}
<div className="bg-panel border border-border rounded-xl overflow-auto">
<table className="w-full min-w-[640px] text-sm">
<thead className="bg-input text-content-muted">
<tr>
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
<th className="text-left px-4 py-3 font-medium w-28">{t("contextSize")}</th>
<th className="text-center px-4 py-3 font-medium w-28">{t("supports1M")}</th>
{agent === "kimi_code" && (
<th className="text-left px-4 py-3 font-medium">{t("capabilities")}</th>
)}
<th className="text-center px-4 py-3 font-medium w-32">{t("default")}</th>
<th className="text-center px-4 py-3 font-medium w-20">{t("operation")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#2a2a2e]">
{models.map((m) => (
<tr key={m.alias} className="hover:bg-hover">
<td className="px-4 py-2">
<span className="text-sm text-content-muted">
{m.model ? `${m.model}[${m.provider}]` : m.alias}
</span>
</td>
<td className="px-4 py-2">
<input
className="w-full bg-transparent border border-border rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.model}
onChange={(e) =>
onModelChange({ ...m, model: e.target.value })
}
/>
</td>
<td className="px-4 py-2">
<input
type="number"
min={0}
step={1024}
className="w-full bg-transparent border border-border rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.max_context_size}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
onModelChange({
...m,
max_context_size: isNaN(value) ? 0 : value,
});
}}
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={m.supports_1m || false}
onChange={(e) =>
onModelChange({
...m,
supports_1m: e.target.checked,
})
}
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
</td>
{agent === "kimi_code" && (
<td className="px-4 py-2">
<CapabilitiesCell
capabilities={m.capabilities || []}
onChange={(next) =>
onModelChange({ ...m, capabilities: next })
}
/>
</td>
)}
<td className="px-4 py-2 text-center">
<button
type="button"
onClick={() => onSetDefault(m.alias)}
className={`text-xs px-2 py-1 rounded border ${
defaultModel === m.alias
? "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30"
: "border-border text-content-muted hover:bg-hover-2"
}`}
>
{defaultModel === m.alias ? t("isDefault") : t("setDefault")}
</button>
</td>
<td className="px-4 py-2 text-center">
<button
type="button"
onClick={() => {
if (confirm(t("deleteModelConfirm", { alias: m.alias }))) {
onModelDelete(m.alias);
}
}}
className="text-red-400 hover:text-red-300 text-sm"
>
{t("delete")}
</button>
</td>
</tr>
))}
</tbody>
</table>
{models.length === 0 && (
<div className="text-center py-8 text-content-muted text-sm">
{t("noModelMappings")}
</div>
)}
</div>
<button
type="button"
onClick={onModelAdd}
className="px-4 py-2 border border-border rounded-lg hover:bg-hover-2 text-sm"
>
{t("addModelMapping")}
</button>
</div>
);
}
function JsonPreview({
provider,
models,
onApply,
}: {
provider: Provider;
models: Model[];
onApply: (provider: Provider, models: Model[]) => void;
}) {
const { t } = useTranslation();
const [text, setText] = useState(() =>
JSON.stringify({ provider, models }, null, 2)
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setText(JSON.stringify({ provider, models }, null, 2));
setError(null);
}, [provider, models]);
const handleApply = () => {
try {
const parsed = JSON.parse(text);
if (!parsed.provider || typeof parsed.provider !== "object") {
throw new Error("missing provider object");
}
if (!Array.isArray(parsed.models)) {
throw new Error("models must be an array");
}
onApply(parsed.provider as Provider, parsed.models as Model[]);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<div className="w-full h-full flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-content-primary">{t("configJson")}</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted">{t("readOnlyPreview")}</span>
<button
type="button"
onClick={handleApply}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("apply")}
</button>
</div>
</div>
{error && (
<div className="mb-2 text-red-400 text-sm bg-red-900/20 border border-red-500/20 p-2 rounded">
{t("invalidJson", { message: error })}
</div>
)}
<textarea
className="flex-1 min-h-[40vh] bg-panel border border-border rounded-xl p-4 overflow-auto text-xs font-mono text-green-600 dark:text-green-400 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
/>
</div>
);
}
function CapabilitiesCell({
capabilities,
onChange,
}: {
capabilities: string[];
onChange: (next: string[]) => void;
}) {
const { t } = useTranslation();
const known = new Set<string>(KNOWN_CAPABILITIES);
const customs = capabilities.filter((c) => !known.has(c));
const toggle = (cap: string, on: boolean) => {
if (on) {
onChange([...capabilities.filter((c) => c !== cap), cap]);
} else {
onChange(capabilities.filter((c) => c !== cap));
}
};
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
{KNOWN_CAPABILITIES.map((cap) => (
<label
key={cap}
className="flex items-center gap-1 text-xs text-content-muted cursor-pointer"
>
<input
type="checkbox"
checked={capabilities.includes(cap)}
onChange={(e) => toggle(cap, e.target.checked)}
className="w-3.5 h-3.5 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{t(CAPABILITY_LABELS[cap])}
</label>
))}
<input
className="w-28 bg-transparent border border-border rounded px-1.5 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={customs.join(", ")}
placeholder={t("customCapabilities")}
onChange={(e) => {
const newCustoms = e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const kept = capabilities.filter((c) => known.has(c));
onChange([...kept, ...newCustoms]);
}}
/>
</div>
);
}