import React, { useState } from 'react'; import { ChevronDown, Plus } from 'lucide-react'; import { cn } from '../lib/utils'; // --- Launch-control primitives (ported from the prototype's sidebar) --- // A labelled config row. Inline (label left, control right) by default; pass // `stacked` to put a full-width control under the label — used for the model // dropdown and the effort/reasoning segmented pickers, which need the room. export function ConfigRow({ label, stacked, children }: { label: string; stacked?: boolean; children: React.ReactNode }) { if (stacked) { return (
{label} {children}
); } return (
{label} {children}
); } // Pill selector for small option sets (effort, reasoning, tour engine). export function SegmentedPicker({ options, value, onChange }: { options: Array<{ value: string; label: string }>; value: string; onChange: (v: string) => void }) { return (
{options.map((opt) => ( ))}
); } // Animated on/off switch (fast mode). export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ); } // Lists longer than this get a type-to-filter input and provider grouping — // marker-engine catalogs (Cursor ~150, Pi ~59, OpenCode ~25) are unusable as a // flat list; the classic Claude/Codex catalogs (7–11) stay a plain menu. // Exported so other pickers over the same catalogs (e.g. GuideEmptyState's // InlinePicker in review-editor) share this one threshold instead of hardcoding it. export const SEARCHABLE_THRESHOLD = 12; export interface SelectOptionGroup { /** null = ungrouped (single flat list). */ label: string | null; options: Array<{ value: string; label: string }>; } /** Group `provider/model`-shaped option lists by their provider prefix. * Returns one ungrouped bucket when the list is small or ids aren't * provider-prefixed (Claude aliases, Codex models, Cursor ids). */ export function groupModelOptions(options: Array<{ value: string; label: string }>): SelectOptionGroup[] { const slashed = options.filter((o) => o.value.includes('/')).length; if (options.length <= SEARCHABLE_THRESHOLD || slashed < options.length / 2) { return [{ label: null, options }]; } const groups = new Map>(); for (const o of options) { const key = o.value.includes('/') ? o.value.slice(0, o.value.indexOf('/')) : 'other'; const bucket = groups.get(key); if (bucket) bucket.push(o); else groups.set(key, [o]); } return [...groups.entries()].map(([label, opts]) => ({ label, options: opts })); } /** Strip a redundant `group/` prefix from an option label rendered under its * group header (e.g. "anthropic/claude-sonnet-4-6" under "anthropic"). */ export function labelWithinGroup(label: string, group: string | null): string { if (group && label.startsWith(`${group}/`)) return label.slice(group.length + 1); return label; } // Dropdown button + downward popover. Reused for the provider selector and the // model picker (whose 7–9 options rule out a segmented control). The popover // opens downward (`top-full`) because the launch panel is pinned to the top of // the tab. Long catalogs automatically gain a filter input + provider grouping. export function SelectMenu({ value, options, onChange, icon, placeholder, footerAction }: { value: string; options: Array<{ value: string; label: string }>; onChange: (v: string) => void; icon?: React.ReactNode; placeholder?: string; footerAction?: { label: string; onClick: () => void } }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const current = options.find((o) => o.value === value); const searchable = options.length > SEARCHABLE_THRESHOLD; const q = query.trim().toLowerCase(); const filtered = q ? options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)) : options; const grouped = groupModelOptions(filtered); const close = () => { setOpen(false); setQuery(''); }; const select = (v: string) => { onChange(v); close(); }; return (
{open && ( <>
{searchable && (
setQuery(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Escape') close(); if (e.key === 'Enter' && filtered.length > 0) select(filtered[0].value); }} placeholder="Type to filter…" className="w-full rounded-lg border border-border/30 bg-surface-1/30 px-2.5 py-1.5 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-border/60" />
)}
{filtered.length === 0 && (
No matches
)} {grouped.map((group) => ( {group.label && (
{group.label}
)} {group.options.map((o) => ( ))}
))}
{footerAction && (
)}
)}
); }