import { truncateToWidth } from "@earendil-works/pi-tui"; import type { CustomUiComponent, CustomUiTui, ModelLike, ThinkingLevel, } from "../_shared/pi-api.js"; import { renderOperatorBlock, type OperatorBlock, type OperatorThemeLike, } from "../_shared/operator-ui.js"; import { formatAssignment, type ModelRoleAssignment, type ModelRolesState, type ModelRoleSource, } from "../_shared/model-settings.js"; export type ModelRoleSupport = "active" | "fallback" | "dormant"; export const MODEL_ROLE_ACTIONS = [ { role: "default", tag: "DEFAULT", label: "Set DEFAULT route", capability: "active · session + route fallback", narrowCapability: "active · session/fallback", support: "active", appliesCurrentModel: true, }, { role: "agent", tag: "AGENT", label: "Set AGENT route", capability: "active · agents/workflows primary", narrowCapability: "active · agents", support: "active", appliesCurrentModel: false, }, { role: "task", tag: "TASK", label: "Set TASK route", capability: "fallback · agents/workflows", narrowCapability: "fallback · agents", support: "fallback", appliesCurrentModel: false, }, { role: "plan", tag: "PLAN", label: "Set PLAN route", capability: "dormant · beta prompt planning", narrowCapability: "dormant · beta", support: "dormant", appliesCurrentModel: false, }, { role: "summary", tag: "SUMMARY", label: "Set SUMMARY route", capability: "dormant · resolver only", narrowCapability: "dormant · resolver", support: "dormant", appliesCurrentModel: false, }, { role: "smol", tag: "SMOL", label: "Set SMOL route", capability: "fallback-only · summary resolver", narrowCapability: "fallback-only", support: "fallback", appliesCurrentModel: false, }, ] as const; export const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; export type ModelRoleAction = (typeof MODEL_ROLE_ACTIONS)[number]; export interface ModelRoleSelection { model: ModelLike; action: ModelRoleAction; thinking: ThinkingLevel; } export interface ModelRoleReceipt { kind: "success" | "warning" | "error"; text: string; } export interface AppliedModelRoleState { currentSelector: string | undefined; currentThinking: ThinkingLevel | undefined; roleSummaries: RoleSummary[]; receipt: ModelRoleReceipt; } export interface ModelRow { model: ModelLike; selector: string; label: string; provider: string; roleTags: string[]; current: boolean; rank: number; } export interface RoleSummary { role: string; tag: string; source: ModelRoleSource; inherited: boolean; assignment?: ModelRoleAssignment; } export interface ModelEffortCapability { levels: ThinkingLevel[]; source: "registry" | "legacy" | "unknown"; known: boolean; } interface CapabilityModel extends ModelLike { reasoning?: boolean; thinkingLevelMap?: Partial>; } export interface ModelRoleSelectorOptions { rows: ModelRow[]; roleSummaries: RoleSummary[]; currentSelector: string | undefined; currentThinking: ThinkingLevel | undefined; applySelection(selection: ModelRoleSelection): Promise; done?: () => void; } type SelectorStage = "models" | "roles" | "effort"; const ALL_PROVIDER_FILTER = "ALL"; /** Pi-compatible effort projection from registry capability metadata. */ export function modelEffortCapability(model: ModelLike | undefined): ModelEffortCapability { if (model === undefined) return { levels: ["off"], source: "unknown", known: false }; const capability = model as CapabilityModel; if (typeof capability.reasoning === "boolean" || capability.thinkingLevelMap !== undefined) { if (capability.reasoning === false) return { levels: ["off"], source: "registry", known: true }; const levels = THINKING_LEVELS.filter((level) => { const mapped = capability.thinkingLevelMap?.[level]; if (mapped === null) return false; if (level === "xhigh") return mapped !== undefined; return true; }); return { levels: [...levels], source: "registry", known: true }; } if (Array.isArray(capability.thinking)) { const advertised = capability.thinking.filter( (level): level is ThinkingLevel => (THINKING_LEVELS as readonly string[]).includes(level), ); return { levels: [...new Set(["off", ...advertised])], source: "legacy", known: advertised.length > 0, }; } return { levels: ["off"], source: "unknown", known: false }; } export function effortLevelsForModel(model: ModelLike | undefined): ThinkingLevel[] { return [...modelEffortCapability(model).levels]; } export function buildModelRows( models: ModelLike[], state: ModelRolesState, currentSelector: string | undefined, ): ModelRow[] { const rowsBySelector = new Map(); for (const [index, model] of models.entries()) { const selector = modelSelector(model); const roleTags = assignedRoleTags(selector, state); const current = currentSelector === selector; const row: ModelRow = { model, selector, label: modelLabel(model), provider: cleanSelectorPart(model.provider), roleTags, current, rank: modelRowRank(roleTags, current, index), }; const existing = rowsBySelector.get(selector); rowsBySelector.set(selector, existing ? preferredDuplicateRow(existing, row) : row); } return [...rowsBySelector.values()].sort((left, right) => left.rank - right.rank || left.label.localeCompare(right.label)); } export function roleSummaries(state: ModelRolesState): RoleSummary[] { return MODEL_ROLE_ACTIONS.map((action) => { const effective = state.effective.get(action.role); return { role: action.role, tag: action.tag, source: effective?.source ?? "unset", inherited: effective?.inherited ?? false, ...(effective?.assignment === undefined ? {} : { assignment: effective.assignment }), }; }); } export function modelSelector(model: ModelLike): string { const provider = cleanSelectorPart(model.provider); const id = cleanSelectorPart(model.id); if (!provider || !id) return ""; return id.includes("/") ? id : `${provider}/${id}`; } export class ModelRoleSelectorComponent implements CustomUiComponent { readonly #tui: CustomUiTui; readonly #theme: OperatorThemeLike; #rows: ModelRow[]; readonly #providers: string[]; #roleSummaries: RoleSummary[]; #currentSelector: string | undefined; #currentThinking: ThinkingLevel | undefined; readonly #applySelection: (selection: ModelRoleSelection) => Promise; #done: (() => void) | undefined; #stage: SelectorStage = "models"; #activeProviderIndex = 0; #selectedIndex = 0; #roleIndex = 0; #effortIndex = 0; #selectedAction: ModelRoleAction | undefined; #applying = false; #receipt: ModelRoleReceipt | undefined; constructor(tui: CustomUiTui, theme: OperatorThemeLike, options: ModelRoleSelectorOptions) { this.#tui = tui; this.#theme = theme; this.#rows = options.rows; this.#roleSummaries = options.roleSummaries; this.#currentSelector = options.currentSelector; this.#currentThinking = options.currentThinking; this.#applySelection = options.applySelection; this.#done = options.done; this.#providers = [ ALL_PROVIDER_FILTER, ...[...new Set(options.rows.map((row) => row.provider).filter(Boolean))] .sort((left, right) => left.localeCompare(right)), ]; } render(width: number): string[] { const safeWidth = Math.max(1, Math.floor(width)); return renderOperatorBlock(this.#operatorBlock(safeWidth), safeWidth, this.#theme); } async handleInput(data: string): Promise { if (this.#applying) return; if (data === "q" || data === "Q") { this.#finish(); return; } if (isEscape(data)) { if (this.#stage === "models") this.#finish(); else this.#back(); return; } if (isLeft(data)) { if (this.#stage === "models") this.#moveProvider(-1); else this.#back(); return; } if (this.#stage === "models" && (isRight(data) || data === "\t")) { this.#moveProvider(1); return; } if (isDown(data)) { this.#moveFocus(1); return; } if (isUp(data)) { this.#moveFocus(-1); return; } if (this.#stage === "models" && isPageDown(data)) { this.#moveSelection(8); return; } if (this.#stage === "models" && isPageUp(data)) { this.#moveSelection(-8); return; } if (isEnter(data)) await this.#advance(); } invalidate(): void { // Projection is rebuilt from semantic state on every render. } #operatorBlock(width: number): OperatorBlock { const current = this.#currentSelector ?? "unset"; const effort = this.#currentThinking === undefined ? "" : ` · effort ${semanticText(this.#theme, "warning", this.#currentThinking, true)}`; const body = [ this.#defaultRouteLine(), ...this.#routingLines(width), ...this.#stageBody(width), ...(this.#receipt === undefined ? [] : [receiptLine(this.#receipt)]), ...(this.#applying ? ["[WORKING] Applying model route…"] : []), ]; return { type: "SELECT", subject: "Model roles", primary: `${semanticText(this.#theme, "warning", "Current", true)} session model: ${semanticText(this.#theme, "accent", current, true)}${effort}`, badges: [ { text: this.#stage, tone: "accent" }, { text: `filter:${this.#providers[this.#activeProviderIndex] ?? ALL_PROVIDER_FILTER}`, tone: "success" }, ], body, controls: [this.#controls()], }; } #stageBody(width: number): string[] { if (this.#stage === "models") return this.#modelBody(width); if (this.#stage === "roles") return this.#roleBody(width); return this.#effortBody(width); } #modelBody(width: number): string[] { const rows = this.#visibleRows(); const limit = visibleModelLimit(width); const start = windowStart(this.#selectedIndex, rows.length, limit); const end = Math.min(start + limit, rows.length); return [ this.#filterLine(width), this.#rolesLine(width), `Models ${rows.length === 0 ? "0" : `${start + 1}-${end}`} of ${rows.length}`, ...rows.slice(start, end).map((row, offset) => this.#modelRow(row, start + offset === this.#selectedIndex, width)), ]; } #roleBody(width: number): string[] { const row = this.#selectedRow(); if (row === undefined) return ["Selected model: none"]; return [ `Selected model: ${semanticText(this.#theme, "accent", row.selector, true)}`, "Choose routing role:", ...MODEL_ROLE_ACTIONS.map((action, index) => { const selected = index === this.#roleIndex; const marker = selected ? semanticText(this.#theme, "accent", ">", true) : " "; const capability = width < 60 ? action.narrowCapability : action.capability; const role = semanticText(this.#theme, selected ? "accent" : roleTone(action.tag), action.tag, selected); return truncateToWidth(`${marker} ${role} · ${semanticText(this.#theme, "dim", capability)}`, Math.max(1, width - 4)); }), ]; } #effortBody(width: number): string[] { const row = this.#selectedRow(); const action = this.#selectedAction; if (row === undefined || action === undefined) return ["Selected route: none"]; const capability = modelEffortCapability(row.model); const capabilityLabel = capability.known ? capability.source : "unknown; off only"; return [ `Selected model: ${semanticText(this.#theme, "accent", row.selector, true)}`, `Route: ${semanticText(this.#theme, roleTone(action.tag), action.tag, true)} · ${width < 60 ? action.narrowCapability : action.capability}`, `Effort capability: ${capabilityLabel}`, ...(capability.levels.length === 0 ? ["[ERROR] Model advertises no supported effort levels."] : capability.levels.map((level, index) => { const selected = index === this.#effortIndex; const marker = selected ? semanticText(this.#theme, "accent", ">", true) : " "; return `${marker} ${semanticText(this.#theme, selected ? "accent" : "text", level, selected)}`; })), ]; } #defaultRouteLine(): string { const summary = this.#roleSummaries.find((item) => item.role === "default"); const label = semanticText(this.#theme, "warning", "DEFAULT", true); if (summary?.assignment === undefined) return `${label} route: unset`; const inherited = summary.inherited ? " · inherited" : ""; const assignment = semanticText(this.#theme, "warning", formatAssignment(summary.assignment), true); return `${label} route: ${assignment} · ${routeSourceLabel(summary.source)}${inherited}`; } #routingLines(width: number): string[] { const assigned = this.#roleSummaries.filter((item) => item.role !== "default" && item.assignment !== undefined); if (assigned.length === 0) return ["Routing roles: none"]; return [ "Routing roles:", ...assigned.map((item) => truncateToWidth( ` ${semanticText(this.#theme, "warning", item.tag, true)}=${semanticText(this.#theme, "warning", formatAssignment(item.assignment!), true)}`, Math.max(1, width - 4), )), ]; } #filterLine(width: number): string { const active = this.#providers[this.#activeProviderIndex] ?? ALL_PROVIDER_FILTER; if (width < 60) { const selected = semanticText(this.#theme, "success", `[${formatProvider(active)}]`, true); return `Provider filter ${this.#activeProviderIndex + 1}/${this.#providers.length}: ${selected}`; } const filters = this.#providers.map((provider, index) => index === this.#activeProviderIndex ? semanticText(this.#theme, "success", `[${formatProvider(provider)}]`, true) : semanticText(this.#theme, "dim", formatProvider(provider))); return truncateToWidth(`Provider filters: ${filters.join(" ")}`, Math.max(1, width - 4)); } #rolesLine(width: number): string { const actions = width < 60 ? MODEL_ROLE_ACTIONS.slice(0, 2) : MODEL_ROLE_ACTIONS; const roles = actions.map((action) => semanticText(this.#theme, roleTone(action.tag), action.tag, true)); const hidden = MODEL_ROLE_ACTIONS.length - actions.length; return truncateToWidth( `Available roles: ${roles.join(" · ")}${hidden > 0 ? ` · +${hidden}` : ""}`, Math.max(1, width - 4), ); } #modelRow(row: ModelRow, selected: boolean, width: number): string { const markers = [row.current ? "CURRENT" : "", ...row.roleTags].filter(Boolean); const markerText = markers.length === 0 ? "" : `${markers.map((marker) => semanticText(this.#theme, "warning", `[${marker}]`, true)).join(" ")} `; const pointer = selected ? semanticText(this.#theme, "accent", ">", true) : " "; const assigned = row.current || row.roleTags.length > 0; const selector = semanticText(this.#theme, assigned ? "warning" : "accent", row.selector, selected || assigned); const name = modelNameSuffix(row.model); return truncateToWidth( `${pointer} ${markerText}${selector}${name === "" ? "" : semanticText(this.#theme, "dim", name)}`, Math.max(1, width - 4), ); } #controls(): string { if (this.#stage === "models") return "↑↓/jk models · Tab/←→ filters · Enter roles · q/Esc close"; if (this.#stage === "roles") return "↑↓/jk roles · Enter effort · Esc/← models · q close"; return "↑↓/jk effort · Enter apply · Esc/← roles · q close"; } async #advance(): Promise { if (this.#stage === "models") { if (this.#selectedRow() === undefined) return; this.#stage = "roles"; this.#receipt = undefined; this.#requestRender(); return; } if (this.#stage === "roles") { const row = this.#selectedRow(); const action = MODEL_ROLE_ACTIONS[this.#roleIndex]; if (row === undefined || action === undefined) return; this.#selectedAction = action; const levels = effortLevelsForModel(row.model); this.#effortIndex = preferredEffortIndex(levels, this.#roleSummaries, action, row, this.#currentSelector, this.#currentThinking); this.#stage = "effort"; this.#receipt = undefined; this.#requestRender(); return; } await this.#applyCurrentSelection(); } async #applyCurrentSelection(): Promise { const row = this.#selectedRow(); const action = this.#selectedAction; const levels = row === undefined ? [] : effortLevelsForModel(row.model); const thinking = levels[this.#effortIndex]; if (row === undefined || action === undefined || thinking === undefined) return; this.#applying = true; this.#requestRender(); try { const applied = await this.#applySelection({ model: row.model, action, thinking }); this.#currentSelector = applied.currentSelector; this.#currentThinking = applied.currentThinking; this.#roleSummaries = applied.roleSummaries; this.#receipt = applied.receipt; this.#refreshRows(); this.#restoreSelectedModel(row.selector); this.#stage = applied.receipt.kind === "error" ? "effort" : "models"; if (this.#stage === "models") this.#selectedAction = undefined; } catch (error) { const message = error instanceof Error ? error.message : String(error); this.#receipt = { kind: "error", text: `Apply failed: ${message}` }; this.#stage = "effort"; } finally { this.#applying = false; this.#requestRender(); } } #refreshRows(): void { this.#rows = this.#rows.map((row, index) => { const roleTags = this.#roleSummaries .filter((role) => role.assignment?.model === row.selector) .map((role) => role.tag); const current = row.selector === this.#currentSelector; return { ...row, roleTags, current, rank: modelRowRank(roleTags, current, index) }; }).sort((left, right) => left.rank - right.rank || left.label.localeCompare(right.label)); } #restoreSelectedModel(selector: string): void { const index = this.#visibleRows().findIndex((row) => row.selector === selector); this.#selectedIndex = Math.max(0, index); } #back(): void { if (this.#stage === "effort") { this.#stage = "roles"; this.#selectedAction = undefined; } else { this.#stage = "models"; } this.#requestRender(); } #moveFocus(delta: number): void { if (this.#stage === "models") this.#moveSelection(delta); else if (this.#stage === "roles") { this.#roleIndex = cycleIndex(this.#roleIndex, delta, MODEL_ROLE_ACTIONS.length); this.#requestRender(); } else { const row = this.#selectedRow(); const levels = row === undefined ? [] : effortLevelsForModel(row.model); this.#effortIndex = cycleIndex(this.#effortIndex, delta, levels.length); this.#requestRender(); } } #moveProvider(delta: number): void { this.#activeProviderIndex = cycleIndex(this.#activeProviderIndex, delta, this.#providers.length); this.#selectedIndex = 0; this.#receipt = undefined; this.#requestRender(); } #moveSelection(delta: number): void { const total = this.#visibleRows().length; if (total === 0) return; this.#selectedIndex = cycleIndex(this.#selectedIndex, delta, total); this.#requestRender(); } #visibleRows(): ModelRow[] { const provider = this.#providers[this.#activeProviderIndex] ?? ALL_PROVIDER_FILTER; return provider === ALL_PROVIDER_FILTER ? this.#rows : this.#rows.filter((row) => row.provider === provider); } #selectedRow(): ModelRow | undefined { return this.#visibleRows()[this.#selectedIndex]; } #finish(): void { const done = this.#done; this.#done = undefined; done?.(); } #requestRender(): void { this.#tui.requestRender(); } } export function createModelRoleSelectorTheme(theme: unknown): OperatorThemeLike { if (typeof theme !== "object" || theme === null) return {}; const candidate = theme as Record; const fg = typeof candidate.fg === "function" ? candidate.fg : undefined; const bold = typeof candidate.bold === "function" ? candidate.bold : undefined; return { ...(fg === undefined ? {} : { fg: (color: Parameters>[0], text: string) => String(fg.call(candidate, color, text)) }), ...(bold === undefined ? {} : { bold: (text: string) => String(bold.call(candidate, text)) }), }; } function semanticText( theme: OperatorThemeLike, tone: Parameters>[0], text: string, bold = false, ): string { const colored = typeof theme.fg === "function" ? theme.fg(tone, text) : text; return bold && typeof theme.bold === "function" ? theme.bold(colored) : colored; } function roleTone(tag: string): "warning" | "accent" { return tag === "CURRENT" || tag === "DEFAULT" || tag === "SMOL" ? "warning" : "accent"; } function modelNameSuffix(model: ModelLike): string { return typeof model.name === "string" && model.name.trim() ? ` — ${model.name.trim()}` : ""; } function preferredDuplicateRow(left: ModelRow, right: ModelRow): ModelRow { const winner = right.label.length < left.label.length ? right : left; return { ...winner, current: left.current || right.current, roleTags: [...new Set([...left.roleTags, ...right.roleTags])], rank: Math.min(left.rank, right.rank), }; } function assignedRoleTags(selector: string, state: ModelRolesState): string[] { return MODEL_ROLE_ACTIONS .filter((action) => state.effective.get(action.role)?.assignment?.model === selector) .map((action) => action.tag); } function modelRowRank(roleTags: string[], current: boolean, originalIndex: number): number { if (current) return -1; const firstRoleIndex = MODEL_ROLE_ACTIONS.findIndex((action) => roleTags.includes(action.tag)); return firstRoleIndex >= 0 ? firstRoleIndex : MODEL_ROLE_ACTIONS.length + originalIndex; } function modelLabel(model: ModelLike): string { const name = typeof model.name === "string" && model.name.trim() ? ` — ${model.name.trim()}` : ""; return `${modelSelector(model)}${name}`; } function cleanSelectorPart(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } function preferredEffortIndex( levels: readonly ThinkingLevel[], summaries: readonly RoleSummary[], action: ModelRoleAction, row: ModelRow, currentSelector: string | undefined, currentThinking: ThinkingLevel | undefined, ): number { const assigned = summaries.find((summary) => summary.role === action.role)?.assignment; const preferred = assigned?.model === row.selector ? assigned.thinking : action.appliesCurrentModel && currentSelector === row.selector ? currentThinking : "off"; const index = preferred === undefined ? -1 : levels.indexOf(preferred); return Math.max(0, index); } function receiptLine(receipt: ModelRoleReceipt): string { const marker = receipt.kind === "success" ? "OK" : receipt.kind === "warning" ? "WARN" : "ERROR"; return `[${marker}] ${receipt.text}`; } function routeSourceLabel(source: ModelRoleSource): string { return source === "session" ? "project + session evidence" : source; } function formatProvider(provider: string): string { return provider.replace(/[-_]+/g, " ").toUpperCase(); } function visibleModelLimit(width: number): number { if (width >= 100) return 8; if (width >= 60) return 6; return 4; } function windowStart(selected: number, total: number, limit: number): number { if (total <= limit) return 0; return Math.max(0, Math.min(selected - Math.floor(limit / 2), total - limit)); } function cycleIndex(index: number, delta: number, total: number): number { if (total <= 0) return 0; return (index + delta + total) % total; } function isEnter(data: string): boolean { return data === "\r" || data === "\n"; } function isEscape(data: string): boolean { return data === "\x1b"; } function isUp(data: string): boolean { return data === "\x1b[A" || data === "\x1bOA" || data === "k"; } function isDown(data: string): boolean { return data === "\x1b[B" || data === "\x1bOB" || data === "j"; } function isRight(data: string): boolean { return data === "\x1b[C" || data === "\x1bOC" || data === "l"; } function isLeft(data: string): boolean { return data === "\x1b[D" || data === "\x1bOD" || data === "h"; } function isPageDown(data: string): boolean { return data === "\x1b[6~"; } function isPageUp(data: string): boolean { return data === "\x1b[5~"; }