Keys section polish (wave-5 chunk A, owner design): the minimal key-value table

The owner called the old section messy; the redesign is theirs:

- MINIMAL TABLE: provider label left, masked status right (…tail ·
  chars · env), description hints GONE (they took too much space),
  the 'Get a key ↗' link a tiny muted guest on curated rows. Rows
  read as clean key-value pairs.
- ENV-OWNED ROWS ARE READ-ONLY (owner ruling): a real environment
  variable owns the value — the row dims (graph-view graying) and no
  paste field renders, because a paste box on a key the UI cannot
  make live would mislead. Clicking does nothing.
- OPENROUTER UN-PINNED (owner ruling): one provider among 33 — a row
  only when stored, otherwise it's an option in the Add dropdown like
  everyone else. No synthesis, no lifeline description.
- ROW INTERACTION (owner choice): clicking a writable row unfolds a
  paste field + Save (+ Delete for a file-sourced value) beneath it;
  success folds it back to rest; Esc folds without closing the dialog
  (claimed at document CAPTURE phase — the only stopPropagation that
  outruns both React handlers and the dialog's window listener; the
  React-level attempt leaked and closed the dialog, caught live).
- ADD-PROVIDER = the table's last row, in the app's shared dropdown
  language: new ProviderMenuList (fuzzy search + arrow-walk + Enter
  over the 33 pi-ai providers, label + envVar rows) in the shared
  model-menu.tsx, portaled and fixed-positioned like the role menus.
  Picking one unfolds a paste row; Save stores, Cancel/Esc folds.
- Tools & services (Deepgram + the Google pair) keep their own group,
  same table look, same unfold interaction.
- Unknown auth.json vars stay read-only dimmed rows.

Tests: classifyKeys re-pinned (openrouter rides the normal provider
classification; addable drops stored keys), keyStatusLine minimal
('not set' for absent, ' · env' marker for environment-owned). Gates:
typecheck, lint, 759/759, build.

