"use client"; /** * The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL * key-value table. Provider left, masked key right, no description hints * (they took too much space), the "Get a key ↗" link a tiny muted guest. * Clicking a writable row unfolds a paste field + Save (+ Delete for a * file-sourced value) beneath it; Esc or a second click folds it away. * The Add-provider control is the LAST ROW of the table and uses the * app's shared dropdown look (ProviderMenuList — fuzzy search over the * 33 pi-ai providers). * * Owner rulings carried in: * - Env-owned rows (a real environment variable owns the name — dev * .env.local or launch env) render READ-ONLY + dimmed, no paste field: * a paste box on a key the UI cannot make live would mislead. * - OpenRouter is NOT special: one provider among 33, a row only when * stored, otherwise it lives in the Add dropdown like everyone else. * - Providers groups API keys, subscriptions, dictation and search. * Unknown variables stay masked/read-only at the end of Model API keys. * * API: GET /api/keys (masked statuses + the providers array), PUT * /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take * effect immediately. Plaintext never returns from the store. */ import { useEffect, useRef, useState } from "react"; import { AUTH_KEYS, CUSTOM_PROVIDER, type AuthKeyStatus, type CustomModel } from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; import { SubscriptionConnections } from "./subscription-connections.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; import { DictationLanguageSetting } from "@/components/dictation-language"; /** One pi-ai provider with plain API-key auth (the Platform seam — * derived from pi-ai's own env-key table; no drift possible). */ export interface ProviderKeyInfo { id: string; envVar: string; label: string; } interface KeysResponse { keys: AuthKeyStatus[]; providers?: ProviderKeyInfo[]; } /** The curated tools group, pinned by id (the catalog owns the copy; * these ids are the contract). Derived from the catalog (search wave, * 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the * section can never drift from core/auth-keys.ts again. */ const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[]; /** The classification driving the table groups. Pure — exported for * tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis, * no special row — it rides the same provider classification. */ export function classifyKeys( statuses: readonly AuthKeyStatus[], providers: readonly ProviderKeyInfo[] | undefined, ): { /** Stored provider keys (env or file), in GET order. */ providerRows: AuthKeyStatus[]; /** The curated tools rows (synthesized when absent — always shown, * they carry the docs links a first boot needs). */ toolRows: AuthKeyStatus[]; /** Unknown file vars — preserved, read-only. */ unknownRows: AuthKeyStatus[]; /** Providers without a stored key — the Add dropdown's options. */ addable: ProviderKeyInfo[]; } { const byId = new Map(statuses.map((s) => [s.id, s])); const isTool = (id: string) => (TOOL_IDS as readonly string[]).includes(id); const providerEnv = new Set((providers ?? []).map((p) => p.envVar)); const synth = (id: string): AuthKeyStatus => ({ id, info: null, present: false, masked: null, source: "none", shadowedByEnv: false, }); const providerRows: AuthKeyStatus[] = []; const unknownRows: AuthKeyStatus[] = []; for (const s of statuses) { if (isTool(s.id)) continue; // The custom endpoint's key has its OWN home (the Custom endpoint // section), so it never shows here — not as a provider row, not as an // "other stored variable". if (s.id === CUSTOM_PROVIDER.envVar) continue; if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s); else if (!s.info) unknownRows.push(s); // (pre-interlude catalogs: a curated-but-unclassified status still // renders as a provider row so nothing disappears) else providerRows.push(s); } return { providerRows, toolRows: TOOL_IDS.map((id) => byId.get(id) ?? synth(id)), unknownRows, addable: (providers ?? []).filter((p) => !byId.has(p.envVar)), }; } /** A key row names its credential, not whichever provider sorts first. * Pi exposes Zen and Go separately but both read OPENCODE_API_KEY. */ export function providerKeyLabel(id: string, providers: readonly ProviderKeyInfo[] | undefined): string | undefined { const matches = providers?.filter((p) => p.envVar === id); if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)"; return matches?.[0]?.label; } /** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an * environment-owned value — the row itself is read-only then). Pure. */ export function keyStatusLine(status: AuthKeyStatus): string { if (!status.present || !status.masked) return "not set"; const tail = status.masked.replace(/ \((\d+) chars\)$/, ""); const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?"; return `${tail} · ${chars}${status.source === "env" ? " · env" : ""}`; } /** One table row: label left, tiny docs link + status right. Writable * rows click-unfold an editor beneath (owner interaction); env-owned * rows are dimmed and read-only (a paste box there would mislead). */ function KeyTableRow({ label, docsUrl, status, expanded, onToggle, onSave, onDelete, onCancel, testid, }: { label: string; /** The "Get a key ↗" target — curated keys carry one. */ docsUrl?: string; status: AuthKeyStatus; expanded: boolean; onToggle: () => void; onSave: (value: string) => Promise; onDelete?: () => Promise; /** Folds the editor (owner, fix wave: the way OUT of a paste prompt * must be visible — Cancel beside Save/Delete, not only Esc). */ onCancel: () => void; testid: string; }) { const envOwned = status.source === "env"; const [value, setValue] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); async function save() { if (!value.trim() || busy) return; setBusy(true); setError(null); const err = await onSave(value); if (err) setError(err); else setValue(""); setBusy(false); } // INLINE editing (owner, fix wave: "type the keys in line — more // table-like"): the expanded row becomes the editor itself — label // left, the paste field filling the middle, Save | Cancel (| Delete) // right — instead of a second line beneath the row. if (expanded && !envOwned) { return (
{label} setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void save(); }} placeholder="Paste key…" aria-label={`${label} key`} data-testid={`${testid}-input`} disabled={busy} autoFocus className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" /> {status.present && status.source === "file" && onDelete && ( )}
{error && (

{error}

)}
); } return (
{error && (

{error}

)}
); } /** The Add-provider control — the table's last row, the app's shared * dropdown look (portaled menu, the search + arrow-walk machine). */ function AddProviderRow({ addable, onPick, }: { addable: ProviderKeyInfo[]; onPick: (p: ProviderKeyInfo) => void; }) { const [open, setOpen] = useState(false); const anchorRef = useRef(null); useEffect(() => { if (!open) return; const onPointerDown = (e: PointerEvent) => { const target = e.target as Node; if (anchorRef.current?.contains(target)) return; if (target instanceof Element && target.closest("[data-lh-menu]")) return; if (target instanceof Element && target.closest('[role="dialog"]')) setOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); setOpen(false); } }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [open]); return (
{open && ( {/* Close on pick (owner, fix wave): a chosen provider ends the dropdown — the paste row is the next step. */} { setOpen(false); onPick(p); }} /> )}
); } export function KeysSection({ /** A deep-linked row to unfold on arrival (zero-keys wave): the * dictation error's click lands here on DEEPGRAM_API_KEY. Followed * while mounted too — the settings dialog's while-open retarget * pattern. undefined = nothing seeded. */ initialRow, }: { initialRow?: string; }) { const [statuses, setStatuses] = useState(null); const [providers, setProviders] = useState(undefined); const [loading, setLoading] = useState(true); /** The unfolded row (its id), null = all folded. Seeded by the * deep-link (initialRow) and re-followed when it retargets. */ const [openRow, setOpenRow] = useState(initialRow ?? null); /** The paste editor for a freshly chosen Add-provider target. */ const [adding, setAdding] = useState(null); const [addError, setAddError] = useState(null); // Escape folds any open editor (unfolded row or the Add-provider paste) // WITHOUT closing the dialog. Claimed at document CAPTURE phase — the // only stopPropagation that outruns both the input's React handler and // the dialog's window-level listener. useEffect(() => { if (openRow === null && adding === null) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // A language dropdown above this editor in the dismissal stack owns // the first Escape; do not swallow it at document capture phase. if (document.querySelector('[data-testid="dictation-language-menu"]')) return; e.preventDefault(); e.stopPropagation(); setOpenRow(null); setAdding(null); }; document.addEventListener("keydown", onKeyDown, true); return () => document.removeEventListener("keydown", onKeyDown, true); }, [openRow, adding]); const reload = () => fetch("/api/keys") .then((r) => r.json()) .then((body: KeysResponse) => { setStatuses(body.keys ?? []); if (body.providers) setProviders(body.providers); }) .catch(() => setStatuses([])) .finally(() => setLoading(false)); useEffect(() => { void reload(); }, []); // The deep-link follow: a retarget while the section is open (the // same while-open pattern the dialog's section nav uses) unfolds the // named row; a fresh mount seeds it via the useState initializer. useEffect(() => { if (initialRow !== undefined) setOpenRow(initialRow); }, [initialRow]); async function saveKey(id: string, value: string): Promise { const res = await fetch(`/api/keys/${id}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ value }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; return body.error ?? `Save failed (${res.status})`; } await reload(); // The cross-surface keys signal (zero-keys wave): the zero-keys pill, // the setup dialog's stage advance, and any other keys reader hear // that a key landed — deletions included (see deleteKey below). notifySettingsSaved("keys"); return null; } async function deleteKey(id: string): Promise { await fetch(`/api/keys/${id}`, { method: "DELETE" }).catch(() => undefined); await reload(); notifySettingsSaved("keys"); } if (loading || statuses === null) { return

Loading keys…

; } const { providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers); async function saveNewProvider(p: ProviderKeyInfo, value: string) { const err = await saveKey(p.envVar, value); if (err) setAddError(err); else { setAdding(null); setAddError(null); } } return ( <> {providerRows.map((status) => ( setOpenRow(openRow === status.id ? null : status.id)} onSave={async (v) => { const err = await saveKey(status.id, v); if (!err) setOpenRow(null); // fold on success — rest state return err; }} onCancel={() => setOpenRow(null)} onDelete={() => deleteKey(status.id)} testid={`key-${status.id.toLowerCase()}`} /> ))} {providers !== undefined && ( { setAdding(p); setAddError(null); }} /> )} {adding && (
{adding.label} void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
)} {unknownRows.length > 0 &&
{unknownRows.map((status) =>
{status.id} {status.masked}
)}
}
{(["Dictation", "Search"] as const).map((label) => {toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => ( setOpenRow(openRow === status.id ? null : status.id)} onSave={async (v) => { const err = await saveKey(status.id, v); if (!err) setOpenRow(null); return err; }} onCancel={() => setOpenRow(null)} onDelete={() => deleteKey(status.id)} testid={`key-${status.id.toLowerCase()}`} /> ))} {label === "Dictation" && <>

Create a Deepgram API key with the Member role or higher, then add it above.

}
)} ); } /** The paste field for a provider chosen in the Add dropdown — rendered * beneath the Add row until saved or cancelled. */ function AddProviderEditor({ provider, onSave, onCancel, error, }: { provider: ProviderKeyInfo; onSave: (value: string) => void; onCancel: () => void; error: string | null; }) { const [value, setValue] = useState(""); return ( <> setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && value.trim()) onSave(value); }} placeholder="Paste key…" aria-label={`${provider.label} key`} data-testid={`key-${provider.id}-input`} autoFocus className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" /> {error && (

