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:
@@ -175,4 +175,93 @@ 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user