Live-verified: add→save→replace→delete round-trips, fold-on-save,
Esc layering both flows, env-owned rows read-only, addable count
31→30→31.
This commit is contained in:
amosblomqvist
2026-09-22 00:16:23 +03:00
parent f9e3a4c43d
commit 21d288e9f9
3 changed files with 420 additions and 221 deletions
+277 -172
View File
@@ -1,78 +1,64 @@
"use client"; "use client";
/** /**
* The Keys section (auth/provider-keys wave, 2026-09-20): BYOK keys over * The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
* the merged auth API — GET /api/keys (masked statuses, never plaintext), * key-value table. Provider left, masked key right, no description hints
* PUT /api/keys/:id (shape-gated 400s), DELETE /api/keys/:id. Saves take * (they took too much space), the "Get a key ↗" link a tiny muted guest.
* effect immediately (file + env reflection, no restart). * 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): * Owner rulings carried in:
* - Model providers — free-form, pi-compatible. OpenRouter is pinned as * - Env-owned rows (a real environment variable owns the name — dev
* an always-visible row (the app's lifeline: every model call today), * .env.local or launch env) render READ-ONLY + dimmed, no paste field:
* other providers appear when stored, and an Add-provider dropdown * a paste box on a key the UI cannot make live would mislead.
* lists every pi-ai provider that takes an API key. The list arrives as * - OpenRouter is NOT special: one provider among 33, a row only when
* GET /api/keys' optional `providers` array (Platform interlude; the * stored, otherwise it lives in the Add dropdown like everyone else.
* rolePins pattern — absent = the dropdown waits, everything else * - Tools & services (Deepgram, the Google pair) keep their own group,
* works). Provider ids/env vars come from pi-ai's own table, so this * same look, same interaction.
* 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 UX decisions: always-visible paste inputs (no reveal affordance — * API: GET /api/keys (masked statuses + the providers array), PUT
* plaintext never returns from the store, masked tail + length only); * /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
* bad pastes show the server's format message inline; a dev .env.local * effect immediately. Plaintext never returns from the store.
* 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.
*/ */
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 { 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 { export interface ProviderKeyInfo {
/** pi-ai provider id ("groq", "deepseek", …). */
id: string; id: string;
/** The env var / auth.json key a paste writes ("GROQ_API_KEY", …). */
envVar: string; envVar: string;
label: string; label: string;
} }
/** GET /api/keys — merged shape plus the interlude's optional array. */
interface KeysResponse { interface KeysResponse {
keys: AuthKeyStatus[]; keys: AuthKeyStatus[];
providers?: ProviderKeyInfo[]; 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; /** The curated tools group, pinned by id (the catalog owns the copy;
* these ids are the contract). */ * these ids are the contract). Derived from the catalog (search wave,
// The curated app-service keys, derived from the catalog (search wave, * 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the
// 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the * section can never drift from core/auth-keys.ts again. */
// section can never drift from core/auth-keys.ts again.
const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[]; const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[];
/** The classification that drives the section's two groups. Pure — /** The classification driving the table groups. Pure — exported for
* exported for tests. */ * tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
* no special row — it rides the same provider classification. */
export function classifyKeys( export function classifyKeys(
statuses: readonly AuthKeyStatus[], statuses: readonly AuthKeyStatus[],
providers: readonly ProviderKeyInfo[] | undefined, providers: readonly ProviderKeyInfo[] | undefined,
): { ): {
openrouter: AuthKeyStatus; /** Stored provider keys (env or file), in GET order. */
/** Stored non-OpenRouter provider keys, with their provider entry. */ providerRows: AuthKeyStatus[];
providerRows: { status: AuthKeyStatus; provider?: ProviderKeyInfo }[]; /** The curated tools rows (synthesized when absent — always shown,
/** The curated tools rows (synthesized when absent = first boot). */ * they carry the docs links a first boot needs). */
toolRows: AuthKeyStatus[]; toolRows: AuthKeyStatus[];
/** Unknown file vars — preserved, read-only. */ /** Unknown file vars — preserved, read-only. */
unknownRows: AuthKeyStatus[]; unknownRows: AuthKeyStatus[];
@@ -80,7 +66,8 @@ export function classifyKeys(
addable: ProviderKeyInfo[]; addable: ProviderKeyInfo[];
} { } {
const byId = new Map(statuses.map((s) => [s.id, s])); 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 => ({ const synth = (id: string): AuthKeyStatus => ({
id, id,
info: null, info: null,
@@ -89,60 +76,61 @@ export function classifyKeys(
source: "none", source: "none",
shadowedByEnv: false, shadowedByEnv: false,
}); });
const isTool = (id: string) => (TOOL_IDS as readonly string[]).includes(id); const providerRows: AuthKeyStatus[] = [];
const providerRows: { status: AuthKeyStatus; provider?: ProviderKeyInfo }[] = [];
const unknownRows: AuthKeyStatus[] = []; const unknownRows: AuthKeyStatus[] = [];
for (const s of statuses) { for (const s of statuses) {
if (s.id === OPENROUTER_ID || isTool(s.id)) continue; if (isTool(s.id)) continue;
const provider = providerByEnv.get(s.id); if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s);
if (provider) providerRows.push({ status: s, provider });
else if (!s.info) unknownRows.push(s); else if (!s.info) unknownRows.push(s);
// a curated-but-present status outside the pinned ids: treat as a // (pre-interlude catalogs: a curated-but-unclassified status still
// provider row (pre-interlude the catalog only has the pinned ids). // renders as a provider row so nothing disappears)
else providerRows.push({ status: s }); else providerRows.push(s);
} }
return { return {
openrouter: byId.get(OPENROUTER_ID) ?? synth(OPENROUTER_ID),
providerRows, providerRows,
toolRows: TOOL_IDS.map((id) => byId.get(id) ?? synth(id)), toolRows: TOOL_IDS.map((id) => byId.get(id) ?? synth(id)),
unknownRows, 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 /** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* at-a-glance state. Pure — tests. */ * environment-owned value — the row itself is read-only then). Pure. */
export function keyStatusLine(status: AuthKeyStatus): string | null { export function keyStatusLine(status: AuthKeyStatus): string {
if (!status.present || !status.masked) return null; if (!status.present || !status.masked) return "not set";
const where = status.source === "env" ? "environment" : "saved"; const tail = status.masked.replace(/ \((\d+) chars\)$/, "");
return `${status.masked.replace(/ \(\d+ chars\)$/, "")} · ${status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?"} chars · ${where}`; 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 /** One table row: label left, tiny docs link + status right. Writable
* present). The 400 message lands inline under the input. */ * rows click-unfold an editor beneath (owner interaction); env-owned
function KeyEditorRow({ * rows are dimmed and read-only (a paste box there would mislead). */
function KeyTableRow({
label, label,
hint,
docsUrl, docsUrl,
status, status,
expanded,
onToggle,
onSave, onSave,
onDelete, onDelete,
testid, testid,
}: { }: {
label: string; label: string;
hint?: string; /** The "Get a key ↗" target — curated keys carry one. */
docsUrl?: string; docsUrl?: string;
status: AuthKeyStatus; status: AuthKeyStatus;
expanded: boolean;
onToggle: () => void;
onSave: (value: string) => Promise<string | null>; onSave: (value: string) => Promise<string | null>;
onDelete?: () => Promise<void>; onDelete?: () => Promise<void>;
testid: string; testid: string;
}) { }) {
const envOwned = status.source === "env";
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const statusLine = keyStatusLine(status);
async function save() { async function save() {
if (!value.trim() || busy) return; if (!value.trim() || busy) return;
setBusy(true); setBusy(true);
@@ -154,31 +142,36 @@ function KeyEditorRow({
} }
return ( return (
<div data-testid={testid}> <div data-testid={testid} className={envOwned ? "opacity-40" : undefined}>
<SettingsRow label={label} hint={hint} testid={`${testid}-row`}> <button
{statusLine && ( type="button"
<span aria-expanded={expanded}
data-testid={`${testid}-status`} data-testid={`${testid}-row`}
className="font-mono text-[11px] text-muted-foreground" onClick={() => !envOwned && onToggle()}
className={`flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left ${
envOwned ? "cursor-default" : "cursor-pointer transition-colors hover:bg-accent/50"
}`}
> >
{statusLine} <span className="font-ui text-xs text-foreground">{label}</span>
{status.shadowedByEnv && ( <span className="flex items-center gap-2">
<span className="text-foreground"> — an environment variable owns this name; the saved value is stored but not live</span> {docsUrl && (
)}
</span>
)}
<a <a
href={docsUrl ?? "about:blank"} href={docsUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
aria-disabled={!docsUrl} onClick={(e) => e.stopPropagation()}
onClick={docsUrl ? undefined : (e) => e.preventDefault()} className="text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline"
className={`text-[11px] underline-offset-2 hover:underline ${docsUrl ? "text-muted-foreground hover:text-foreground" : "invisible"}`}
> >
Get a key ↗ key ↗
</a> </a>
</SettingsRow> )}
<div className="mt-1 flex items-center gap-2 pl-6"> <span data-testid={`${testid}-status`} className="font-mono text-[11px] text-muted-foreground">
{keyStatusLine(status)}
</span>
</span>
</button>
{expanded && !envOwned && (
<div className="mt-0.5 mb-1 flex items-center gap-2 pl-6" data-testid={`${testid}-editor`}>
<input <input
type="text" type="text"
value={value} value={value}
@@ -190,7 +183,7 @@ function KeyEditorRow({
aria-label={`${label} key`} aria-label={`${label} key`}
data-testid={`${testid}-input`} data-testid={`${testid}-input`}
disabled={busy} disabled={busy}
className="h-7 w-72 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" 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"
/> />
<button <button
type="button" type="button"
@@ -201,7 +194,7 @@ function KeyEditorRow({
> >
Save Save
</button> </button>
{status.present && onDelete && ( {status.present && status.source === "file" && onDelete && (
<button <button
type="button" type="button"
onClick={() => void onDelete()} onClick={() => void onDelete()}
@@ -213,8 +206,9 @@ function KeyEditorRow({
</button> </button>
)} )}
</div> </div>
)}
{error && ( {error && (
<p role="alert" data-testid={`${testid}-error`} className="mt-1 pl-6 text-xs text-red-500"> <p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500">
{error} {error}
</p> </p>
)} )}
@@ -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<HTMLDivElement>(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 (
<div ref={anchorRef} className="relative">
<button
type="button"
aria-expanded={open}
data-testid="add-provider"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>+ Add provider key</span>
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
</button>
{open && style !== null && createPortal(
<div
data-testid="add-provider-menu"
data-lh-menu=""
style={style}
className="flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ProviderMenuList providers={addable} onPick={onPick} />
</div>,
document.body,
)}
</div>
);
}
export function KeysSection() { export function KeysSection() {
const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null); const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined); const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
/** The provider chosen in the Add dropdown, if any. */
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
/** The unfolded row (its id), null = all folded. */
const [openRow, setOpenRow] = useState<string | null>(null);
/** The paste editor for a freshly chosen Add-provider target. */
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
const [addError, setAddError] = useState<string | null>(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 = () => const reload = () =>
fetch("/api/keys") fetch("/api/keys")
@@ -266,98 +346,70 @@ export function KeysSection() {
return <p className="px-6 py-5 text-xs text-muted-foreground/70">Loading keys…</p>; return <p className="px-6 py-5 text-xs text-muted-foreground/70">Loading keys…</p>;
} }
const { openrouter, providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers); const { providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers);
const envWarning = openrouter.shadowedByEnv;
async function saveNewProvider(p: ProviderKeyInfo, value: string) {
const err = await saveKey(p.envVar, value);
if (err) setAddError(err);
else {
setAdding(null);
setAddError(null);
}
}
return ( return (
<> <>
<SettingsGroup label="Model providers" testid="group-provider-keys"> <SettingsGroup label="Model providers" testid="group-provider-keys">
<KeyEditorRow {providerRows.map((status) => (
label={openrouter.info?.label ?? OPENROUTER_FALLBACK.label} <KeyTableRow
hint={openrouter.info?.description ?? OPENROUTER_FALLBACK.description}
docsUrl={openrouter.info?.docsUrl ?? OPENROUTER_FALLBACK.docsUrl}
status={openrouter}
testid="key-openrouter"
onSave={(v) => saveKey(OPENROUTER_ID, v)}
onDelete={() => deleteKey(OPENROUTER_ID)}
/>
{providerRows.map(({ status, provider }) => (
<KeyEditorRow
key={status.id} key={status.id}
label={provider?.label ?? status.id} label={providers?.find((p) => p.envVar === status.id)?.label ?? status.info?.label ?? status.id}
hint={status.info?.description ?? provider?.id}
docsUrl={status.info?.docsUrl} docsUrl={status.info?.docsUrl}
status={status} status={status}
testid={`key-${provider?.id ?? status.id.toLowerCase()}`} expanded={openRow === status.id}
onSave={(v) => saveKey(status.id, v)} 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)} onDelete={() => deleteKey(status.id)}
testid={`key-${status.id.toLowerCase()}`}
/> />
))} ))}
{providers === undefined ? ( {providers !== undefined && (
<p className="text-xs text-muted-foreground/60" data-testid="providers-pending"> <AddProviderRow
The full provider list ships with the server update. addable={addable}
</p> onPick={(p) => {
) : ( setAdding(p);
<div className="mt-1 pl-6" data-testid="add-provider"> setAddError(null);
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Add provider key</span>
<select
aria-label="Add provider key"
data-testid="add-provider-select"
value={adding?.id ?? ""}
onChange={(e) => {
const next = addable.find((p) => p.id === e.target.value) ?? null;
setAdding(next);
}} }}
className="h-7 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground outline-none" />
> )}
<option value="">Choose a provider…</option>
{addable.map((p) => (
<option key={p.id} value={p.id}>
{p.label} ({p.envVar})
</option>
))}
</select>
</div>
{adding && ( {adding && (
<div className="mt-2"> <div className="mt-0.5 mb-1 flex items-center gap-2 pl-6" data-testid={`key-${adding.id}-editor`}>
<KeyEditorRow <span className="font-ui text-xs text-foreground">{adding.label}</span>
label={adding.label} <AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
hint={adding.id}
status={statuses.find((s) => 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)}
/>
</div> </div>
)} )}
</div>
)}
{envWarning && (
<p className="text-xs text-foreground" data-testid="openrouter-shadowed">
The environment variable {OPENROUTER_ID} is set — it wins over any key saved here.
</p>
)}
</SettingsGroup> </SettingsGroup>
<SettingsGroup label="Tools & services" testid="group-tool-keys"> <SettingsGroup label="Tools & services" testid="group-tool-keys">
{toolRows.map((status) => ( {toolRows.map((status) => (
<KeyEditorRow <KeyTableRow
key={status.id} key={status.id}
label={status.info?.label ?? status.id} label={status.info?.label ?? status.id}
hint={status.info?.description}
docsUrl={status.info?.docsUrl} docsUrl={status.info?.docsUrl}
status={status} status={status}
testid={`key-${status.id.toLowerCase()}`} expanded={openRow === status.id}
onSave={(v) => saveKey(status.id, v)} 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)} onDelete={() => deleteKey(status.id)}
testid={`key-${status.id.toLowerCase()}`}
/> />
))} ))}
</SettingsGroup> </SettingsGroup>
@@ -365,18 +417,71 @@ export function KeysSection() {
{unknownRows.length > 0 && ( {unknownRows.length > 0 && (
<SettingsGroup label="Other stored variables" testid="group-unknown-keys"> <SettingsGroup label="Other stored variables" testid="group-unknown-keys">
{unknownRows.map((status) => ( {unknownRows.map((status) => (
<SettingsRow <div
key={status.id} key={status.id}
label={status.id} data-testid={`unknown-${status.id}`}
hint="preserved from auth.json — read-only" className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40"
testid={`unknown-${status.id}`}
dimmed
> >
<span className="font-mono text-xs text-foreground">{status.id}</span>
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span> <span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
</SettingsRow> </div>
))} ))}
</SettingsGroup> </SettingsGroup>
)} )}
</> </>
); );
} }
/** 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 (
<>
<input
type="text"
value={value}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => value.trim() && onSave(value)}
disabled={!value.trim()}
data-testid={`key-${provider.id}-save`}
className="text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
Save
</button>
<button
type="button"
onClick={onCancel}
data-testid={`key-${provider.id}-cancel`}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>
{error && (
<p role="alert" className="w-full pl-0 text-xs text-red-500" data-testid={`key-${provider.id}-error`}>
{error}
</p>
)}
</>
);
}
+89
View File
@@ -176,3 +176,92 @@ export function ThinkingMenuList({
</> </>
); );
} }
/** 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<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(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 (
<>
<input
ref={searchRef}
data-testid="provider-search"
value={query}
onChange={(e) => {
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 && (
<div className="px-2 py-3 text-center text-muted-foreground">No providers match.</div>
)}
<ul ref={listRef} data-testid="provider-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((p, i) => (
<li key={p.id}>
<button
type="button"
data-testid={`provider-option-${p.id.replace(/[^a-z0-9-]/gi, "_")}`}
data-idx={i}
onClick={() => onPick(p)}
className={`flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}`}
>
<span className="truncate text-card-foreground">{p.label}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">{p.envVar}</span>
</button>
</li>
))}
</ul>
</>
);
}
+18 -13
View File
@@ -155,18 +155,20 @@ describe("classifyKeys — the Keys section's two-group projection", () => {
{ id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" }, { 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); const none = classifyKeys([], providers);
assert.equal(none.openrouter.id, "OPENROUTER_API_KEY"); assert.equal(none.providerRows.length, 0); // nothing synthesized
assert.ok(!none.openrouter.present); assert.ok(none.addable.some((p) => p.id === "openrouter")); // it's just an Add option
const stored = classifyKeys([synth("OPENROUTER_API_KEY", true)], providers); 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); const out = classifyKeys([synth("GROQ_API_KEY", true)], providers);
assert.equal(out.providerRows.length, 1); 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 // the Add dropdown no longer offers groq
assert.ok(!out.addable.some((p) => p.id === "groq")); assert.ok(!out.addable.some((p) => p.id === "groq"));
assert.ok(out.addable.some((p) => p.id === "deepseek")); 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", () => { describe("keyStatusLine — the minimal at-a-glance state", () => {
it("present file key: tail, chars, source", () => { it("present file key: tail + chars, no source suffix", () => {
assert.equal( assert.equal(
keyStatusLine({ id: "X", info: null, present: true, masked: "…ab12 (64 chars)", source: "file", shadowedByEnv: false }), 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'", () => { it("env-owned key carries the 'env' marker (the row renders read-only)", () => {
assert.ok(keyStatusLine({ id: "X", info: null, present: true, masked: "…ab12 (64 chars)", source: "env", shadowedByEnv: false })?.endsWith("environment")); 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", () => { it("absent key reads 'not set'", () => {
assert.equal(keyStatusLine({ id: "X", info: null, present: false, masked: null, source: "none", shadowedByEnv: false }), null); assert.equal(keyStatusLine({ id: "X", info: null, present: false, masked: null, source: "none", shadowedByEnv: false }), "not set");
}); });
}); });