{error}

)} ); } /** The custom OpenAI-compatible endpoint's row in the provider list — one more * provider, connected by a base URL + (optional) key exactly like the built-ins * are connected by a key (the URL is the one extra field). Its status reads * GET /api/custom-provider; Connect POSTs the connect (validate + discover * models + register) and dispatches the keys signal so the catalog widens with * no restart. Keyless local servers (Ollama / LM Studio) connect with no key. */ function CustomProviderRow() { const [status, setStatus] = useState<{ baseUrl: string; models: CustomModel[]; keyPresent: boolean } | null>(null); const [expanded, setExpanded] = useState(false); const [baseUrl, setBaseUrl] = useState(""); const [key, setKey] = useState(""); const [keyTouched, setKeyTouched] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const reload = () => fetch("/api/custom-provider") .then((r) => r.json()) .then((b: { baseUrl?: string; models?: CustomModel[]; keyPresent?: boolean }) => setStatus({ baseUrl: b.baseUrl ?? "", models: b.models ?? [], keyPresent: Boolean(b.keyPresent) }), ) .catch(() => setStatus({ baseUrl: "", models: [], keyPresent: false })); useEffect(() => { void reload(); }, []); async function save() { if (busy || !baseUrl.trim()) return; setBusy(true); setError(null); const res = await fetch("/api/custom-provider", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ baseUrl, ...(keyTouched ? { key } : {}) }), }).catch(() => null); setBusy(false); if (!res || !res.ok) { const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection."); return; } setExpanded(false); setKey(""); setKeyTouched(false); await reload(); notifySettingsSaved("keys"); } // Flip one model's image capability and persist it (capability update — no // re-discovery, no key change). Uses the CONNECTED base URL (status.baseUrl), // not the editor's possibly-edited field, so toggles always apply to the // endpoint whose models are listed. async function toggleImage(id: string) { if (!status?.baseUrl || busy) return; const models = status.models.map((m) => (m.id === id ? { ...m, image: !m.image } : m)); setBusy(true); setError(null); const res = await fetch("/api/custom-provider", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ baseUrl: status.baseUrl, models }), }).catch(() => null); setBusy(false); if (!res || !res.ok) { const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; setError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection."); return; } await reload(); notifySettingsSaved("keys"); } if (expanded) { return (
Custom endpoint setBaseUrl(e.target.value)} placeholder="Base URL, e.g. http://localhost:11434/v1" aria-label="Custom endpoint base URL" data-testid="custom-baseurl-input" disabled={busy} autoFocus className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" />
{ setKey(e.target.value); setKeyTouched(true); }} placeholder={status?.keyPresent ? "Key set — paste to replace, empty to clear" : "API key (optional for local servers)"} aria-label="Custom endpoint API key" data-testid="custom-key-input" disabled={busy} className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" />
{status && status.models.length > 0 && (
Models — check the ones that accept images (needed for the Vision slot) {status.models.map((m) => ( ))}
)} {error && (

{error}

)}
); } const count = status?.models.length ?? 0; const imgCount = status?.models.filter((m) => m.image).length ?? 0; const line = status?.baseUrl ? `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${imgCount ? ` · ${imgCount} image` : ""}${status.keyPresent ? "" : " · keyless"}` : "not set"; return (
); }