diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 5192655..e84d5e9 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -1,78 +1,64 @@ "use client"; /** - * The Keys section (auth/provider-keys wave, 2026-09-20): BYOK keys over - * the merged auth API — GET /api/keys (masked statuses, never plaintext), - * PUT /api/keys/:id (shape-gated 400s), DELETE /api/keys/:id. Saves take - * effect immediately (file + env reflection, no restart). + * 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). * - * Two groups (owner design): - * - Model providers — free-form, pi-compatible. OpenRouter is pinned as - * an always-visible row (the app's lifeline: every model call today), - * other providers appear when stored, and an Add-provider dropdown - * lists every pi-ai provider that takes an API key. The list arrives as - * GET /api/keys' optional `providers` array (Platform interlude; the - * rolePins pattern — absent = the dropdown waits, everything else - * works). Provider ids/env vars come from pi-ai's own table, so this - * UI cannot drift from what pi actually reads. - * - Tools & services — the curated rows: Deepgram (dictation) and the - * Google pair (search key + CSE ID), each with description + docs - * link (opens in the real browser; the Electron shell routes it). + * 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. + * - Tools & services (Deepgram, the Google pair) keep their own group, + * same look, same interaction. * - * Owner UX decisions: always-visible paste inputs (no reveal affordance — - * plaintext never returns from the store, masked tail + length only); - * bad pastes show the server's format message inline; a dev .env.local - * variable beats the saved file value and the row says so honestly. - * - * Unknown auth.json vars surface as read-only dimmed rows — preserved by - * the store (forward-compat), not editable through the curated surface. + * 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, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; -import { SettingsGroup, SettingsRow } from "./controls.tsx"; +import { SettingsGroup } from "./controls.tsx"; +import { ProviderMenuList } from "./model-menu.tsx"; -/** One pi-ai provider with plain API-key auth (the Platform seam). */ +/** 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 { - /** pi-ai provider id ("groq", "deepseek", …). */ id: string; - /** The env var / auth.json key a paste writes ("GROQ_API_KEY", …). */ envVar: string; label: string; } -/** GET /api/keys — merged shape plus the interlude's optional array. */ interface KeysResponse { keys: AuthKeyStatus[]; providers?: ProviderKeyInfo[]; } -/** The OpenRouter row survives its curated-catalog graduation: the label - * is pinned here so the row reads correctly even when the catalog no - * longer carries it (first boot: absent, not even a status row). */ -const OPENROUTER_ID = "OPENROUTER_API_KEY"; -const OPENROUTER_FALLBACK = { - label: "OpenRouter", - description: "The main model key — every teacher, prober and sub-agent call goes through it.", - docsUrl: "https://openrouter.ai/settings/keys", -}; - /** The curated tools group, pinned by id (the catalog owns the copy; - * these ids are the contract). */ -// The curated app-service keys, 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. + * 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 that drives the section's two groups. Pure — - * exported for tests. */ +/** 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, ): { - openrouter: AuthKeyStatus; - /** Stored non-OpenRouter provider keys, with their provider entry. */ - providerRows: { status: AuthKeyStatus; provider?: ProviderKeyInfo }[]; - /** The curated tools rows (synthesized when absent = first boot). */ + /** 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[]; @@ -80,7 +66,8 @@ export function classifyKeys( addable: ProviderKeyInfo[]; } { const byId = new Map(statuses.map((s) => [s.id, s])); - const providerByEnv = new Map((providers ?? []).map((p) => [p.envVar, p])); + 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, @@ -89,60 +76,61 @@ export function classifyKeys( source: "none", shadowedByEnv: false, }); - const isTool = (id: string) => (TOOL_IDS as readonly string[]).includes(id); - const providerRows: { status: AuthKeyStatus; provider?: ProviderKeyInfo }[] = []; + const providerRows: AuthKeyStatus[] = []; const unknownRows: AuthKeyStatus[] = []; for (const s of statuses) { - if (s.id === OPENROUTER_ID || isTool(s.id)) continue; - const provider = providerByEnv.get(s.id); - if (provider) providerRows.push({ status: s, provider }); + if (isTool(s.id)) continue; + if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s); else if (!s.info) unknownRows.push(s); - // a curated-but-present status outside the pinned ids: treat as a - // provider row (pre-interlude the catalog only has the pinned ids). - else providerRows.push({ status: s }); + // (pre-interlude catalogs: a curated-but-unclassified status still + // renders as a provider row so nothing disappears) + else providerRows.push(s); } return { - openrouter: byId.get(OPENROUTER_ID) ?? synth(OPENROUTER_ID), providerRows, toolRows: TOOL_IDS.map((id) => byId.get(id) ?? synth(id)), unknownRows, - addable: (providers ?? []).filter((p) => p.envVar !== OPENROUTER_ID && !byId.has(p.envVar)), + addable: (providers ?? []).filter((p) => !byId.has(p.envVar)), }; } -/** The masked status line, e.g. "…ab12 · 64 chars · saved" — the row's - * at-a-glance state. Pure — tests. */ -export function keyStatusLine(status: AuthKeyStatus): string | null { - if (!status.present || !status.masked) return null; - const where = status.source === "env" ? "environment" : "saved"; - return `${status.masked.replace(/ \(\d+ chars\)$/, "")} · ${status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?"} chars · ${where}`; +/** 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 editable key row: status + paste input + Save + (Delete when - * present). The 400 message lands inline under the input. */ -function KeyEditorRow({ +/** 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, - hint, docsUrl, status, + expanded, + onToggle, onSave, onDelete, testid, }: { label: string; - hint?: string; + /** The "Get a key ↗" target — curated keys carry one. */ docsUrl?: string; status: AuthKeyStatus; + expanded: boolean; + onToggle: () => void; onSave: (value: string) => Promise; onDelete?: () => Promise; testid: string; }) { + const envOwned = status.source === "env"; const [value, setValue] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const statusLine = keyStatusLine(status); - async function save() { if (!value.trim() || busy) return; setBusy(true); @@ -154,67 +142,73 @@ function KeyEditorRow({ } return ( -
- - {statusLine && ( - - {statusLine} - {status.shadowedByEnv && ( - — an environment variable owns this name; the saved value is stored but not live - )} +
+ - {status.present && onDelete && ( + + + {expanded && !envOwned && ( +
+ 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} + className="h-7 w-64 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}

)} @@ -222,12 +216,98 @@ function KeyEditorRow({ ); } +/** 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]); + + const style = open && anchorRef.current ? (() => { + const r = anchorRef.current.getBoundingClientRect(); + return { position: "fixed" as const, right: window.innerWidth - r.right, top: r.bottom + 4, zIndex: 60 }; + })() : null; + + return ( +
+ + {open && style !== null && createPortal( +
+ +
, + document.body, + )} +
+ ); +} + export function KeysSection() { const [statuses, setStatuses] = useState(null); const [providers, setProviders] = useState(undefined); - /** The provider chosen in the Add dropdown, if any. */ - const [adding, setAdding] = useState(null); const [loading, setLoading] = useState(true); + /** The unfolded row (its id), null = all folded. */ + const [openRow, setOpenRow] = useState(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; + 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") @@ -266,98 +346,70 @@ export function KeysSection() { return

Loading keys…

; } - const { openrouter, providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers); - const envWarning = openrouter.shadowedByEnv; + 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 ( <> - saveKey(OPENROUTER_ID, v)} - onDelete={() => deleteKey(OPENROUTER_ID)} - /> - {providerRows.map(({ status, provider }) => ( - ( + p.envVar === status.id)?.label ?? status.info?.label ?? status.id} docsUrl={status.info?.docsUrl} status={status} - testid={`key-${provider?.id ?? status.id.toLowerCase()}`} - onSave={(v) => saveKey(status.id, v)} + expanded={openRow === status.id} + onToggle={() => 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; + }} onDelete={() => deleteKey(status.id)} + testid={`key-${status.id.toLowerCase()}`} /> ))} - {providers === undefined ? ( -

- The full provider list ships with the server update. -

- ) : ( -
-
- Add provider key - -
- {adding && ( -
- s.id === adding.envVar) ?? { - id: adding.envVar, - info: null, - present: false, - masked: null, - source: "none" as const, - shadowedByEnv: false, - }} - testid={`key-${adding.id}`} - onSave={(v) => saveKey(adding.envVar, v)} - onDelete={() => deleteKey(adding.envVar)} - /> -
- )} -
+ {providers !== undefined && ( + { + setAdding(p); + setAddError(null); + }} + /> )} - {envWarning && ( -

- The environment variable {OPENROUTER_ID} is set — it wins over any key saved here. -

+ {adding && ( +
+ {adding.label} + void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} /> +
)}
{toolRows.map((status) => ( - saveKey(status.id, v)} + expanded={openRow === status.id} + onToggle={() => setOpenRow(openRow === status.id ? null : status.id)} + onSave={async (v) => { + const err = await saveKey(status.id, v); + if (!err) setOpenRow(null); + return err; + }} onDelete={() => deleteKey(status.id)} + testid={`key-${status.id.toLowerCase()}`} /> ))} @@ -365,18 +417,71 @@ export function KeysSection() { {unknownRows.length > 0 && ( {unknownRows.map((status) => ( - + {status.id} {status.masked} - +
))} )} ); } + +/** 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`} + className="h-7 w-64 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} +

+ )} + + ); +} diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 6238897..24c74d3 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -175,4 +175,93 @@ export function ThinkingMenuList({ )} ); -} \ No newline at end of file +} + +/** The provider-picker dropdown body (keys wave-5 polish, 2026-09-21: + * the owner wanted the Add-provider control to match the app's dropdown + * language) — the same fuzzy-search + arrow-walk + Enter machine over + * the pi-ai provider table. Rows read: provider label, its env var in + * quiet mono on the right. */ +export function ProviderMenuList({ + providers, + onPick, +}: { + providers: { id: string; envVar: string; label: string }[]; + onPick: (provider: { id: string; envVar: string; label: string }) => void; +}) { + const [query, setQuery] = useState(""); + const [menuIdx, setMenuIdx] = useState(null); + const searchRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + useEffect(() => { + if (menuIdx === null) return; + listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); + }, [menuIdx]); + + const results = providers + .map((p) => ({ p, s: fuzzyScore(query, p.id, p.envVar, p.label) })) + .filter((r) => r.s !== null) + .sort((a, b) => b.s! - a.s! || a.p.id.localeCompare(b.p.id)) + .map((r) => r.p); + + return ( + <> + { + setQuery(e.target.value); + setMenuIdx(null); + }} + onKeyDown={(e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + if (results.length === 0) return; + setMenuIdx((i) => + e.key === "ArrowDown" + ? Math.min((i ?? -1) + 1, results.length - 1) + : Math.max((i ?? results.length) - 1, 0), + ); + } else if (e.key === "Enter") { + const chosen = results[menuIdx ?? 0]; + if (chosen) { + e.preventDefault(); + e.stopPropagation(); + onPick(chosen); + } + } + }} + placeholder="Search providers…" + className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" + /> + {results.length === 0 && ( +
No providers match.
+ )} +
    + {results.map((p, i) => ( +
  • + +
  • + ))} +
