import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai"; import { Container, fuzzyFilter, getKeybindings, Input, matchesKey, Spacer, type Tab, TabBar, Text, type TUI, visibleWidth, } from "@oh-my-pi/pi-tui"; import type { ModelRegistry } from "../../config/model-registry"; import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry"; import { resolveModelRoleValue } from "../../config/model-resolver"; import type { Settings } from "../../config/settings"; import { type ThemeColor, theme } from "../../modes/theme/theme"; import { getThinkingLevelMetadata } from "../../thinking"; import { getTabBarTheme } from "../shared"; import { DynamicBorder } from "./dynamic-border"; function makeInvertedBadge(label: string, color: ThemeColor): string { const fgAnsi = theme.getFgAnsi(color); const bgAnsi = fgAnsi.replace(/\x1b\[38;/g, "\x1b[48;"); return `${bgAnsi}\x1b[30m ${label} \x1b[39m\x1b[49m`; } function normalizeSearchText(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .trim(); } function compactSearchText(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); } function getAlphaSearchTokens(query: string): string[] { return [...normalizeSearchText(query).matchAll(/[a-z]+/g)].map(match => match[0]).filter(token => token.length > 0); } function computeModelRank(model: Model, roles: Record): number { let i = 0; while (i < MODEL_ROLE_IDS.length) { const role = MODEL_ROLE_IDS[i]; const assigned = roles[role]; if (assigned && modelsAreEqual(assigned.model, model)) { break; } i++; } return i; } interface ModelItem { kind: "provider"; provider: string; id: string; model: Model; selector: string; } interface CanonicalModelItem { kind: "canonical"; id: string; model: Model; selector: string; variantCount: number; searchText: string; normalizedSearchText: string; compactSearchText: string; } interface ScopedModelItem { model: Model; thinkingLevel?: string; } interface RoleAssignment { model: Model; thinkingLevel: ThinkingLevel; } type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ThinkingLevel, selector?: string) => void; type CancelCallback = () => void; interface MenuRoleAction { label: string; role: string; // now accepts custom role strings } const ALL_TAB = "ALL"; const CANONICAL_TAB = "CANONICAL"; /** * Component that renders a model selector with provider tabs and context menu. * - Tab/Arrow Left/Right: Switch between provider tabs * - Arrow Up/Down: Navigate model list * - Enter: Open context menu to select action * - Escape: Close menu or selector */ export class ModelSelectorComponent extends Container { #searchInput: Input; #headerContainer: Container; #tabBar: TabBar | null = null; #listContainer: Container; #menuContainer: Container; #allModels: ModelItem[] = []; #filteredModels: ModelItem[] = []; #canonicalModels: CanonicalModelItem[] = []; #filteredCanonicalModels: CanonicalModelItem[] = []; #selectedIndex: number = 0; #roles = {} as Record; #settings = null as unknown as Settings; #modelRegistry = null as unknown as ModelRegistry; #onSelectCallback = (() => {}) as RoleSelectCallback; #onCancelCallback = (() => {}) as CancelCallback; #errorMessage?: unknown; #tui: TUI; #scopedModels: ReadonlyArray; #temporaryOnly: boolean; #menuRoleActions: MenuRoleAction[] = []; // Tab state #providers: string[] = [ALL_TAB]; #activeTabIndex: number = 0; // Context menu state #isMenuOpen: boolean = false; #menuSelectedIndex: number = 0; #menuStep: "role" | "thinking" = "role"; #menuSelectedRole: string | null = null; constructor( tui: TUI, _currentModel: Model | undefined, settings: Settings, modelRegistry: ModelRegistry, scopedModels: ReadonlyArray, onSelect: (model: Model, role: string | null, thinkingLevel?: ThinkingLevel, selector?: string) => void, onCancel: () => void, options?: { temporaryOnly?: boolean; initialSearchInput?: string }, ) { super(); this.#tui = tui; this.#settings = settings; this.#modelRegistry = modelRegistry; this.#scopedModels = scopedModels; this.#onSelectCallback = onSelect; this.#onCancelCallback = onCancel; this.#temporaryOnly = options?.temporaryOnly ?? false; const initialSearchInput = options?.initialSearchInput; // Initialize menu role actions (built-in + custom from settings) this.#buildMenuRoleActions(); // Load current role assignments from settings this.#loadRoleModels(); // Add top border this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); // Add hint about model filtering const hintText = scopedModels.length > 0 ? "Showing models from --models scope" : "Only showing models with configured API keys (see README for details)"; this.addChild(new Text(theme.fg("warning", hintText), 0, 0)); this.addChild(new Spacer(1)); // Create header container for tab bar this.#headerContainer = new Container(); this.addChild(this.#headerContainer); this.addChild(new Spacer(1)); // Create search input this.#searchInput = new Input(); if (initialSearchInput) { this.#searchInput.setValue(initialSearchInput); } this.#searchInput.onSubmit = () => { // Enter on search input opens menu if we have a selection if (this.#filteredModels[this.#selectedIndex]) { this.#openMenu(); } }; this.addChild(this.#searchInput); this.addChild(new Spacer(1)); // Create list container this.#listContainer = new Container(); this.addChild(this.#listContainer); // Create menu container (hidden by default) this.#menuContainer = new Container(); this.addChild(this.#menuContainer); this.addChild(new Spacer(1)); // Add bottom border this.addChild(new DynamicBorder()); // Load models and do initial render this.#loadModels().then(() => { this.#buildProviderTabs(); this.#updateTabBar(); // Always apply the current search query — the user may have typed // while models were loading asynchronously. const currentQuery = this.#searchInput.getValue(); if (currentQuery) { this.#filterModels(currentQuery); } else { this.#updateList(); } // Request re-render after models are loaded this.#tui.requestRender(); }); } #buildMenuRoleActions(): void { this.#menuRoleActions = getKnownRoleIds(this.#settings).map(role => { const roleInfo = getRoleInfo(role, this.#settings); const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name; return { label: `Set as ${roleLabel}`, role, }; }); } #loadRoleModels(): void { const allModels = this.#modelRegistry.getAll(); const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() }; for (const role of getKnownRoleIds(this.#settings)) { const roleValue = this.#settings.getModelRole(role); if (!roleValue) continue; const resolved = resolveModelRoleValue(roleValue, allModels, { settings: this.#settings, matchPreferences, modelRegistry: this.#modelRegistry, }); if (resolved.model) { this.#roles[role] = { model: resolved.model, thinkingLevel: resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined ? resolved.thinkingLevel : ThinkingLevel.Inherit, }; } } } #sortModels(models: ModelItem[]): void { // Sort: tagged models (default/smol/slow/plan) first, then MRU, then alphabetical const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; const mruIndex = new Map(mruOrder.map((key, i) => [key, i])); const modelRank = (item: ModelItem) => computeModelRank(item.model, this.#roles); const dateRe = /-(\d{8})$/; const latestRe = /-latest$/; models.sort((a, b) => { const aKey = a.selector; const bKey = b.selector; const aRank = modelRank(a); const bRank = modelRank(b); if (aRank !== bRank) return aRank - bRank; // Then MRU order (models in mruIndex come before those not in it) const aMru = mruIndex.get(aKey) ?? Number.MAX_SAFE_INTEGER; const bMru = mruIndex.get(bKey) ?? Number.MAX_SAFE_INTEGER; if (aMru !== bMru) return aMru - bMru; // By provider, then recency within provider const providerCmp = a.provider.localeCompare(b.provider); if (providerCmp !== 0) return providerCmp; // Priority field (lower = better, e.g. Codex priority values) const aPri = a.model.priority ?? Number.MAX_SAFE_INTEGER; const bPri = b.model.priority ?? Number.MAX_SAFE_INTEGER; if (aPri !== bPri) return aPri - bPri; // Version number descending (higher version = better model) const aVer = extractVersionNumber(a.id); const bVer = extractVersionNumber(b.id); if (aVer !== bVer) return bVer - aVer; const aIsLatest = latestRe.test(a.id); const bIsLatest = latestRe.test(b.id); const aDate = a.id.match(dateRe)?.[1] ?? ""; const bDate = b.id.match(dateRe)?.[1] ?? ""; // Both have dates or latest tags — sort by recency const aHasRecency = aIsLatest || aDate !== ""; const bHasRecency = bIsLatest || bDate !== ""; // Models with recency info come before those without if (aHasRecency !== bHasRecency) return aHasRecency ? -1 : 1; // If neither has recency info, fall back to alphabetical if (!aHasRecency) return a.id.localeCompare(b.id); // -latest always sorts first within recency group if (aIsLatest !== bIsLatest) return aIsLatest ? -1 : 1; // Both have dates — descending (newest first) if (aDate && bDate) return bDate.localeCompare(aDate); // One has date, other is latest — latest first return aIsLatest ? -1 : bIsLatest ? 1 : a.id.localeCompare(b.id); }); } #sortCanonicalModels(models: CanonicalModelItem[]): void { const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; const mruIndex = new Map(mruOrder.map((key, i) => [key, i])); const modelRank = (item: CanonicalModelItem) => computeModelRank(item.model, this.#roles); models.sort((a, b) => { const aRank = modelRank(a); const bRank = modelRank(b); if (aRank !== bRank) return aRank - bRank; const aMru = mruIndex.get(`${a.model.provider}/${a.model.id}`) ?? Number.MAX_SAFE_INTEGER; const bMru = mruIndex.get(`${b.model.provider}/${b.model.id}`) ?? Number.MAX_SAFE_INTEGER; if (aMru !== bMru) return aMru - bMru; const providerCmp = a.model.provider.localeCompare(b.model.provider); if (providerCmp !== 0) return providerCmp; return a.id.localeCompare(b.id); }); } async #loadModels(): Promise { let models: ModelItem[]; // Use scoped models if provided via --models flag if (this.#scopedModels.length > 0) { models = this.#scopedModels.map(scoped => ({ kind: "provider", provider: scoped.model.provider, id: scoped.model.id, model: scoped.model, selector: `${scoped.model.provider}/${scoped.model.id}`, })); } else { // Reload config and cached discovery state without blocking on live provider refresh await this.#modelRegistry.refresh("offline"); // Check for models.json errors const loadError = this.#modelRegistry.getError(); if (loadError) { this.#errorMessage = loadError; } else { this.#errorMessage = undefined; } // Load available models (built-in models still work even if models.json failed) try { const availableModels = this.#modelRegistry.getAvailable(); models = availableModels.map((model: Model) => ({ kind: "provider", provider: model.provider, id: model.id, model, selector: `${model.provider}/${model.id}`, })); } catch (error) { this.#allModels = []; this.#filteredModels = []; this.#canonicalModels = []; this.#filteredCanonicalModels = []; this.#errorMessage = error instanceof Error ? error.message : String(error); return; } } const canonicalRecords = this.#modelRegistry.getCanonicalModels({ availableOnly: this.#scopedModels.length === 0, candidates: models.map(item => item.model), }); const canonicalModels = canonicalRecords .map(record => { const selectedModel = this.#modelRegistry.resolveCanonicalModel(record.id, { availableOnly: this.#scopedModels.length === 0, candidates: models.map(item => item.model), }); if (!selectedModel) return undefined; const searchText = [ record.id, record.name, selectedModel.provider, selectedModel.id, selectedModel.name, ...record.variants.flatMap(variant => [variant.selector, variant.model.name]), ].join(" "); return { kind: "canonical" as const, id: record.id, model: selectedModel, selector: record.id, variantCount: record.variants.length, searchText, normalizedSearchText: normalizeSearchText(searchText), compactSearchText: compactSearchText(searchText), }; }) .filter((item): item is CanonicalModelItem => item !== undefined); this.#sortModels(models); this.#sortCanonicalModels(canonicalModels); this.#allModels = models; this.#filteredModels = models; this.#canonicalModels = canonicalModels; this.#filteredCanonicalModels = canonicalModels; this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1)); } #buildProviderTabs(): void { const providerSet = new Set(); for (const item of this.#allModels) { providerSet.add(item.provider.toUpperCase()); } for (const provider of this.#modelRegistry.getDiscoverableProviders()) { providerSet.add(provider.toUpperCase()); } const sortedProviders = Array.from(providerSet).sort(); this.#providers = [ALL_TAB, CANONICAL_TAB, ...sortedProviders]; } async #refreshSelectedProvider(): Promise { const activeProvider = this.#getActiveProvider(); if (this.#scopedModels.length > 0 || activeProvider === ALL_TAB || activeProvider === CANONICAL_TAB) { return; } await this.#modelRegistry.refreshProvider(activeProvider.toLowerCase()); await this.#loadModels(); this.#buildProviderTabs(); this.#updateTabBar(); this.#applyTabFilter(); this.#tui.requestRender(); } #updateTabBar(): void { this.#headerContainer.clear(); const tabs: Tab[] = this.#providers.map(provider => ({ id: provider, label: provider })); const tabBar = new TabBar("Models", tabs, getTabBarTheme(), this.#activeTabIndex); tabBar.onTabChange = (_tab, index) => { this.#activeTabIndex = index; this.#selectedIndex = 0; this.#applyTabFilter(); void this.#refreshSelectedProvider().catch(error => { this.#errorMessage = error instanceof Error ? error.message : String(error); this.#updateList(); this.#tui.requestRender(); }); }; this.#tabBar = tabBar; this.#headerContainer.addChild(tabBar); } #getActiveProvider(): string { return this.#providers[this.#activeTabIndex] ?? ALL_TAB; } #isCanonicalTab(): boolean { return this.#getActiveProvider() === CANONICAL_TAB; } #filterModels(query: string): void { const activeProvider = this.#getActiveProvider(); const isCanonicalTab = activeProvider === CANONICAL_TAB; // Start with all models or filter by provider/canonical view let baseModels = this.#allModels; const baseCanonicalModels = this.#canonicalModels; if (!isCanonicalTab && activeProvider !== ALL_TAB) { baseModels = this.#allModels.filter(m => m.provider.toUpperCase() === activeProvider); } // Apply fuzzy filter if query is present if (query.trim()) { // If user is searching from a provider tab, auto-switch to ALL to show global provider results. if (activeProvider !== ALL_TAB && !isCanonicalTab) { this.#activeTabIndex = 0; if (this.#tabBar && this.#tabBar.getActiveIndex() !== 0) { this.#tabBar.setActiveIndex(0); return; } this.#updateTabBar(); baseModels = this.#allModels; } if (isCanonicalTab) { const alphaTokens = getAlphaSearchTokens(query); const alphaFiltered = alphaTokens.length === 0 ? baseCanonicalModels : baseCanonicalModels.filter(item => alphaTokens.every(token => item.normalizedSearchText.includes(token)), ); const compactQuery = compactSearchText(query); const substringFiltered = compactQuery.length === 0 ? alphaFiltered : alphaFiltered.filter(item => item.compactSearchText.includes(compactQuery)); const fuzzySource = substringFiltered.length > 0 ? substringFiltered : alphaFiltered.length > 0 ? alphaFiltered : baseCanonicalModels; const fuzzyMatches = fuzzyFilter(fuzzySource, query, ({ searchText }) => searchText); this.#sortCanonicalModels(fuzzyMatches); this.#filteredCanonicalModels = fuzzyMatches; } else { const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${id} ${provider}`); this.#sortModels(fuzzyMatches); this.#filteredModels = fuzzyMatches; } } else { this.#filteredModels = baseModels; this.#filteredCanonicalModels = baseCanonicalModels; } const visibleCount = isCanonicalTab ? this.#filteredCanonicalModels.length : this.#filteredModels.length; this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, visibleCount - 1)); this.#updateList(); } #applyTabFilter(): void { const query = this.#searchInput.getValue(); this.#filterModels(query); } #formatDiscoveryAge(fetchedAt: number | undefined): string | undefined { if (!fetchedAt) { return undefined; } const ageMs = Math.max(0, Date.now() - fetchedAt); if (ageMs < 60_000) { return "less than a minute ago"; } const ageMinutes = Math.round(ageMs / 60_000); return `${ageMinutes}m ago`; } #formatDiscoveryErrorHint(error: string | undefined): string | undefined { if (!error) { return undefined; } const httpMatch = error.match(/^HTTP (\d+) from (.+)$/); if (!httpMatch) { return undefined; } const [, statusCode, url] = httpMatch; if (statusCode === "404") { return ` Discovery endpoint ${url} returned 404. Point baseUrl at the host that serves /models (usually .../v1).`; } return ` Discovery failed: ${error}`; } #getProviderEmptyStateMessage(): string | undefined { const activeProvider = this.#getActiveProvider(); if (activeProvider === ALL_TAB || activeProvider === CANONICAL_TAB || this.#searchInput.getValue().trim()) { return undefined; } const state = this.#modelRegistry.getProviderDiscoveryState(activeProvider.toLowerCase()); if (!state) { return undefined; } const age = this.#formatDiscoveryAge(state.fetchedAt); switch (state.status) { case "cached": return age ? ` Using cached model list from ${age}. Live refresh is still pending.` : " Using cached model list. Live refresh is still pending."; case "unavailable": return ( this.#formatDiscoveryErrorHint(state.error) ?? (age ? ` Provider unavailable. Using cached model list from ${age}.` : " Provider unavailable.") ); case "unauthenticated": return " Provider requires authentication before models can be discovered."; case "idle": return " Provider has not been refreshed yet."; case "empty": return " Discovery succeeded but returned 0 models. Check that /models returns { data: [{ id }] }."; case "ok": return undefined; } } #updateList(): void { this.#listContainer.clear(); const isCanonicalTab = this.#isCanonicalTab(); const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels; const maxVisible = 10; const startIndex = Math.max( 0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), visibleItems.length - maxVisible), ); const endIndex = Math.min(startIndex + maxVisible, visibleItems.length); const activeProvider = this.#getActiveProvider(); const showProvider = activeProvider === ALL_TAB; // Show visible slice of filtered models for (let i = startIndex; i < endIndex; i++) { const item = visibleItems[i]; if (!item) continue; const canonicalItem = isCanonicalTab ? (item as CanonicalModelItem) : undefined; const providerItem = isCanonicalTab ? undefined : (item as ModelItem); const isSelected = i === this.#selectedIndex; // Build role badges (inverted: color as background, black text) const roleBadgeTokens: string[] = []; for (const role of MODEL_ROLE_IDS) { const { tag, color } = getRoleInfo(role, this.#settings); const assigned = this.#roles[role]; if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue; const badge = makeInvertedBadge(tag, color ?? "success"); const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label; roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`); } // Custom role badges for (const [role, assigned] of Object.entries(this.#roles)) { if (role in MODEL_ROLES || !assigned || !modelsAreEqual(assigned.model, item.model)) continue; const roleInfo = getRoleInfo(role, this.#settings); const badgeLabel = roleInfo.tag ?? roleInfo.name; const badge = makeInvertedBadge(badgeLabel, roleInfo.color ?? "muted"); const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label; roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`); } const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : ""; let line = ""; if (isSelected) { const prefix = theme.fg("accent", `${theme.nav.cursor} `); if (isCanonicalTab) { const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`); const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`); line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}`; } else if (showProvider) { const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`); line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}`; } else { line = `${prefix}${theme.fg("accent", item.id)}${badgeText}`; } } else { const prefix = " "; if (isCanonicalTab) { const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`); const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`); line = `${prefix}${item.id}${variants}${backing}${badgeText}`; } else if (showProvider) { const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`); line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}`; } else { line = `${prefix}${item.id}${badgeText}`; } } this.#listContainer.addChild(new Text(line, 0, 0)); } // Add scroll indicator if needed if (startIndex > 0 || endIndex < visibleItems.length) { const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${visibleItems.length})`); this.#listContainer.addChild(new Text(scrollInfo, 0, 0)); } // Show error message or "no results" if empty if (this.#errorMessage) { const errorLines = String(this.#errorMessage).split("\n"); for (const line of errorLines) { this.#listContainer.addChild(new Text(theme.fg("error", line), 0, 0)); } } else if (visibleItems.length === 0) { const statusMessage = this.#getProviderEmptyStateMessage(); this.#listContainer.addChild(new Text(theme.fg("muted", statusMessage ?? " No matching models"), 0, 0)); } else { const selected = visibleItems[this.#selectedIndex]; if (!selected) { return; } this.#listContainer.addChild(new Spacer(1)); const suffix = isCanonicalTab ? ` (${selected.model.provider}/${selected.model.id}, ${(selected as CanonicalModelItem).variantCount} variants)` : ""; this.#listContainer.addChild( new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`), 0, 0), ); } } #getThinkingLevelsForModel(model: Model): ReadonlyArray { return [ThinkingLevel.Inherit, ThinkingLevel.Off, ...getSupportedEfforts(model)]; } #getCurrentRoleThinkingLevel(role: string): ThinkingLevel { return this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit; } #getThinkingPreselectIndex(role: string, model: Model): number { const options = this.#getThinkingLevelsForModel(model); const currentLevel = this.#getCurrentRoleThinkingLevel(role); const foundIndex = options.indexOf(currentLevel); return foundIndex >= 0 ? foundIndex : 0; } #getSelectedItem(): ModelItem | CanonicalModelItem | undefined { return this.#isCanonicalTab() ? this.#filteredCanonicalModels[this.#selectedIndex] : this.#filteredModels[this.#selectedIndex]; } #openMenu(): void { if (!this.#getSelectedItem()) return; this.#isMenuOpen = true; this.#menuStep = "role"; this.#menuSelectedRole = null; this.#menuSelectedIndex = 0; this.#updateMenu(); } #closeMenu(): void { this.#isMenuOpen = false; this.#menuStep = "role"; this.#menuSelectedRole = null; this.#menuContainer.clear(); } #updateMenu(): void { this.#menuContainer.clear(); const selectedItem = this.#getSelectedItem(); if (!selectedItem) return; const showingThinking = this.#menuStep === "thinking" && this.#menuSelectedRole !== null; const thinkingOptions = showingThinking ? this.#getThinkingLevelsForModel(selectedItem.model) : []; const optionLines = showingThinking ? thinkingOptions.map((thinkingLevel, index) => { const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " "; const label = getThinkingLevelMetadata(thinkingLevel).label; return `${prefix}${label}`; }) : this.#menuRoleActions.map((action, index) => { const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " "; return `${prefix}${action.label}`; }); const selectedRoleName = this.#menuSelectedRole ? getRoleInfo(this.#menuSelectedRole, this.#settings).name : ""; const headerText = showingThinking && this.#menuSelectedRole ? ` Thinking for: ${selectedRoleName} (${selectedItem.id})` : ` Action for: ${selectedItem.id}`; const hintText = showingThinking ? " Enter: confirm Esc: back" : " Enter: continue Esc: cancel"; const menuWidth = Math.max( visibleWidth(headerText), visibleWidth(hintText), ...optionLines.map(line => visibleWidth(line)), ); this.#menuContainer.addChild(new Spacer(1)); this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); if (showingThinking && this.#menuSelectedRole) { this.#menuContainer.addChild( new Text( theme.fg("text", ` Thinking for: ${theme.bold(selectedRoleName)} (${theme.bold(selectedItem.id)})`), 0, 0, ), ); } else { this.#menuContainer.addChild(new Text(theme.fg("text", ` Action for: ${theme.bold(selectedItem.id)}`), 0, 0)); } this.#menuContainer.addChild(new Spacer(1)); for (let i = 0; i < optionLines.length; i++) { const lineText = optionLines[i]; if (!lineText) continue; const isSelected = i === this.#menuSelectedIndex; const line = isSelected ? theme.fg("accent", lineText) : theme.fg("muted", lineText); this.#menuContainer.addChild(new Text(line, 0, 0)); } this.#menuContainer.addChild(new Spacer(1)); this.#menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0)); this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); } handleInput(keyData: string): void { if (this.#isMenuOpen) { this.#handleMenuInput(keyData); return; } // Tab bar navigation if (this.#tabBar?.handleInput(keyData)) { return; } // Up arrow - navigate list (wrap to bottom when at top) if (matchesKey(keyData, "up")) { const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length; if (itemCount === 0) return; this.#selectedIndex = this.#selectedIndex === 0 ? itemCount - 1 : this.#selectedIndex - 1; this.#updateList(); return; } // Down arrow - navigate list (wrap to top when at bottom) if (matchesKey(keyData, "down")) { const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length; if (itemCount === 0) return; this.#selectedIndex = this.#selectedIndex === itemCount - 1 ? 0 : this.#selectedIndex + 1; this.#updateList(); return; } // Enter - open context menu or select directly in temporary mode if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { const selectedItem = this.#getSelectedItem(); if (selectedItem) { if (this.#temporaryOnly) { // In temporary mode, skip menu and select directly this.#handleSelect(selectedItem, null); } else { this.#openMenu(); } } return; } // Escape or Ctrl+C - close selector if (getKeybindings().matches(keyData, "tui.select.cancel")) { this.#onCancelCallback(); return; } // Pass everything else to search input this.#searchInput.handleInput(keyData); this.#filterModels(this.#searchInput.getValue()); } #handleMenuInput(keyData: string): void { const selectedItem = this.#getSelectedItem(); if (!selectedItem) return; const optionCount = this.#menuStep === "thinking" && this.#menuSelectedRole !== null ? this.#getThinkingLevelsForModel(selectedItem.model).length : this.#menuRoleActions.length; if (optionCount === 0) return; if (matchesKey(keyData, "up")) { this.#menuSelectedIndex = (this.#menuSelectedIndex - 1 + optionCount) % optionCount; this.#updateMenu(); return; } if (matchesKey(keyData, "down")) { this.#menuSelectedIndex = (this.#menuSelectedIndex + 1) % optionCount; this.#updateMenu(); return; } if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { if (this.#menuStep === "role") { const action = this.#menuRoleActions[this.#menuSelectedIndex]; if (!action) return; this.#menuSelectedRole = action.role; this.#menuStep = "thinking"; this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model); this.#updateMenu(); return; } if (!this.#menuSelectedRole) return; const thinkingOptions = this.#getThinkingLevelsForModel(selectedItem.model); const thinkingLevel = thinkingOptions[this.#menuSelectedIndex]; if (!thinkingLevel) return; this.#handleSelect(selectedItem, this.#menuSelectedRole, thinkingLevel); this.#closeMenu(); return; } if (getKeybindings().matches(keyData, "tui.select.cancel")) { if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) { this.#menuStep = "role"; const roleIndex = this.#menuRoleActions.findIndex(action => action.role === this.#menuSelectedRole); this.#menuSelectedRole = null; this.#menuSelectedIndex = roleIndex >= 0 ? roleIndex : 0; this.#updateMenu(); return; } this.#closeMenu(); return; } } #handleSelect(item: ModelItem | CanonicalModelItem, role: string | null, thinkingLevel?: ThinkingLevel): void { // For temporary role, don't save to settings - just notify caller if (role === null) { this.#onSelectCallback(item.model, null, undefined, item.selector); return; } const selectedThinkingLevel = thinkingLevel ?? this.#getCurrentRoleThinkingLevel(role); // Update local state for UI this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel }; // Notify caller (for updating agent state if needed) this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector); // Update list to show new badges this.#updateList(); } getSearchInput(): Input { return this.#searchInput; } } /** Extract the first version number from a model ID (e.g. "gemini-2.5-pro" → 2.5, "claude-sonnet-4-6" → 4.6). */ function extractVersionNumber(id: string): number { // Dot-separated version: "gemini-2.5-pro" → 2.5 const dotMatch = id.match(/(?:^|[-_])(\d+\.\d+)/); if (dotMatch) return Number.parseFloat(dotMatch[1]); // Dash-separated short segments: "claude-sonnet-4-6" → 4.6, "llama-3-1-8b" → 3.1 const dashMatch = id.match(/(?:^|[-_])(\d{1,2})-(\d{1,2})(?=-|$)/); if (dashMatch) return Number.parseFloat(`${dashMatch[1]}.${dashMatch[2]}`); // Single number after separator: "gpt-4o" → 4 const singleMatch = id.match(/(?:^|[-_])(\d+)/); if (singleMatch) return Number.parseFloat(singleMatch[1]); return 0; }