Files
oh-my-pi/packages/coding-agent/src/modes/components/model-selector.ts
T
can1357 7f866a48a8 feat(coding-agent): added per-turn AUTO_THINKING in coding-agent session
- Added AUTO_THINKING as a configured thinking level in settings, schema, SDK, and session plumbing.
- Implemented per-turn auto reasoning classification with online/local prompts, effort clamping, and skip guards.
- Updated model selectors, ACP options, footer/status UI, and events to render auto and auto->resolved states.
- Added AUTO_THINKING parse/clamp tests and fixed local-module cycle and hashline preview regressions.
2026-05-31 03:32:21 +02:00

1118 lines
38 KiB
TypeScript

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 { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } 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<string, RoleAssignment | undefined>): 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: ConfiguredThinkingLevel;
}
type RoleSelectCallback = (
model: Model,
role: string | null,
thinkingLevel?: ConfiguredThinkingLevel,
selector?: string,
) => void;
type CancelCallback = () => void;
interface MenuRoleAction {
label: string;
role: string; // now accepts custom role strings
}
interface ProviderTabState {
id: string;
label: string;
providerId?: string;
}
const ALL_TAB = "ALL";
const CANONICAL_TAB = "CANONICAL";
const STATIC_PROVIDER_TABS: ProviderTabState[] = [
{ id: ALL_TAB, label: ALL_TAB },
{ id: CANONICAL_TAB, label: CANONICAL_TAB },
];
const MODEL_TAB_REFRESH_DEBOUNCE_MS = 120;
function formatProviderTabLabel(providerId: string): string {
return providerId.replace(/[-_]+/g, " ").toUpperCase();
}
function createProviderTab(providerId: string): ProviderTabState {
return { id: providerId, label: formatProviderTabLabel(providerId), providerId };
}
/**
* 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<string, RoleAssignment | undefined>;
#settings = null as unknown as Settings;
#modelRegistry = null as unknown as ModelRegistry;
#onSelectCallback = (() => {}) as RoleSelectCallback;
#onCancelCallback = (() => {}) as CancelCallback;
#errorMessage?: unknown;
#tui: TUI;
#scopedModels: ReadonlyArray<ScopedModelItem>;
#temporaryOnly: boolean;
#menuRoleActions: MenuRoleAction[] = [];
// Tab state
#providers: ProviderTabState[] = STATIC_PROVIDER_TABS;
#activeTabIndex: number = 0;
#refreshingProviders: Set<string> = new Set();
#scheduledProviderRefreshes: Map<string, ReturnType<typeof setTimeout>> = new Map();
#refreshSpinnerFrame: number = 0;
#refreshSpinnerInterval?: NodeJS.Timeout;
// 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<ScopedModelItem>,
onSelect: RoleSelectCallback,
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,
};
}
}
}
/**
* @param skipRoleRank When a search query is narrowing the list, role assignments
* should NOT promote a weakly-matching default model above a perfect text
* match — defer to MRU/version instead so user affinity drives the order.
*/
#sortModels(models: ModelItem[], { skipRoleRank = false }: { skipRoleRank?: boolean } = {}): 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;
if (!skipRoleRank) {
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[], { skipRoleRank = false }: { skipRoleRank?: boolean } = {}): 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) => {
if (!skipRoleRank) {
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);
});
}
#loadModelsFromCurrentRegistryState(): void {
let models: ModelItem[];
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 {
const loadError = this.#modelRegistry.getError();
if (loadError) {
this.#errorMessage = loadError;
} else {
this.#errorMessage = undefined;
}
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 candidates = models.map(item => item.model);
const canonicalRecords = this.#modelRegistry.getCanonicalModels({
availableOnly: this.#scopedModels.length === 0,
candidates,
});
const canonicalModels = canonicalRecords
.map(record => {
const selectedModel = this.#modelRegistry.resolveCanonicalModel(record.id, {
availableOnly: this.#scopedModels.length === 0,
candidates,
});
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));
}
async #loadModels(): Promise<void> {
if (this.#scopedModels.length === 0) {
// Reload config and cached discovery state without blocking on live provider refresh
await this.#modelRegistry.refresh("offline");
}
this.#loadModelsFromCurrentRegistryState();
}
#buildProviderTabs(): void {
const activeTabId = this.#getActiveTab().id;
const providerSet = new Set<string>();
for (const item of this.#allModels) {
providerSet.add(item.provider);
}
for (const provider of this.#modelRegistry.getDiscoverableProviders()) {
providerSet.add(provider);
}
const sortedProviderIds = Array.from(providerSet).sort((left, right) =>
formatProviderTabLabel(left).localeCompare(formatProviderTabLabel(right)),
);
this.#providers = [...STATIC_PROVIDER_TABS, ...sortedProviderIds.map(createProviderTab)];
const activeIndex = this.#providers.findIndex(tab => tab.id === activeTabId);
this.#activeTabIndex =
activeIndex >= 0 ? activeIndex : Math.min(this.#activeTabIndex, this.#providers.length - 1);
}
#getActiveProviderRefreshStatusText(): string | undefined {
const providerId = this.#getActiveProviderId();
if (!providerId || !this.#refreshingProviders.has(providerId)) {
return undefined;
}
const spinnerFrames = theme.spinnerFrames;
const spinner =
spinnerFrames.length > 0
? spinnerFrames[this.#refreshSpinnerFrame % spinnerFrames.length]
: theme.status.pending;
return theme.fg("warning", ` ${spinner} Refreshing ${formatProviderTabLabel(providerId)} in background...`);
}
#startRefreshSpinner(): void {
if (this.#refreshSpinnerInterval) {
return;
}
this.#refreshSpinnerInterval = setInterval(() => {
const frameCount = theme.spinnerFrames.length;
if (frameCount > 0) {
this.#refreshSpinnerFrame = (this.#refreshSpinnerFrame + 1) % frameCount;
}
this.#updateTabBar();
this.#tui.requestRender();
}, 80);
}
#stopRefreshSpinner(): void {
if (this.#refreshingProviders.size > 0) {
return;
}
if (this.#refreshSpinnerInterval) {
clearInterval(this.#refreshSpinnerInterval);
this.#refreshSpinnerInterval = undefined;
}
this.#refreshSpinnerFrame = 0;
}
#setProviderRefreshing(providerId: string, refreshing: boolean): void {
if (refreshing) {
this.#refreshingProviders.add(providerId);
this.#startRefreshSpinner();
} else {
this.#refreshingProviders.delete(providerId);
this.#stopRefreshSpinner();
}
}
#cancelScheduledProviderRefreshesExcept(keepProviderId?: string): void {
for (const [providerId, timer] of this.#scheduledProviderRefreshes) {
if (providerId === keepProviderId) {
continue;
}
clearTimeout(timer);
this.#scheduledProviderRefreshes.delete(providerId);
this.#setProviderRefreshing(providerId, false);
}
}
#scheduleSelectedProviderRefresh(): void {
const providerId = this.#getActiveProviderId();
if (this.#scopedModels.length > 0 || !providerId) {
return;
}
if (this.#scheduledProviderRefreshes.has(providerId) || this.#refreshingProviders.has(providerId)) {
return;
}
this.#setProviderRefreshing(providerId, true);
const timer = setTimeout(() => {
this.#scheduledProviderRefreshes.delete(providerId);
void this.#refreshProviderInBackground(providerId);
}, MODEL_TAB_REFRESH_DEBOUNCE_MS);
this.#scheduledProviderRefreshes.set(providerId, timer);
}
async #refreshProviderInBackground(providerId: string): Promise<void> {
try {
await this.#modelRegistry.refreshProvider(providerId, "online");
// Provider refresh already updated the registry snapshot. Re-reading it
// here must stay purely in-memory — do not call modelRegistry.refresh()
// again or tab switches will pay an extra whole-registry reload after the
// network round-trip completes.
this.#loadModelsFromCurrentRegistryState();
this.#buildProviderTabs();
this.#updateTabBar();
this.#applyTabFilter();
} catch (error) {
this.#errorMessage = error instanceof Error ? error.message : String(error);
this.#updateList();
} finally {
this.#setProviderRefreshing(providerId, false);
this.#updateTabBar();
this.#tui.requestRender();
}
}
#updateTabBar(): void {
this.#headerContainer.clear();
const tabs: Tab[] = this.#providers.map(provider => ({ id: provider.id, label: provider.label }));
const tabBar = new TabBar("Models", tabs, getTabBarTheme(), this.#activeTabIndex);
tabBar.onTabChange = (_tab, index) => {
this.#activeTabIndex = index;
this.#selectedIndex = 0;
this.#cancelScheduledProviderRefreshesExcept(this.#getActiveProviderId());
this.#applyTabFilter();
this.#scheduleSelectedProviderRefresh();
this.#updateTabBar();
// Let TUI's normal post-input render paint the new tab immediately.
// The live refresh is debounced onto a later timer so tab cycling never
// shares a stack frame with provider refresh work.
this.#tui.requestRender();
};
this.#tabBar = tabBar;
this.#headerContainer.addChild(tabBar);
const refreshStatusText = this.#getActiveProviderRefreshStatusText();
if (refreshStatusText) {
this.#headerContainer.addChild(new Text(refreshStatusText, 0, 0));
}
}
#getActiveTab(): ProviderTabState {
return this.#providers[this.#activeTabIndex] ?? STATIC_PROVIDER_TABS[0]!;
}
#getActiveTabId(): string {
return this.#getActiveTab().id;
}
#getActiveProviderId(): string | undefined {
return this.#getActiveTab().providerId;
}
#isCanonicalTab(): boolean {
return this.#getActiveTabId() === CANONICAL_TAB;
}
#filterModels(query: string): void {
const activeTabId = this.#getActiveTabId();
const activeProviderId = this.#getActiveProviderId();
const isCanonicalTab = activeTabId === CANONICAL_TAB;
// Start with all models or filter by provider/canonical view
let baseModels = this.#allModels;
const baseCanonicalModels = this.#canonicalModels;
if (activeProviderId) {
baseModels = this.#allModels.filter(m => m.provider === activeProviderId);
}
// 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 (activeProviderId && !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;
// Fuzzy provides the candidate set, but `${provider}/${id}` scoring
// is biased by provider-prefix length (e.g. `openai/X` beats
// `openai-codex/X` purely because the prefix is shorter). Re-sort by
// affinity — MRU then version — so the user's actually-used model
// wins. Role rank is skipped: when narrowing by query, a weakly
// matching default model should not be promoted above a stronger
// non-default match.
const fuzzyMatches = fuzzyFilter(fuzzySource, query, ({ searchText }) => searchText);
this.#sortCanonicalModels(fuzzyMatches, { skipRoleRank: true });
this.#filteredCanonicalModels = fuzzyMatches;
} else {
// Match against the displayed "provider/id" string so the user can
// type what they see: bare names (`mimo`, `kimi`), provider prefixes
// (`openrouter`), or scoped queries (`openrouter/mimo`) all flow
// through the same fuzzy matcher. The score is biased by provider-
// prefix length, so re-sort by MRU/version afterwards; skip role
// rank so a weakly matching default doesn't trump a stronger match.
const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${provider}/${id}`);
this.#sortModels(fuzzyMatches, { skipRoleRank: true });
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 activeProviderId = this.#getActiveProviderId();
if (!activeProviderId || this.#searchInput.getValue().trim()) {
return undefined;
}
const state = this.#modelRegistry.getProviderDiscoveryState(activeProviderId);
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 showProvider = this.#getActiveTabId() === 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 = getConfiguredThinkingLevelMetadata(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 = getConfiguredThinkingLevelMetadata(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<ConfiguredThinkingLevel> {
return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
}
#getCurrentRoleThinkingLevel(role: string): ConfiguredThinkingLevel {
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 = getConfiguredThinkingLevelMetadata(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 (matchesSelectUp(keyData)) {
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 (matchesSelectDown(keyData)) {
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 (matchesSelectUp(keyData)) {
this.#menuSelectedIndex = (this.#menuSelectedIndex - 1 + optionCount) % optionCount;
this.#updateMenu();
return;
}
if (matchesSelectDown(keyData)) {
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?: ConfiguredThinkingLevel,
): 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;
}