+ + ); +} diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts index ad29e39..4bee66f 100644 --- a/tests/settings-pure.test.ts +++ b/tests/settings-pure.test.ts @@ -155,18 +155,20 @@ describe("classifyKeys — the Keys section's two-group projection", () => { { id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" }, ]; - it("OpenRouter is pinned: synthesized when absent (first boot), found when present", () => { + it("OpenRouter is UN-pinned (wave-5 owner ruling): no row when absent, a row when stored", () => { const none = classifyKeys([], providers); - assert.equal(none.openrouter.id, "OPENROUTER_API_KEY"); - assert.ok(!none.openrouter.present); + assert.equal(none.providerRows.length, 0); // nothing synthesized + assert.ok(none.addable.some((p) => p.id === "openrouter")); // it's just an Add option const stored = classifyKeys([synth("OPENROUTER_API_KEY", true)], providers); - assert.ok(stored.openrouter.present); + assert.equal(stored.providerRows.length, 1); + assert.equal(stored.providerRows[0]!.id, "OPENROUTER_API_KEY"); + assert.ok(!stored.addable.some((p) => p.id === "openrouter")); }); - it("stored provider keys land in providerRows with their provider entry", () => { + it("stored provider keys land in providerRows; the Add dropdown drops them", () => { const out = classifyKeys([synth("GROQ_API_KEY", true)], providers); assert.equal(out.providerRows.length, 1); - assert.equal(out.providerRows[0]!.provider?.id, "groq"); + assert.equal(out.providerRows[0]!.id, "GROQ_API_KEY"); // the Add dropdown no longer offers groq assert.ok(!out.addable.some((p) => p.id === "groq")); assert.ok(out.addable.some((p) => p.id === "deepseek")); @@ -195,19 +197,22 @@ describe("classifyKeys — the Keys section's two-group projection", () => { }); }); -describe("keyStatusLine — the masked at-a-glance state", () => { - it("present file key: tail, chars, source", () => { +describe("keyStatusLine — the minimal at-a-glance state", () => { + it("present file key: tail + chars, no source suffix", () => { assert.equal( keyStatusLine({ id: "X", info: null, present: true, masked: "…ab12 (64 chars)", source: "file", shadowedByEnv: false }), - "…ab12 · 64 chars · saved", + "…ab12 · 64", ); }); - it("env-sourced key reads 'environment'", () => { - assert.ok(keyStatusLine({ id: "X", info: null, present: true, masked: "…ab12 (64 chars)", source: "env", shadowedByEnv: false })?.endsWith("environment")); + it("env-owned key carries the 'env' marker (the row renders read-only)", () => { + assert.equal( + keyStatusLine({ id: "X", info: null, present: true, masked: "…ca10 (73 chars)", source: "env", shadowedByEnv: false }), + "…ca10 · 73 · env", + ); }); - it("absent key has no line", () => { - assert.equal(keyStatusLine({ id: "X", info: null, present: false, masked: null, source: "none", shadowedByEnv: false }), null); + it("absent key reads 'not set'", () => { + assert.equal(keyStatusLine({ id: "X", info: null, present: false, masked: null, source: "none", shadowedByEnv: false }), "not set"); }); });