Files
oh-my-pi/packages/coding-agent/src/modes/components/model-hub.ts
T
jamesarch d6ab7291d1 fix(model-hub): separate status glyph from role label
Under the `nerd` symbol preset, `status.enabled` and `status.shadowed` are
Nerd Font private-use icons (U+F111 / U+F10C). Those glyphs are drawn two
cells wide, but `visibleWidth` counts them as one: `tui/utils.ts` pins
`ambiguousIsNarrow: true`, and the PUA block is East_Asian_Width=Ambiguous.

With no separator the icon overhangs into the next cell and swallows the
label's first character, so the role chips in the model browser and model
hub render as `efault` / `ision` / `lan` / `ask` / `dvisor` instead of
`default` / `vision` / `plan` / `task` / `advisor`.

The adjacent `status.success` check on the very same line already carries a
leading space and renders correctly, which isolates the missing separator
as the cause rather than the glyph itself.

Role-chip assertions in test/model-hub.test.ts are updated for the new
spacing, and the change is recorded under CHANGELOG `[Unreleased]`.

Constraint: lint
Confidence: high
Scope-risk: low
2026-08-05 09:31:52 +08:00

2015 lines
73 KiB
TypeScript

/**
* Fullscreen /models hub, shown on the alternate screen like /settings.
*
* Layout: a sidebar of scopes (recently used, role management, all models,
* one entry per provider — locked providers included, dimmed) beside a
* {@link ModelBrowser} body. The Roles view manages assignments directly:
* pick a role, pick a model, adjust thinking in an inline strip, or clear the
* role back to auto-selection. Locked providers forward to the /login flow.
* Fully mouse-navigable (hover, wheel, click). Session-only switching lives
* in the compact alt+p picker ({@link ./model-picker}).
*/
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
import { getCatalogProviderEntry } from "@oh-my-pi/pi-catalog/provider-models";
import {
type Component,
extractPrintableText,
fuzzyFilter,
getKeybindings,
Input,
matchesKey,
routeSgrMouseInput,
type SgrMouseEvent,
type TUI,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import type { ModelRegistry } from "../../config/model-registry";
import { type ModelRoleLookup, type ResolvedModelRoleValue, resolveModelRoleValue } from "../../config/model-resolver";
import { getKnownRoleIds, getRoleInfo } from "../../config/model-roles";
import type { Settings } from "../../config/settings";
import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking";
import { theme } from "../theme/theme";
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
import {
buildBrowserItems,
ModelBrowser,
type ModelBrowserItem,
type RoleAssignments,
resolveRoleAssignments,
sortModelItems,
thinkingLevelGlyph,
} from "./model-browser";
import { bottomBorder, dividerSplit, row, splitBodyWidth, splitRow, topBorderSplit } from "./overlay-box";
import { renderSegmentTrack } from "./segment-track";
/**
* A row of the Roles view: a role, a model/wildcard chain-key header, one of a
* chain's fallback entries, or the trailing "+ New role…". Fallback rows under
* a chain-key header carry the key in `role` — `retry.fallbackChains` treats
* roles, `provider/model-id`, and `provider/*` keys uniformly.
*/
type RolesRow =
| { kind: "role"; role: string }
| { kind: "chainKey"; role: string }
| { kind: "fallback"; role: string; chainIndex: number; selector: string }
| { kind: "separator" }
| { kind: "newFallback" }
| { kind: "newRole" };
/**
* What the model browser is currently picking for: a role's model, a slot in
* a fallback chain (`role` may be a role name, model selector, or `provider/*`
* key), or the primary model a brand-new fallback chain protects.
*/
type AssignTarget =
| { kind: "role"; role: string }
| { kind: "fallback"; role: string; index: number | null }
| { kind: "fallbackKey" };
/** A `--models` scope entry (mirrors the session's scoped model list). */
export interface ScopedModelItem {
model: Model;
thinkingLevel?: string;
}
export type ModelRoleSelectionScope = "global" | "project";
export interface ModelHubCallbacks {
/** Persist a role assignment. */
onAssign: (
model: Model,
role: string,
thinkingLevel: ConfiguredThinkingLevel | undefined,
selector: string,
scope?: ModelRoleSelectionScope,
) => void;
/** Clear a configured role back to auto-selection. */
onUnassign: (role: string, scope?: ModelRoleSelectionScope) => void;
/** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
onFallbackChainChange?: (role: string, chain: string[]) => void;
/** Locked provider activation: forward to the /login flow. */
onLoginRequest?: (providerId: string) => void;
/** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
onCycleOrderChange?: (order: string[]) => void;
onCancel: () => void;
}
export interface ModelHubOptions {
/** Preselect this provider's sidebar entry (e.g. when reopening after /login). */
initialProviderId?: string;
}
interface SidebarEntry {
id: string;
kind: "recent" | "roles" | "all" | "separator" | "provider";
label: string;
providerId?: string;
locked?: boolean;
/** Right-aligned annotation: model count, `assigned/total`, or `login`. */
annotation?: string;
oauth?: boolean;
catalogCount?: number;
}
interface StripChip {
label: string;
/** Pre-styled label body (without selection decoration). */
styled: string;
role?: string;
action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "scope" | "thinking";
thinkingLevel?: ConfiguredThinkingLevel;
scope?: ModelRoleSelectionScope;
}
type StripState =
| {
kind: "role" | "scope" | "thinking";
item: ModelBrowserItem;
role?: string;
scope?: ModelRoleSelectionScope;
chips: StripChip[];
index: number;
/** Where to land when a scope or thinking strip closes. */
returnToRoles: boolean;
}
| {
/** Footer text input naming a new custom role. */
kind: "roleName";
input: Input;
};
/** Recorded chip hit-range on the footer row (columns relative to frame col 0). */
interface ChipRange {
start: number;
end: number;
index: number;
}
const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
const RECENT_LIMIT = 15;
const SIDEBAR_MIN_WIDTH = 18;
const SIDEBAR_MAX_WIDTH = 26;
/**
* Providers already auto-refreshed this process. Selecting a provider fetches
* its live model list at most once per application lifetime (surviving hub
* close/reopen); F5 re-fetches on demand.
*/
const autoRefreshedProviders = new Set<string>();
/** Test hook: forget which providers were auto-refreshed this process. */
export function resetProviderAutoRefreshGuard(): void {
autoRefreshedProviders.clear();
}
/**
* The fullscreen model hub component. Hosted via `ui.showOverlay(..., { fullscreen: true })`;
* the host must call {@link ModelHubComponent.dispose} when the overlay closes.
*/
export class ModelHubComponent implements Component {
#tui: TUI;
#settings: Settings;
#registry: ModelRegistry;
#scopedModels: ReadonlyArray<ScopedModelItem>;
#callbacks: ModelHubCallbacks;
#browser: ModelBrowser;
#roles: RoleAssignments = {};
#availableItems: ModelBrowserItem[] = [];
#recentItems: ModelBrowserItem[] = [];
#configError: string | undefined;
#entries: SidebarEntry[] = [];
// Sidebar sections from the last registry sync; #composeEntries assembles
// #entries from these (reordered while searching).
#fixedEntries: SidebarEntry[] = [];
#unlockedProviderEntries: SidebarEntry[] = [];
#lockedProviderEntries: SidebarEntry[] = [];
/** Fuzzy match totals while searching: recent-scope hits and overall hits. */
#recentSearchCount = 0;
#searchTotal = 0;
#activeEntryId = "all";
#sidebarScroll = 0;
/** Snap the sidebar viewport to the active entry on the next render; wheel panning leaves it free. */
#sidebarFollowActive = true;
#sidebarHover: number | null = null;
/**
* Arrow-key ownership: `scope` (default) hops the sidebar even while the
* search bar holds the caret; `list` navigates rows (browser models or
* role rows). Tab toggles.
*/
#focus: "scope" | "list" = "scope";
#rolesRows: RolesRow[] = [];
#roleIndex = 0;
#roleHover: number | null = null;
#assigning: AssignTarget | null = null;
#strip: StripState | null = null;
/** Per-provider fuzzy match counts while a query is active; null when not searching. */
#searchCounts: Map<string, number> | null = null;
// Provider discovery refresh (debounced per sidebar selection, with spinner).
#refreshingProviders = new Set<string>();
#scheduledProviderRefreshes = new Map<string, Timer>();
#refreshSpinnerFrame = 0;
#refreshSpinnerInterval?: Timer;
// Frame geometry from the last render, for mouse hit-testing (the
// fullscreen overlay paints from screen row 0, so mouse rows map 1:1).
#contentRowStart = 1;
#contentRowCount = 0;
#sidebarWidthLast = SIDEBAR_MIN_WIDTH;
#footerRow = 0;
#chipRanges: ChipRange[] = [];
#lockedLoginLine: number | null = null;
#rolesRowStart = 1;
constructor(
tui: TUI,
settings: Settings,
registry: ModelRegistry,
scopedModels: ReadonlyArray<ScopedModelItem>,
callbacks: ModelHubCallbacks,
options: ModelHubOptions = {},
) {
this.#tui = tui;
this.#settings = settings;
this.#registry = registry;
this.#scopedModels = scopedModels;
this.#callbacks = callbacks;
this.#browser = new ModelBrowser(settings, {
emptyText: () => this.#emptyStateMessage(),
});
this.#browser.onActivate = item => this.#activateItem(item);
this.#browser.onCancel = () => this.#callbacks.onCancel();
this.#browser.onQueryChange = query => this.#onQueryChanged(query);
// Hydrate synchronously from the current registry snapshot so the first
// Enter after opening acts on cached models instead of being dropped
// while the offline refresh promise is still pending.
this.#syncFromRegistryState();
const initialProvider = options.initialProviderId;
if (initialProvider && this.#entries.some(entry => entry.providerId === initialProvider)) {
this.#setActiveEntry(`provider:${initialProvider}`);
} else {
this.#setActiveEntry("all");
}
// Reconcile with cached discovery state in the background. A --models
// scope is registry-independent, so the offline reload would only repeat
// the synchronous hydration above.
if (this.#scopedModels.length === 0) {
this.#registry
.refresh("offline")
.then(() => this.#syncFromRegistryState())
.catch(error => {
this.#configError = error instanceof Error ? error.message : String(error);
})
.finally(() => this.#tui.requestRender());
}
}
/** Cancel pending provider refresh timers and the spinner. Host calls this on overlay close. */
dispose(): void {
for (const [, timer] of this.#scheduledProviderRefreshes) clearTimeout(timer);
this.#scheduledProviderRefreshes.clear();
this.#refreshingProviders.clear();
if (this.#refreshSpinnerInterval) {
clearInterval(this.#refreshSpinnerInterval);
this.#refreshSpinnerInterval = undefined;
}
}
invalidate(): void {}
// ═══════════════════════════════════════════════════════════════════════
// Data pipeline
// ═══════════════════════════════════════════════════════════════════════
#visibleRoleIds(): string[] {
return getKnownRoleIds(this.#settings).filter(role => !getRoleInfo(role, this.#settings).hidden);
}
/** Resolve every known role: configured values first, auto-selection for the rest. */
#reloadRoles(autoCandidates: ReadonlyArray<Model>): void {
const allModels = this.#scopedModels.length > 0 ? autoCandidates : this.#registry.getAll();
this.#roles = resolveRoleAssignments(this.#settings, allModels, autoCandidates);
}
/** Rebuild items, roles, and the sidebar from the registry's in-memory state. */
#syncFromRegistryState(): void {
let allModels: ReadonlyArray<Model>;
let availableModels: ReadonlyArray<Model>;
if (this.#scopedModels.length > 0) {
allModels = this.#scopedModels.map(scoped => scoped.model);
availableModels = allModels;
this.#configError = undefined;
} else {
const loadError = this.#registry.getError();
this.#configError = loadError ? String(loadError) : undefined;
allModels = this.#registry.getAll();
try {
availableModels = this.#registry.getAvailable();
} catch (error) {
this.#configError = error instanceof Error ? error.message : String(error);
availableModels = [];
}
}
this.#reloadRoles(availableModels);
this.#buildRolesRows();
const storage = this.#settings.getStorage();
const mruOrder = storage?.getModelUsageOrder() ?? [];
this.#availableItems = buildBrowserItems(availableModels);
sortModelItems(this.#availableItems, { roles: this.#roles, mruOrder });
this.#browser.setRoles(this.#roles);
this.#browser.setMruOrder(mruOrder);
this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map());
const bySelector = new Map(this.#availableItems.map(item => [item.selector, item]));
this.#recentItems = [];
for (const key of mruOrder) {
const item = bySelector.get(key);
if (item) this.#recentItems.push(item);
if (this.#recentItems.length >= RECENT_LIMIT) break;
}
this.#buildSidebar(allModels, availableModels);
this.#applyScope();
}
#buildSidebar(allModels: ReadonlyArray<Model>, availableModels: ReadonlyArray<Model>): void {
const scoped = this.#scopedModels.length > 0;
let disabledProviders: ReadonlySet<string>;
try {
disabledProviders = new Set(this.#settings.get("disabledProviders"));
} catch {
disabledProviders = new Set();
}
const availableCounts = new Map<string, number>();
for (const model of availableModels) {
availableCounts.set(model.provider, (availableCounts.get(model.provider) ?? 0) + 1);
}
const catalogCounts = new Map<string, number>();
for (const model of allModels) {
catalogCounts.set(model.provider, (catalogCounts.get(model.provider) ?? 0) + 1);
}
const unlocked = new Set<string>(availableCounts.keys());
const locked = new Set<string>();
if (!scoped) {
const authStorage = this.#registry.authStorage;
for (const provider of catalogCounts.keys()) {
if (!unlocked.has(provider) && !disabledProviders.has(provider)) {
locked.add(provider);
}
}
for (const provider of this.#registry.getDiscoverableProviders()) {
if (unlocked.has(provider) || disabledProviders.has(provider)) continue;
// Discoverable without stored auth: catalog-backed providers stay
// locked; keyless/custom endpoints (ollama, vllm, …) surface as
// selectable so discovery can populate them.
if (authStorage.hasAuth(provider) || !locked.has(provider)) {
locked.delete(provider);
unlocked.add(provider);
}
}
}
const oauthIds = new Set(getOAuthProviders().map(provider => provider.id));
const providerEntry = (providerId: string, isLocked: boolean): SidebarEntry => ({
id: `provider:${providerId}`,
kind: "provider",
label: providerId,
providerId,
locked: isLocked,
annotation: isLocked ? undefined : String(availableCounts.get(providerId) ?? 0),
oauth: oauthIds.has(providerId),
catalogCount: catalogCounts.get(providerId) ?? 0,
});
const visibleRoles = this.#visibleRoleIds();
let assignedCount = 0;
for (const role of visibleRoles) {
const assignment = this.#roles[role];
if (assignment && !assignment.autoSelected) assignedCount++;
}
// Roles leads the fixed section so downward hops from Recent head into
// model scopes instead of being captured by the roles view.
const fixed: SidebarEntry[] = [
{
id: "roles",
kind: "roles",
label: "Roles",
annotation: `${assignedCount}/${visibleRoles.length}`,
},
{ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) },
];
this.#fixedEntries = fixed;
this.#unlockedProviderEntries = [...unlocked]
.sort((a, b) => a.localeCompare(b))
.map(provider => providerEntry(provider, false));
this.#lockedProviderEntries = [...locked]
.sort((a, b) => a.localeCompare(b))
.map(provider => providerEntry(provider, true));
this.#composeEntries();
}
/**
* Assemble `#entries` from the stored sections. While a search is active,
* providers with matches float to the top of the provider section (each
* group stays alphabetical) so the hop order, mouse hit-testing, and the
* paint all agree.
*/
#composeEntries(): void {
const counts = this.#searchCounts;
let providers = this.#unlockedProviderEntries;
if (counts) {
providers = [...providers].sort((a, b) => {
const aMatched = (counts.get(a.providerId ?? "") ?? 0) > 0;
const bMatched = (counts.get(b.providerId ?? "") ?? 0) > 0;
if (aMatched !== bMatched) return aMatched ? -1 : 1;
return a.label.localeCompare(b.label);
});
}
const entries: SidebarEntry[] = [...this.#fixedEntries];
if (providers.length > 0) {
entries.push({ id: "sep:providers", kind: "separator", label: "" }, ...providers);
}
if (this.#lockedProviderEntries.length > 0) {
entries.push({ id: "sep:locked", kind: "separator", label: "" }, ...this.#lockedProviderEntries);
}
this.#entries = entries;
if (!entries.some(entry => entry.id === this.#activeEntryId)) {
this.#activeEntryId = "all";
this.#sidebarFollowActive = true;
}
}
#activeEntry(): SidebarEntry {
return this.#entries.find(entry => entry.id === this.#activeEntryId) ?? this.#entries[0];
}
#setActiveEntry(id: string): void {
if (!this.#entries.some(entry => entry.id === id)) return;
this.#activeEntryId = id;
this.#sidebarFollowActive = true;
this.#applyScope();
const entry = this.#activeEntry();
// Hops must never steal arrow focus: landing on a scope keeps provider
// navigation active. Diving into the roles rows is explicit (Enter, →,
// or a click on the Roles entry).
this.#focus = "scope";
if (entry.kind === "provider" && !entry.locked) {
this.#scheduleProviderRefresh(entry.providerId ?? "");
}
this.#cancelScheduledRefreshesExcept(entry.kind === "provider" ? entry.providerId : undefined);
}
/** Push the active scope's items into the browser. */
#applyScope(): void {
const entry = this.#activeEntry();
switch (entry.kind) {
case "recent":
this.#browser.setShowProvider(true);
this.#browser.setItems([...this.#recentItems]);
break;
case "provider": {
if (entry.locked) {
// Assign-mode renders the browser regardless of scope; a locked
// provider contributes nothing selectable.
this.#browser.setItems([]);
break;
}
const providerId = entry.providerId;
this.#browser.setShowProvider(false);
this.#browser.setItems(this.#availableItems.filter(item => item.provider === providerId));
break;
}
case "roles":
this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1));
break;
default:
this.#browser.setShowProvider(true);
this.#browser.setItems([...this.#availableItems]);
break;
}
}
/**
* The configured `retry.fallbackChains` record with malformed keys/entries
* dropped: non-array chains and non-string selectors never reach the rows
* or chain editors, so an edit through the hub replaces them wholesale.
*/
#fallbackChains(): Record<string, string[]> {
try {
const chains = this.#settings.get("retry.fallbackChains");
if (!chains || typeof chains !== "object" || Array.isArray(chains)) return {};
const sanitized: Record<string, string[]> = {};
for (const key in chains) {
const chain = (chains as Record<string, unknown>)[key];
if (!Array.isArray(chain)) continue;
sanitized[key] = chain.filter((entry): entry is string => typeof entry === "string");
}
return sanitized;
} catch {
return {};
}
}
/**
* Rebuild the Roles view rows: each visible role followed by its
* fallback-chain entries, then model-oriented chains (`provider/model-id`
* and `provider/*` keys) as headed groups.
*/
#buildRolesRows(): void {
const rows: RolesRow[] = [];
const chains = this.#fallbackChains();
for (const role of this.#visibleRoleIds()) {
rows.push({ kind: "role", role });
const chain = chains[role] ?? [];
for (let i = 0; i < chain.length; i++) {
rows.push({ kind: "fallback", role, chainIndex: i, selector: chain[i] });
}
}
rows.push({ kind: "newRole" });
rows.push({ kind: "separator" });
const modelKeys = Object.keys(chains)
.filter(key => key.includes("/"))
.sort();
for (const key of modelKeys) {
const chain = chains[key] ?? [];
rows.push({ kind: "chainKey", role: key });
for (let i = 0; i < chain.length; i++) {
rows.push({ kind: "fallback", role: key, chainIndex: i, selector: chain[i] });
}
}
rows.push({ kind: "newFallback" });
this.#rolesRows = rows;
}
/** Refresh roles + dependent state after a settings mutation (assign/unassign). */
#refreshAfterMutation(): void {
this.#syncFromRegistryState();
this.#tui.requestRender();
}
/** Re-sync after an asynchronous callback finishes mutating settings. */
refreshAfterExternalMutation(): void {
this.#refreshAfterMutation();
}
/**
* Recompute per-provider match counts for the active query. Providers
* without matches gray out and the scope hop skips them; a provider scope
* that just lost its last match falls back to All models so the results
* never silently vanish.
*/
#onQueryChanged(query: string): void {
if (!query.trim()) {
this.#searchCounts = null;
this.#composeEntries();
return;
}
const matches = fuzzyFilter(this.#availableItems, query, ({ provider, id }) => `${provider}/${id}`);
const counts = new Map<string, number>();
for (const item of matches) {
counts.set(item.provider, (counts.get(item.provider) ?? 0) + 1);
}
const recentSelectors = new Set(this.#recentItems.map(item => item.selector));
this.#recentSearchCount = matches.reduce(
(total, item) => total + (recentSelectors.has(item.selector) ? 1 : 0),
0,
);
this.#searchTotal = matches.length;
this.#searchCounts = counts;
this.#composeEntries();
const entry = this.#activeEntry();
if (
this.#assigning === null &&
entry.kind === "provider" &&
(entry.locked || (counts.get(entry.providerId ?? "") ?? 0) === 0)
) {
this.#setActiveEntry("all");
}
}
/**
* Entries the scope hop skips: separators always; while searching, also
* the Roles view (not a model scope), an empty Recent, locked providers,
* and providers without matches.
*/
#isHopSkipped(entry: SidebarEntry): boolean {
if (entry.kind === "separator") return true;
if (!this.#searchCounts) return false;
if (entry.kind === "roles") return true;
if (entry.kind === "recent") return this.#recentSearchCount === 0;
if (entry.kind === "provider") {
if (entry.locked) return true;
return (this.#searchCounts.get(entry.providerId ?? "") ?? 0) === 0;
}
return false;
}
// ═══════════════════════════════════════════════════════════════════════
// Provider discovery refresh
// ═══════════════════════════════════════════════════════════════════════
#startRefreshSpinner(): void {
if (this.#refreshSpinnerInterval) return;
this.#refreshSpinnerInterval = setInterval(() => {
const frameCount = theme.spinnerFrames.length;
if (frameCount > 0) {
this.#refreshSpinnerFrame = (this.#refreshSpinnerFrame + 1) % frameCount;
}
this.#tui.requestRender();
}, 80);
}
#stopRefreshSpinnerIfIdle(): 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.#stopRefreshSpinnerIfIdle();
}
}
#cancelScheduledRefreshesExcept(keepProviderId?: string): void {
for (const [providerId, timer] of this.#scheduledProviderRefreshes) {
if (providerId === keepProviderId) continue;
clearTimeout(timer);
this.#scheduledProviderRefreshes.delete(providerId);
this.#setProviderRefreshing(providerId, false);
}
}
#scheduleProviderRefresh(providerId: string, options?: { force?: boolean }): void {
if (this.#scopedModels.length > 0 || !providerId) return;
if (this.#scheduledProviderRefreshes.has(providerId) || this.#refreshingProviders.has(providerId)) return;
// Hovering a provider must not re-fetch on every visit: auto-refresh runs
// at most once per provider for the process lifetime. F5 forces a re-fetch.
if (!options?.force && autoRefreshedProviders.has(providerId)) return;
this.#setProviderRefreshing(providerId, true);
const timer = setTimeout(() => {
// Consume the once-guard only when the fetch actually starts: hopping
// through a provider cancels the debounce and must not burn its slot.
autoRefreshedProviders.add(providerId);
this.#scheduledProviderRefreshes.delete(providerId);
void this.#refreshProviderInBackground(providerId);
}, PROVIDER_REFRESH_DEBOUNCE_MS);
this.#scheduledProviderRefreshes.set(providerId, timer);
}
async #refreshProviderInBackground(providerId: string): Promise<void> {
try {
await this.#registry.refreshProvider(providerId, "online");
// The provider refresh already updated the registry snapshot;
// re-reading it here stays purely in-memory.
this.#syncFromRegistryState();
} catch (error) {
this.#configError = error instanceof Error ? error.message : String(error);
} finally {
this.#setProviderRefreshing(providerId, false);
this.#tui.requestRender();
}
}
#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";
return `${Math.round(ageMs / 60_000)}m ago`;
}
#emptyStateMessage(): string | undefined {
if (this.#configError) return ` ${this.#configError}`;
const entry = this.#activeEntry();
if (entry.kind === "recent") return " No recently used models yet";
if (entry.kind !== "provider" || entry.locked) return undefined;
if (this.#browser.query.trim()) {
return ` No matching models in ${entry.label}. Switch to All models to search every provider.`;
}
const providerId = entry.providerId ?? "";
const state = this.#registry.getProviderDiscoveryState(providerId);
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": {
const httpMatch = state.error?.match(/^HTTP (\d+) from (.+)$/);
if (httpMatch?.[1] === "404") {
return ` Discovery endpoint ${httpMatch[2]} returned 404. Point baseUrl at the host that serves /models (usually .../v1).`;
}
if (state.error) return ` Discovery failed: ${state.error}`;
return 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;
}
}
// ═══════════════════════════════════════════════════════════════════════
// Assignment flow
// ═══════════════════════════════════════════════════════════════════════
#activateItem(item: ModelBrowserItem): void {
if (this.#assigning) {
const target = this.#assigning;
this.#assigning = null;
if (target.kind === "role") {
this.#assignRole(item, target.role, true);
} else if (target.kind === "fallbackKey") {
this.#openFallbackKeyStrip(item);
} else {
this.#commitFallback(item, target);
}
return;
}
this.#openRoleStrip(item);
}
#roleForScope(role: string, scope: ModelRoleSelectionScope): ResolvedModelRoleValue {
const roleValue =
scope === "project" ? this.#settings.getProjectModelRole(role) : this.#settings.getGlobalModelRole(role);
const allModels =
this.#scopedModels.length > 0 ? this.#scopedModels.map(scoped => scoped.model) : this.#registry.getAll();
const roleLookup: ModelRoleLookup = {
getModelRole: scopedRole =>
scope === "project"
? (this.#settings.getProjectModelRole(scopedRole) ?? this.#settings.getGlobalModelRole(scopedRole))
: this.#settings.getGlobalModelRole(scopedRole),
};
return resolveModelRoleValue(roleValue, allModels, { settings: this.#settings, roleLookup });
}
#thinkingLevelForScope(role: string, scope: ModelRoleSelectionScope): ConfiguredThinkingLevel {
const resolved = this.#roleForScope(role, scope);
return resolved.explicitThinkingLevel ? (resolved.thinkingLevel ?? ThinkingLevel.Inherit) : ThinkingLevel.Inherit;
}
/** Persist `role → item`, preserving a still-supported thinking level, then open the thinking strip. */
#assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean, scope?: ModelRoleSelectionScope): void {
if (this.#settings.get("modelRoleStorage") === "project" && scope === undefined) {
this.#openScopeStrip(item, role, returnToRoles);
return;
}
const current = this.#roles[role];
let level: ConfiguredThinkingLevel = ThinkingLevel.Inherit;
if (this.#settings.get("modelRoleStorage") === "project" && scope !== undefined) {
level = this.#thinkingLevelForScope(role, scope);
} else if (current && !current.autoSelected) {
level = current.thinkingLevel;
}
const supported = this.#thinkingOptionsFor(item.model);
if (!supported.includes(level)) level = ThinkingLevel.Inherit;
this.#callbacks.onAssign(item.model, role, level, item.selector, scope);
this.#refreshAfterMutation();
this.#openThinkingStrip(item, role, returnToRoles, scope);
}
#unassignRole(role: string): void {
const assignment = this.#roles[role];
if (!assignment || assignment.autoSelected) return;
if (this.#settings.get("modelRoleStorage") === "project") {
const source = this.#settings.getModelRoleSource(role);
this.#callbacks.onUnassign(role, source === "default" ? undefined : source);
} else {
this.#callbacks.onUnassign(role);
}
this.#refreshAfterMutation();
}
#thinkingOptionsFor(model: Model): ConfiguredThinkingLevel[] {
return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
}
#openRoleStrip(item: ModelBrowserItem): void {
const chips: StripChip[] = [];
const scopedStorage = this.#settings.get("modelRoleStorage") === "project";
const scopes: readonly ModelRoleSelectionScope[] = scopedStorage ? ["project", "global"] : ["global"];
for (const role of this.#visibleRoleIds()) {
const info = getRoleInfo(role, this.#settings);
const assignment = this.#roles[role];
for (const scope of scopes) {
const scopedModel = scopedStorage
? this.#roleForScope(role, scope).model
: assignment && !assignment.autoSelected
? assignment.model
: undefined;
const assignedHere =
!!scopedModel && scopedModel.provider === item.model.provider && scopedModel.id === item.model.id;
const roleLabel = (info.tag ?? info.name ?? role).toLowerCase();
const label = scopedStorage ? `${scope} ${roleLabel}` : roleLabel;
chips.push({
label,
styled: assignedHere
? // Separator required: under the `nerd` preset this glyph is a
// two-cell-wide PUA icon that `visibleWidth` counts as one, so
// without it the icon overhangs and eats `label`'s first char.
theme.fg(info.color ?? "muted", `${theme.status.enabled} ${label}`) +
theme.fg("dim", ` ${theme.status.success}`)
: theme.fg(info.color ?? "muted", label),
role,
scope,
action: assignedHere ? "unassign" : "assign",
});
}
}
chips.push({
label: `fallbacks:${item.model.id}`,
styled: theme.fg("muted", `fallbacks:${item.model.id}`),
action: "fallbackModel",
});
chips.push({
label: `fallbacks:${item.model.provider}/*`,
styled: theme.fg("muted", `fallbacks:${item.model.provider}/*`),
action: "fallbackProvider",
});
chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" });
this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
}
#openScopeStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
const chips: StripChip[] = [
{ label: "project", styled: theme.fg("accent", "project"), action: "scope", scope: "project" },
{ label: "global", styled: theme.fg("muted", "global"), action: "scope", scope: "global" },
];
this.#strip = { kind: "scope", item, role, chips, index: 0, returnToRoles };
}
#openThinkingStrip(
item: ModelBrowserItem,
role: string,
returnToRoles: boolean,
scope?: ModelRoleSelectionScope,
): void {
const options = this.#thinkingOptionsFor(item.model);
const current =
this.#settings.get("modelRoleStorage") === "project" && scope !== undefined
? this.#thinkingLevelForScope(role, scope)
: (this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit);
const chips: StripChip[] = options.map(level => {
const label = getConfiguredThinkingLevelMetadata(level).label;
const glyph = thinkingLevelGlyph(level);
return {
label,
styled: glyph ? `${theme.fg("accent", glyph)} ${label}` : label,
action: "thinking",
thinkingLevel: level,
};
});
const preselect = options.indexOf(current);
this.#strip = {
kind: "thinking",
item,
role,
scope,
chips,
index: preselect >= 0 ? preselect : 0,
returnToRoles,
};
}
#closeStrip(): void {
const strip = this.#strip;
this.#strip = null;
this.#chipRanges = [];
if ((strip?.kind === "scope" || strip?.kind === "thinking") && strip.returnToRoles) {
this.#setActiveEntry("roles");
this.#focus = "list";
}
}
#activateStripChip(): void {
const strip = this.#strip;
if (!strip || strip.kind === "roleName") return;
const chip = strip.chips[strip.index];
if (!chip) return;
switch (chip.action) {
case "assign":
if (chip.role) {
this.#strip = null;
this.#assignRole(strip.item, chip.role, false, chip.scope);
}
return;
case "unassign":
if (chip.role) {
if (this.#settings.get("modelRoleStorage") === "project") {
this.#callbacks.onUnassign(chip.role, chip.scope);
} else {
this.#callbacks.onUnassign(chip.role);
}
this.#refreshAfterMutation();
}
this.#closeStrip();
return;
case "fallback":
this.#appendFallback(strip.item, "default");
this.#closeStrip();
return;
case "fallbackModel":
this.#closeStrip();
this.#startAssignFallback(strip.item.selector, null);
return;
case "fallbackProvider":
this.#closeStrip();
this.#startAssignFallback(`${strip.item.model.provider}/*`, null);
return;
case "scope":
if (strip.role && chip.scope) {
this.#strip = null;
this.#assignRole(strip.item, strip.role, strip.returnToRoles, chip.scope);
}
return;
case "thinking":
if (strip.role && chip.thinkingLevel !== undefined) {
this.#callbacks.onAssign(
strip.item.model,
strip.role,
chip.thinkingLevel,
strip.item.selector,
strip.scope,
);
this.#refreshAfterMutation();
}
this.#closeStrip();
return;
}
}
/** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */
#startAssign(role: string): void {
this.#assigning = { kind: "role", role };
this.#focus = "scope";
this.#browser.setShowProvider(true);
this.#browser.setItems([...this.#availableItems]);
this.#browser.setQuery("");
const current = this.#roles[role];
if (current) {
this.#browser.selectSelector(`${current.model.provider}/${current.model.id}`);
}
}
/** Browse the catalog to fill a fallback-chain slot: `index` replaces an entry, `null` appends. */
#startAssignFallback(role: string, index: number | null): void {
this.#assigning = { kind: "fallback", role, index };
this.#focus = "scope";
this.#browser.setShowProvider(true);
this.#browser.setItems([...this.#availableItems]);
this.#browser.setQuery("");
if (index !== null) {
const selector = this.#fallbackChains()[role]?.[index];
if (selector) this.#browser.selectSelector(selector);
}
}
/** Browse the catalog for the primary model a brand-new fallback chain protects. */
#startAssignFallbackKey(): void {
this.#assigning = { kind: "fallbackKey" };
this.#focus = "scope";
this.#browser.setShowProvider(true);
this.#browser.setItems([...this.#availableItems]);
this.#browser.setQuery("");
}
/** Second step of "+ New fallback…": key the chain by the picked model or its whole provider. */
#openFallbackKeyStrip(item: ModelBrowserItem): void {
const chips: StripChip[] = [
{
label: `for ${item.selector}`,
styled: theme.fg("muted", `for ${item.selector}`),
action: "fallbackModel",
},
{
label: `for ${item.model.provider}/*`,
styled: theme.fg("muted", `for ${item.model.provider}/*`),
action: "fallbackProvider",
},
];
this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
}
/** Write the picked model into the target chain slot, dedupe, and land back on its Roles row. */
#commitFallback(item: ModelBrowserItem, target: { role: string; index: number | null }): void {
const chain = [...(this.#fallbackChains()[target.role] ?? [])];
const selector = item.selector;
if (target.index !== null && target.index < chain.length) {
chain[target.index] = selector;
for (let i = chain.length - 1; i >= 0; i--) {
if (i !== target.index && chain[i] === selector) chain.splice(i, 1);
}
} else if (!chain.includes(selector)) {
chain.push(selector);
}
this.#setFallbackChain(target.role, chain);
this.#browser.setQuery("");
this.#setActiveEntry("roles");
this.#focus = "list";
const rowIndex = this.#rolesRows.findIndex(
row => row.kind === "fallback" && row.role === target.role && row.selector === selector,
);
if (rowIndex >= 0) this.#roleIndex = rowIndex;
}
/** Persist `role`'s chain through the host callback and rebuild dependent state. */
#setFallbackChain(role: string, chain: string[]): void {
this.#callbacks.onFallbackChainChange?.(role, chain);
this.#refreshAfterMutation();
}
/** Append `item` to `role`'s fallback chain (no-op when already present). */
#appendFallback(item: ModelBrowserItem, role: string): void {
const chain = [...(this.#fallbackChains()[role] ?? [])];
if (chain.includes(item.selector)) return;
chain.push(item.selector);
this.#setFallbackChain(role, chain);
}
/** Remove one chain entry; the cursor stays on the nearest surviving row. */
#removeFallback(row: { role: string; chainIndex: number }): void {
const chain = [...(this.#fallbackChains()[row.role] ?? [])];
if (row.chainIndex >= chain.length) return;
chain.splice(row.chainIndex, 1);
this.#setFallbackChain(row.role, chain);
this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRows.length - 1));
}
/** Move a chain entry one slot earlier/later; the cursor follows the moved entry. */
#moveFallback(row: { role: string; chainIndex: number }, delta: -1 | 1): void {
const chain = [...(this.#fallbackChains()[row.role] ?? [])];
const target = row.chainIndex + delta;
if (row.chainIndex >= chain.length || target < 0 || target >= chain.length) return;
[chain[row.chainIndex], chain[target]] = [chain[target], chain[row.chainIndex]];
this.#setFallbackChain(row.role, chain);
this.#roleIndex += delta;
}
#cancelAssign(): void {
this.#assigning = null;
this.#browser.setQuery("");
this.#setActiveEntry("roles");
this.#focus = "list";
}
// ═══════════════════════════════════════════════════════════════════════
// Quick-switch cycle (ctrl+p) editing
// ═══════════════════════════════════════════════════════════════════════
#cycleOrder(): string[] {
try {
return [...this.#settings.get("cycleOrder")];
} catch {
return [];
}
}
/** Toggle `role`'s membership in the quick-switch cycle (appended at the end). */
#toggleCycleMembership(role: string): void {
const order = this.#cycleOrder();
const index = order.indexOf(role);
if (index >= 0) {
order.splice(index, 1);
} else {
order.push(role);
}
this.#callbacks.onCycleOrderChange?.(order);
this.#refreshAfterMutation();
}
/** Move `role` one slot earlier/later within the cycle order. */
#moveCycleMembership(role: string, delta: -1 | 1): void {
const order = this.#cycleOrder();
const index = order.indexOf(role);
const target = index + delta;
if (index < 0 || target < 0 || target >= order.length) return;
[order[index], order[target]] = [order[target], order[index]];
this.#callbacks.onCycleOrderChange?.(order);
this.#refreshAfterMutation();
}
/** Open the footer name input that creates a new custom role. */
#openRoleNameStrip(): void {
this.#strip = { kind: "roleName", input: new Input() };
}
/** Validate and commit the new-role name: jump straight into assigning it. */
#submitRoleName(): void {
const strip = this.#strip;
if (strip?.kind !== "roleName") return;
const name = strip.input.getValue().trim();
if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
if (this.#visibleRoleIds().includes(name)) return;
this.#strip = null;
this.#chipRanges = [];
this.#startAssign(name);
}
// ═══════════════════════════════════════════════════════════════════════
// Input
// ═══════════════════════════════════════════════════════════════════════
handleInput(data: string): void {
if (data.startsWith("\x1b[<")) {
routeSgrMouseInput(data, event => this.#routeMouseEvent(event));
return;
}
if (this.#strip) {
this.#handleStripInput(data);
return;
}
if (matchesSelectCancel(data)) {
if (this.#assigning !== null) {
this.#cancelAssign();
return;
}
const entry = this.#activeEntry();
if (this.#isBrowserView(entry) && this.#browser.query.length > 0) {
this.#browser.handleCancel();
return;
}
this.#callbacks.onCancel();
return;
}
const entry = this.#activeEntry();
const rolesView = entry.kind === "roles" && this.#assigning === null;
const lockedView = entry.kind === "provider" && entry.locked && this.#assigning === null;
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
this.#focus = this.#focus === "scope" ? "list" : "scope";
return;
}
if (matchesKey(data, "f5")) {
if (entry.kind === "provider" && !entry.locked) {
this.#scheduleProviderRefresh(entry.providerId ?? "", { force: true });
}
return;
}
// ←/→ are spatial pane switches: the sidebar sits left of the rows.
// They never reach the search caret — fuzzy queries don't need one.
if (matchesKey(data, "left")) {
this.#focus = "scope";
return;
}
if (matchesKey(data, "right")) {
// Only views with rows can take list focus (not the locked pane).
if (rolesView || this.#isBrowserView(entry)) {
this.#focus = "list";
}
return;
}
// Arrow ownership: scope mode hops the sidebar even while the search
// bar holds the caret; list mode navigates rows.
if (this.#focus === "scope") {
if (matchesSelectUp(data)) {
this.#moveSidebar(-1);
return;
}
if (matchesSelectDown(data)) {
this.#moveSidebar(1);
return;
}
}
if (rolesView) {
this.#handleRolesViewInput(data);
return;
}
if (lockedView) {
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
this.#requestLogin(entry);
}
return;
}
this.#browser.handleInput(data);
}
#isBrowserView(entry: SidebarEntry): boolean {
if (this.#assigning !== null) return true;
return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked);
}
#handleStripInput(data: string): void {
const strip = this.#strip;
if (!strip) return;
if (matchesSelectCancel(data)) {
this.#closeStrip();
return;
}
if (strip.kind === "roleName") {
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
this.#submitRoleName();
return;
}
strip.input.handleInput(data);
return;
}
if (matchesKey(data, "left") || matchesKey(data, "up") || matchesKey(data, "shift+tab")) {
strip.index = (strip.index - 1 + strip.chips.length) % strip.chips.length;
return;
}
if (matchesKey(data, "right") || matchesKey(data, "down") || matchesKey(data, "tab")) {
strip.index = (strip.index + 1) % strip.chips.length;
return;
}
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
this.#activateStripChip();
return;
}
}
#moveSidebar(delta: number): void {
const count = this.#entries.length;
if (count === 0) return;
let index = this.#entries.findIndex(entry => entry.id === this.#activeEntryId);
if (index < 0) index = 0;
for (let step = 0; step < count; step++) {
index = (index + delta + count) % count;
const entry = this.#entries[index];
if (entry && !this.#isHopSkipped(entry)) {
// Scope changes keep an active assignment (scoping helps find the
// model); landing on the Roles view cancels it.
if (entry.kind === "roles") this.#assigning = null;
this.#setActiveEntry(entry.id);
return;
}
}
}
/** Row count of the roles view (roles, their fallback entries, and the trailing "+ New role…" row). */
get #rolesRowCount(): number {
return this.#rolesRows.length;
}
/** Enter/click activation for a Roles-view row. */
#activateRolesRow(row: RolesRow): void {
switch (row.kind) {
case "role":
this.#startAssign(row.role);
return;
case "chainKey":
this.#startAssignFallback(row.role, null);
return;
case "fallback":
this.#startAssignFallback(row.role, row.chainIndex);
return;
case "newFallback":
this.#startAssignFallbackKey();
return;
case "newRole":
this.#openRoleNameStrip();
return;
case "separator":
return;
}
}
/** Step the roles cursor by one row, skipping separator rows. Wraps at the ends unless `wrap: false` (then the cursor stays put). */
#stepRoleIndex(from: number, delta: -1 | 1, options: { wrap?: boolean } = {}): number {
const wrap = options.wrap ?? true;
const count = this.#rolesRows.length;
if (count === 0) return 0;
let index = from;
for (let i = 0; i < count; i++) {
const next = index + delta;
if (next < 0 || next >= count) {
if (!wrap) return from;
index = (next + count) % count;
} else {
index = next;
}
if (this.#rolesRows[index]?.kind !== "separator") return index;
}
return from;
}
#handleRolesViewInput(data: string): void {
// Scope focus treats the roles view as a preview: Enter/Space dives
// into the rows, everything else is inert (arrows already hop).
if (this.#focus === "scope") {
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" || matchesKey(data, "space")) {
this.#focus = "list";
}
return;
}
if (matchesSelectUp(data)) {
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, -1);
return;
}
if (matchesSelectDown(data)) {
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1);
return;
}
const row = this.#rolesRows[this.#roleIndex];
const role = row?.kind === "role" ? row.role : undefined;
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
if (row) this.#activateRolesRow(row);
return;
}
if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
if (role) this.#unassignRole(role);
else if (row?.kind === "fallback") this.#removeFallback(row);
else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
return;
}
// Reordering: [ / shift+↑ moves the row earlier, ] / shift+↓ later —
// cycle order on a role row, chain order on a fallback row.
if (matchesKey(data, "shift+up")) {
if (role) this.#moveCycleMembership(role, -1);
else if (row?.kind === "fallback") this.#moveFallback(row, -1);
return;
}
if (matchesKey(data, "shift+down")) {
if (role) this.#moveCycleMembership(role, 1);
else if (row?.kind === "fallback") this.#moveFallback(row, 1);
return;
}
const printable = extractPrintableText(data);
if (printable === "x") {
if (role) this.#unassignRole(role);
else if (row?.kind === "fallback") this.#removeFallback(row);
else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
return;
}
if (printable === "f") {
if (row?.kind === "newFallback") this.#startAssignFallbackKey();
else if (row && row.kind !== "newRole" && row.kind !== "separator") {
this.#startAssignFallback(row.role, null);
}
return;
}
if (printable === "c") {
if (role) this.#toggleCycleMembership(role);
return;
}
if (printable === "[") {
if (role) this.#moveCycleMembership(role, -1);
else if (row?.kind === "fallback") this.#moveFallback(row, -1);
return;
}
if (printable === "]") {
if (role) this.#moveCycleMembership(role, 1);
else if (row?.kind === "fallback") this.#moveFallback(row, 1);
return;
}
if (printable === "n") {
this.#openRoleNameStrip();
return;
}
if (printable === "t") {
const assignment = role ? this.#roles[role] : undefined;
if (role && assignment) {
const source =
this.#settings.get("modelRoleStorage") === "project"
? this.#settings.getModelRoleSource(role)
: "default";
const scope = source === "project" || source === "global" ? source : undefined;
const scopedModel = scope ? this.#roleForScope(role, scope).model : assignment.model;
if (!scopedModel) return;
const item: ModelBrowserItem = {
provider: scopedModel.provider,
id: scopedModel.id,
model: scopedModel,
selector: `${scopedModel.provider}/${scopedModel.id}`,
};
this.#openThinkingStrip(item, role, true, scope);
}
return;
}
}
#requestLogin(entry: SidebarEntry): void {
if (!entry.providerId) return;
if (entry.oauth) {
this.#callbacks.onLoginRequest?.(entry.providerId);
}
}
// ═══════════════════════════════════════════════════════════════════════
// Mouse
// ═══════════════════════════════════════════════════════════════════════
#routeMouseEvent(event: SgrMouseEvent): boolean {
const contentLine = event.row - this.#contentRowStart;
const overContent = contentLine >= 0 && contentLine < this.#contentRowCount;
const sidebarColStart = 2;
const sidebarColEnd = sidebarColStart + this.#sidebarWidthLast;
const bodyColStart = this.#sidebarWidthLast + 5;
const overSidebar = overContent && event.col >= 0 && event.col < sidebarColEnd;
const overBody = overContent && event.col >= bodyColStart;
const bodyLine = contentLine - 1; // body row 0 is the status row
const entry = this.#activeEntry();
// Footer strip chips.
if (event.row === this.#footerRow && this.#strip) {
const strip = this.#strip;
if (event.leftClick && strip.kind !== "roleName") {
for (const range of this.#chipRanges) {
if (event.col >= range.start && event.col < range.end) {
strip.index = range.index;
this.#activateStripChip();
return true;
}
}
}
return true;
}
if (event.wheel !== null) {
if (overSidebar) {
// Wheel pans the sidebar viewport; picking a scope is click/keys only.
const maxScroll = Math.max(0, this.#entries.length - this.#contentRowCount);
this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll + event.wheel, maxScroll));
this.#sidebarHover = this.#sidebarEntryIndexAt(contentLine);
} else if (overBody) {
if (entry.kind === "roles" && this.#assigning === null) {
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1, { wrap: false });
} else if (this.#isBrowserView(entry)) {
this.#browser.routeMouse(event, bodyLine);
}
}
return true;
}
if (event.motion) {
this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
if (overBody && entry.kind === "roles" && this.#assigning === null) {
const roleLine = bodyLine - this.#rolesRowStart;
this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
} else {
this.#roleHover = null;
if (overBody && this.#isBrowserView(entry)) {
this.#browser.routeMouse(event, bodyLine);
} else {
// Pointer left the browser pane: without this, the last
// hovered row keeps its band while the sidebar hovers too.
this.#browser.clearHover();
}
}
return true;
}
if (!event.leftClick) return true;
if (overSidebar) {
const index = this.#sidebarEntryIndexAt(contentLine);
const clicked = index !== null ? this.#entries[index] : undefined;
if (clicked && clicked.kind !== "separator") {
const already = clicked.id === this.#activeEntryId;
if (clicked.kind === "roles") this.#assigning = null;
this.#setActiveEntry(clicked.id);
// A click on Roles is a deliberate dive into the rows.
if (clicked.kind === "roles") this.#focus = "list";
if (already && clicked.kind === "provider" && clicked.locked) {
this.#requestLogin(clicked);
}
}
return true;
}
if (overBody) {
if (entry.kind === "roles" && this.#assigning === null) {
this.#focus = "list";
const roleLine = bodyLine - this.#rolesRowStart;
if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
const rowDef = this.#rolesRows[roleLine];
if (rowDef && rowDef.kind !== "separator") {
if (roleLine === this.#roleIndex) {
this.#activateRolesRow(rowDef);
} else {
this.#roleIndex = roleLine;
}
}
}
} else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) {
this.#requestLogin(entry);
}
} else if (this.#isBrowserView(entry)) {
this.#browser.routeMouse(event, bodyLine);
}
}
return true;
}
/** Map a content-line index to a sidebar entry index (accounting for scroll). */
#sidebarEntryIndexAt(contentLine: number): number | null {
const index = this.#sidebarScroll + contentLine;
if (index < 0 || index >= this.#entries.length) return null;
return index;
}
// ═══════════════════════════════════════════════════════════════════════
// Rendering
// ═══════════════════════════════════════════════════════════════════════
#sidebarWidth(): number {
let longest = 0;
for (const entry of this.#entries) {
const annotation = entry.annotation ?? "";
longest = Math.max(longest, visibleWidth(entry.label) + visibleWidth(annotation) + 5);
}
return Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, longest));
}
#renderSidebar(width: number, rows: number): string[] {
// The scroll offset is persistent: the wheel pans it freely. Only an
// activation (keys, click, programmatic) snaps the viewport to the
// active entry, and only far enough to reveal it.
if (this.#sidebarFollowActive) {
const activeIndex = Math.max(
0,
this.#entries.findIndex(entry => entry.id === this.#activeEntryId),
);
if (activeIndex < this.#sidebarScroll) {
this.#sidebarScroll = activeIndex;
} else if (activeIndex >= this.#sidebarScroll + rows) {
this.#sidebarScroll = activeIndex - rows + 1;
}
this.#sidebarFollowActive = false;
}
this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll, Math.max(0, this.#entries.length - rows)));
const lines: string[] = [];
for (let i = this.#sidebarScroll; i < Math.min(this.#entries.length, this.#sidebarScroll + rows); i++) {
const entry = this.#entries[i];
if (!entry) continue;
if (entry.kind === "separator") {
lines.push(theme.fg("border", "─".repeat(width)));
continue;
}
const active = entry.id === this.#activeEntryId;
const hovered = i === this.#sidebarHover;
const searching = this.#searchCounts !== null;
let matchCount: number | undefined;
if (searching) {
if (entry.kind === "provider" && !entry.locked) {
matchCount = this.#searchCounts?.get(entry.providerId ?? "") ?? 0;
} else if (entry.kind === "recent") {
matchCount = this.#recentSearchCount;
} else if (entry.kind === "all") {
matchCount = this.#searchTotal;
}
}
// While searching, entries the hop skips gray out: locked and
// zero-match providers, an empty Recent, and the Roles view.
const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles");
// The sidebar's active entry is state, not a cursor: accent label
// plus a cursor glyph while the sidebar owns the arrows. The band
// stays in the body pane so the two never look alike.
const cursor = active && this.#focus === "scope" ? theme.fg("accent", theme.nav.cursor) : " ";
let icon: string;
if (entry.kind === "recent") {
icon = theme.icon.time;
} else if (entry.kind === "roles") {
icon = theme.icon.extensionSkill;
} else if (entry.kind === "all") {
icon = theme.icon.model;
} else {
icon = muted ? theme.status.shadowed : theme.status.enabled;
}
const labelStyled = muted
? theme.fg("dim", entry.label)
: active
? theme.bold(theme.fg("accent", entry.label))
: entry.label;
const refreshing = entry.providerId ? this.#refreshingProviders.has(entry.providerId) : false;
const annotationText = matchCount !== undefined ? String(matchCount) : (entry.annotation ?? "");
const annotationStyled = refreshing
? theme.fg("warning", theme.spinnerFrames[this.#refreshSpinnerFrame % theme.spinnerFrames.length] ?? "")
: theme.fg("dim", annotationText);
const left = `${cursor} ${muted ? theme.fg("dim", icon) : theme.fg(entry.kind === "provider" ? "success" : "accent", icon)} ${labelStyled}`;
const leftWidth = visibleWidth(left);
const annWidth = visibleWidth(annotationStyled);
let line: string;
if (leftWidth + annWidth + 1 <= width) {
line = `${left}${" ".repeat(width - leftWidth - annWidth)}${annotationStyled}`;
} else {
line = truncateToWidth(left, width);
const lineWidth = visibleWidth(line);
if (lineWidth < width) line += " ".repeat(width - lineWidth);
}
if (hovered) {
line = theme.bg("selectedBg", line);
}
lines.push(line);
}
return lines;
}
#statusRow(width: number): string {
if (this.#assigning !== null) {
if (this.#assigning.kind === "fallbackKey") {
return truncateToWidth(
theme.fg("accent", " New fallback chain — Enter picks the model it protects, Esc cancels"),
width,
);
}
const info = getRoleInfo(this.#assigning.role, this.#settings);
const label = info.tag ?? info.name ?? this.#assigning.role;
if (this.#assigning.kind === "fallback") {
const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of";
return truncateToWidth(
theme.fg("accent", ` ${verb} ${theme.bold(label)} — Enter picks the fallback model, Esc cancels`),
width,
);
}
return truncateToWidth(
theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`),
width,
);
}
const entry = this.#activeEntry();
const scopedSuffix = this.#scopedModels.length > 0 ? " · --models scope" : "";
let text: string;
switch (entry.kind) {
case "recent":
text = `Recently used models${scopedSuffix}`;
break;
case "roles":
text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
break;
case "provider":
if (entry.locked) {
text = `${entry.label} · not configured`;
} else if (entry.providerId && this.#refreshingProviders.has(entry.providerId)) {
text = `${entry.label} · refreshing model list…`;
} else {
text = `${entry.label} · ${entry.annotation ?? "0"} models${scopedSuffix}`;
}
break;
default:
text = `All available models${scopedSuffix}`;
break;
}
if (this.#configError && entry.kind !== "provider") {
text = this.#configError;
return truncateToWidth(theme.fg("error", ` ${text}`), width);
}
return truncateToWidth(theme.fg("muted", ` ${text}`), width);
}
/** Clamp a roles row to `width`; the bg band is reserved for mouse hover. */
#finishRolesRow(line: string, width: number, hovered: boolean): string {
let out = truncateToWidth(line, width);
if (hovered) {
const w = visibleWidth(out);
if (w < width) out += " ".repeat(width - w);
return theme.bg("selectedBg", out);
}
return out;
}
#renderRolesView(width: number, rows: number): string[] {
const lines: string[] = [];
lines.push("");
// First row's offset in bodyLine coordinates: the mouse router's
// `bodyLine` has already dropped the status row, so this is just the
// leading blank line — no extra status-row offset here.
this.#rolesRowStart = lines.length;
let tagWidth = 0;
for (const rowDef of this.#rolesRows) {
if (rowDef.kind !== "role") continue;
const info = getRoleInfo(rowDef.role, this.#settings);
tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? rowDef.role));
}
const cycleOrder = this.#cycleOrder();
const listFocused = this.#focus === "list";
for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
const rowDef = this.#rolesRows[i];
if (!rowDef) continue;
const selected = i === this.#roleIndex;
const hovered = i === this.#roleHover;
// The unfocused pane draws no cursor; accent text still marks the row.
const cursor = selected && listFocused ? theme.fg("accent", theme.nav.cursor) : " ";
if (rowDef.kind === "separator") {
lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`);
continue;
}
if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") {
const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…";
let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`;
line = this.#finishRolesRow(line, width, hovered);
lines.push(line);
continue;
}
if (rowDef.kind === "chainKey") {
const key = rowDef.role;
const slash = key.lastIndexOf("/");
const tail = key.slice(slash + 1);
const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail);
let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`;
line = this.#finishRolesRow(line, width, hovered);
lines.push(line);
continue;
}
if (rowDef.kind === "fallback") {
const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`);
const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector);
let line = ` ${cursor} ${branch} ${selector}`;
line = this.#finishRolesRow(line, width, hovered);
lines.push(line);
continue;
}
const role = rowDef.role;
const info = getRoleInfo(role, this.#settings);
const assignment = this.#roles[role];
const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth);
let dot: string;
let tagStyled: string;
let value: string;
let levelStyled = "";
if (assignment && !assignment.autoSelected) {
dot = theme.fg(info.color ?? "muted", theme.status.enabled);
tagStyled = theme.fg(info.color ?? "muted", tag);
value = `${theme.fg("dim", `${assignment.model.provider}/`)}${selected ? theme.fg("accent", assignment.model.id) : assignment.model.id}`;
const glyph = thinkingLevelGlyph(assignment.thinkingLevel);
const label = getConfiguredThinkingLevelMetadata(assignment.thinkingLevel).label;
if (assignment.thinkingLevel !== ThinkingLevel.Inherit) {
levelStyled = theme.fg("dim", glyph ? `${glyph} ${label}` : label);
}
} else if (assignment) {
dot = theme.fg("dim", theme.status.shadowed);
tagStyled = theme.fg("dim", tag);
value = theme.fg("dim", `auto → ${assignment.model.provider}/${assignment.model.id}`);
} else {
dot = theme.fg("dim", theme.status.shadowed);
tagStyled = theme.fg("dim", tag);
value = theme.fg("dim", "—");
}
// Quick-cycle membership badge (`⟳2` = second stop of the ctrl+p cycle).
const cycleIndex = cycleOrder.indexOf(role);
const cycleStyled = cycleIndex >= 0 ? theme.fg("accent", `${theme.icon.loop}${cycleIndex + 1}`) : "";
let line = ` ${cursor} ${dot} ${tagStyled} ${value}`;
const right = [levelStyled, cycleStyled].filter(part => part.length > 0).join(" ");
const rightWidth = visibleWidth(right);
const lineWidth = visibleWidth(line);
if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) {
line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`;
}
line = this.#finishRolesRow(line, width, hovered);
lines.push(line);
}
// Live preview of the quick-switch cycle, rendered with the exact
// segment track the ctrl+p status uses; the selected role's chip fills.
while (lines.length < rows - 1) lines.push("");
if (rows >= 2) {
const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
if (cycleOrder.length > 0) {
const selectedRow = this.#rolesRows[this.#roleIndex];
const selectedRole =
selectedRow && (selectedRow.kind === "role" || selectedRow.kind === "fallback") ? selectedRow.role : "";
const activeIndex = cycleOrder.indexOf(selectedRole);
const track = renderSegmentTrack(
cycleOrder.map(role => ({ label: role })),
activeIndex,
);
lines[rows - 1] = truncateToWidth(` ${theme.fg("dim", `${cycleKey} cycle:`)} ${track}`, width);
} else {
lines[rows - 1] = truncateToWidth(
theme.fg("dim", ` ${cycleKey} cycle is empty — press c on a role to add it`),
width,
);
}
}
return lines;
}
#renderLockedView(entry: SidebarEntry, width: number, rows: number): string[] {
const lines: string[] = [];
this.#lockedLoginLine = null;
lines.push("");
lines.push(truncateToWidth(theme.fg("warning", ` ${entry.label} has no credentials configured`), width));
lines.push("");
const envVars = entry.providerId ? (getCatalogProviderEntry(entry.providerId)?.envVars ?? []) : [];
if (envVars.length > 0) {
lines.push(
truncateToWidth(
theme.fg("muted", ` Set ${envVars.join(" or ")} in your environment, or add a key in config.`),
width,
),
);
} else {
lines.push(truncateToWidth(theme.fg("muted", " Add an API key for this provider in config."), width));
}
if (entry.oauth) {
this.#lockedLoginLine = lines.length + 1; // +1 for the status row offset handled by caller
lines.push(truncateToWidth(theme.fg("accent", ` ${theme.nav.cursor} Log in with OAuth (Enter)`), width));
}
lines.push("");
const catalogCount = entry.catalogCount ?? 0;
if (catalogCount > 0) {
lines.push(truncateToWidth(theme.fg("dim", ` ${catalogCount} models in catalog:`), width));
const preview = this.#scopedModels.length > 0 ? [] : this.#registry.getAll();
for (const model of preview) {
if (model.provider !== entry.providerId) continue;
if (lines.length >= rows) break;
lines.push(truncateToWidth(theme.fg("dim", ` ${model.id}`), width));
}
}
while (lines.length < rows) lines.push("");
return lines.slice(0, rows);
}
#footerHint(): string {
const strip = this.#strip;
if (strip) {
if (strip.kind === "roleName") {
return "Enter create + pick model · Esc cancel";
}
if (strip.kind === "role") return "←/→ choose · Enter assign/clear · Esc cancel";
if (strip.kind === "scope") return "←/→ save scope · Enter choose · Esc cancel";
return "←/→ thinking level · Enter apply · Esc keep";
}
if (this.#assigning !== null) {
switch (this.#assigning.kind) {
case "fallback":
return "Enter pick fallback · ↑/↓ providers · type to search · Esc cancel";
case "fallbackKey":
return "Enter pick the protected model · ↑/↓ providers · type to search · Esc cancel";
default:
return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
}
}
const entry = this.#activeEntry();
if (entry.kind === "roles") {
if (this.#focus !== "list") {
return "↑/↓ providers · → roles · Esc close";
}
const row = this.#rolesRows[this.#roleIndex];
if (row?.kind === "fallback") {
return "↑/↓ rows · Enter replace · f add another · x remove · [/] reorder · ← providers";
}
if (row?.kind === "chainKey") {
return "↑/↓ rows · Enter/f add fallback · x clear chain · ← providers";
}
if (row?.kind === "newFallback") {
return "↑/↓ rows · Enter new model/provider fallback chain · ← providers";
}
return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new";
}
if (entry.kind === "provider" && entry.locked) {
return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
}
const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
return `Enter assign roles · ${arrows} · type to search${refresh} · Esc close`;
}
/** Footer row: active strip (chips) or the contextual hint line. */
#renderFooter(width: number): string {
this.#chipRanges = [];
const strip = this.#strip;
if (!strip) {
return truncateToWidth(theme.fg("dim", this.#footerHint()), width);
}
if (strip.kind === "roleName") {
const label = theme.fg("accent", "New role name:");
const inputWidth = Math.max(8, Math.min(32, width - visibleWidth("New role name:") - 24));
const inputLine = strip.input.render(inputWidth)[0] ?? "";
return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
}
const prefix =
strip.kind === "role"
? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} `
: `${theme.fg(getRoleInfo(strip.role ?? "", this.#settings).color ?? "muted", (getRoleInfo(strip.role ?? "", this.#settings).tag ?? strip.role ?? "").toLowerCase())}${theme.fg("dim", ` · ${strip.item.id} →`)} `;
// Horizontal window: once the strip overflows, drop leading chips behind
// a dim ellipsis so the selected chip (plus one chip of lookahead when it
// fits) stays visible while cycling right.
const prefixWidth = visibleWidth(prefix);
const available = Math.max(1, width - prefixWidth);
const chipWidths = strip.chips.map(
(chip, i) => visibleWidth(` ${chip.styled} `) + (i === strip.index ? 2 : 0) + 1,
);
// Smallest start index whose window [start..target] (with its "… " lead-in
// when start > 0) fits in the available width; `target` itself may still
// overflow when a single chip is wider than the row.
const startFor = (target: number): number => {
let start = 0;
while (start < target) {
let sum = start > 0 ? 2 : 0;
for (let i = start; i <= target; i++) sum += chipWidths[i] ?? 0;
if (sum <= available) break;
start++;
}
return start;
};
let start = startFor(Math.min(strip.index + 1, strip.chips.length - 1));
if (start > strip.index) start = startFor(strip.index);
let line = prefix;
// Columns are relative to the frame: row() insets content by 2.
let col = 2 + prefixWidth;
if (start > 0) {
line += theme.fg("dim", "… ");
col += 2;
}
for (let i = start; i < strip.chips.length; i++) {
const chip = strip.chips[i];
if (!chip) continue;
const selected = i === strip.index;
const body = ` ${chip.styled} `;
const rendered = selected
? theme.bg("selectedBg", `${theme.fg("accent", "[")}${body}${theme.fg("accent", "]")}`)
: body;
const w = visibleWidth(body) + (selected ? 2 : 0);
this.#chipRanges.push({ start: col, end: col + w, index: i });
line += rendered;
col += w;
line += " ";
col += 1;
}
return truncateToWidth(line, width);
}
render(width: number): string[] {
const height = Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40);
const sidebarWidth = this.#sidebarWidth();
this.#sidebarWidthLast = sidebarWidth;
const bodyWidth = splitBodyWidth(width, sidebarWidth);
const contentRows = Math.max(10, height - 4);
this.#contentRowCount = contentRows;
const entry = this.#activeEntry();
const bodyLines: string[] = [this.#statusRow(bodyWidth)];
if (entry.kind === "roles" && this.#assigning === null) {
bodyLines.push(...this.#renderRolesView(bodyWidth, contentRows - 1));
} else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1));
} else {
this.#browser.setMaxVisible(contentRows - 1 - 5);
this.#browser.setFocused(this.#focus === "list");
bodyLines.push(...this.#browser.render(bodyWidth));
}
const sidebarLines = this.#renderSidebar(sidebarWidth, contentRows);
const out: string[] = [];
out.push(topBorderSplit(width, "Models", sidebarWidth));
this.#contentRowStart = out.length;
for (let i = 0; i < contentRows; i++) {
out.push(splitRow(sidebarLines[i] ?? "", bodyLines[i] ?? "", width, sidebarWidth));
}
out.push(dividerSplit(width, sidebarWidth));
this.#footerRow = out.length;
out.push(row(this.#renderFooter(width - 4), width));
out.push(bottomBorder(width));
return out;
}
}