/** * `/workflows-models` command handler — the role-routing editor (issue #142). * * Uses Pi's built-in `ctx.ui.select()`, `ctx.ui.confirm()`, and `ctx.ui.notify()` * to let users view and manage model routing configuration for workflows. * * The primary surface is the semantic role layer (worker / conductor / advisor / * security), each with a default model and optional named specialist routes * (e.g. escalation, long-context, independent). A clearly-marked legacy-tier * migration section (small/medium/big) is preserved for compatibility — size * tiers are accepted only as deprecated migration inputs. * * Model selection draws from the same `listAvailableModelSpecs()` that powers * Pi's `/model` command, so users see exactly the same models. */ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent"; import { Container, type SelectItem, SelectList, type SelectListTheme, Spacer, Text, type TUI, } from "@earendil-works/pi-tui"; import { listAvailableModelSpecs } from "./agent.js"; import { buildDefaultTierConfig, loadModelTierConfig, type ModelRole, type ModelRoleConfig, modelTierConfigWarnings, type RoleDefinition, type RoleMap, type RoleRoute, saveModelTierConfig, sortedRoleNames, sortedTierNames, } from "./model-tier-config.js"; const ROLE_DESCRIPTIONS: Record = { worker: "local-first implementation, exploration, mechanical work, fan-out", conductor: "planning, orchestration, DAG decomposition, correction coordination", advisor: "adversarial review, judgment, final verification, architecture decisions", security: "read-only authority-boundary review (secrets/auth/sandbox/supply chain)", }; const COMMON_ROUTES: readonly string[] = ["escalation", "long-context", "independent"]; /** * Register the `/workflows-models` command with Pi. */ export function registerWorkflowModelsCommand(pi: ExtensionAPI): void { pi.registerCommand("workflows-models", { description: "View and edit model routing roles for workflows (worker/conductor/advisor/security)", handler: async (_args, ctx) => { await ctx.waitForIdle(); // Load the saved config, or build an in-memory default (all roles = the // user's current Pi model). Nothing is written to disk until the user // explicitly chooses "Save and exit". const currentModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined; let config = loadModelTierConfig() ?? buildDefaultTierConfig(currentModel); let dirty = false; notifyRoutingWarnings(ctx, config); const ensureFresh = (cfg: ModelRoleConfig) => { config = cfg; dirty = true; }; // eslint-disable-next-line no-constant-condition while (true) { const roles = sortedRoleNames(config); const tiers = sortedTierNames(config); const menuOptions: string[] = []; menuOptions.push("─ Roles (semantic routing) ─".padEnd(30, "─")); for (const name of roles) { const def = config.roles?.[name as ModelRole]; const routeCount = def?.routes ? Object.keys(def.routes).length : 0; const routeSuffix = routeCount ? ` [${routeCount} route${routeCount === 1 ? "" : "s"}]` : ""; menuOptions.push(`${name} role → ${def?.default ?? "(unset)"}${routeSuffix}`); } menuOptions.push("─ Legacy tiers (DEPRECATED, migration only) ─".padEnd(30, "─")); for (const name of tiers) { const model = config.tiers?.[name]; menuOptions.push(`${name} tier → ${model ?? "(unset)"}`); } menuOptions.push("─".repeat(30)); menuOptions.push("Reset to defaults"); menuOptions.push(dirty ? "Save and exit" : "Exit"); const choice = await ctx.ui.select("Model routing configuration", menuOptions); if (!choice) break; // Handle " → [model]" selections let handled = false; for (const name of roles) { if (choice.startsWith(`${name} role →`)) { const updated = await editRole(ctx, config, name as ModelRole); if (updated) ensureFresh(updated); handled = true; break; } } if (handled) continue; // Handle legacy " → [model]" selections for (const name of tiers) { if (choice.startsWith(`${name} tier →`)) { const updatedTiers = await editSingleTier(ctx, config.tiers ?? {}, name); if (updatedTiers !== null) { ensureFresh({ ...config, tiers: updatedTiers }); } break; } } if (choice === "Reset to defaults") { const confirmed = await ctx.ui.confirm( "Reset model routing", "This will reset every role and legacy tier to your current Pi model. Continue?", ); if (confirmed) { ensureFresh({ ...buildDefaultTierConfig(currentModel), routingNotes: config.routingNotes }); ctx.ui.notify("Routing reset to defaults. Use 'Save and exit' to persist.", "info"); } } if (choice === "Save and exit" || choice === "Exit") { if (choice === "Save and exit") { saveModelTierConfig(config); ctx.ui.notify("Model routing saved.", "info"); notifyRoutingWarnings(ctx, config); } break; } } }, }); } function notifyRoutingWarnings(ctx: ExtensionCommandContext, config: ModelRoleConfig): void { for (const warning of modelTierConfigWarnings(config)) { ctx.ui.notify(warning, "warning"); } } /** * Edit a single role: choose an action (change default model, manage a named * route, or remove a route). Returns the updated config, or null if unchanged. */ async function editRole( ctx: ExtensionCommandContext, config: ModelRoleConfig, role: ModelRole, ): Promise { const roles: Partial = config.roles ?? {}; const def: RoleDefinition = roles[role] ?? { default: "" }; const desc = ROLE_DESCRIPTIONS[role]; const routeNames = def.routes ? Object.keys(def.routes) : []; const options: string[] = [ `Default model → ${def.default || "(unset)"}`, "─ Named specialist routes ─", ...COMMON_ROUTES.map((r) => { const existing = def.routes?.[r]; return existing ? `${r} route → ${existing.model}` : `+ add ${r} route`; }), ...routeNames.filter((r) => !COMMON_ROUTES.includes(r)).map((r) => `${r} route → ${def.routes?.[r]?.model}`), "─", "Remove a route", ]; const choice = await ctx.ui.select(`${role} role (${desc})`, options); if (!choice) return null; if (choice.startsWith("Default model →")) { const picked = await pickModelFor(ctx, `${role} role default`, def.default); if (!picked || picked === def.default) return null; return { ...config, roles: { ...roles, [role]: { ...def, default: picked } } }; } // Add/edit a common route for (const route of COMMON_ROUTES) { const prefix = `${route} route →`; const addPrefix = `+ add ${route} route`; if (choice.startsWith(prefix) || choice === addPrefix) { const existing = def.routes?.[route]?.model; const picked = await pickModelFor(ctx, `${role} route "${route}"`, existing); if (!picked) return null; const updatedRoutes: Record = { ...(def.routes ?? {}) }; updatedRoutes[route] = { model: picked, ...(def.routes?.[route]?.note ? { note: def.routes[route].note } : {}) }; return { ...config, roles: { ...roles, [role]: { ...def, routes: updatedRoutes } } }; } } // Custom existing route for (const route of routeNames) { if (COMMON_ROUTES.includes(route)) continue; const prefix = `${route} route →`; if (choice.startsWith(prefix)) { const existing = def.routes?.[route]?.model; const picked = await pickModelFor(ctx, `${role} route "${route}"`, existing); if (!picked) return null; const updatedRoutes: Record = { ...(def.routes ?? {}) }; updatedRoutes[route] = { model: picked, ...(def.routes?.[route]?.note ? { note: def.routes[route].note } : {}) }; return { ...config, roles: { ...roles, [role]: { ...def, routes: updatedRoutes } } }; } } if (choice === "Remove a route") { if (routeNames.length === 0) { ctx.ui.notify("No routes to remove.", "info"); return null; } const removeChoice = await ctx.ui.select(`Remove a route from ${role}`, routeNames); if (!removeChoice) return null; const updatedRoutes: Record = { ...(def.routes ?? {}) }; delete updatedRoutes[removeChoice]; return { ...config, roles: { ...roles, [role]: { ...def, routes: updatedRoutes } } }; } return null; } /** * Scrollable model picker. Returns the chosen spec, or null if cancelled/unchanged. */ async function pickModelFor( ctx: ExtensionCommandContext, label: string, current: string | undefined, ): Promise { // Use the host session's registry so extension-registered providers (e.g. // ollama-cloud) are listed, not only statically configured disk models. const available = listAvailableModelSpecs(ctx.modelRegistry); const items: SelectItem[] = available.map((m) => ({ value: m, label: m })); const result = await ctx.ui.custom((tui: TUI, theme: Theme, _keybindings, done) => { const container = new Container(); const titleText = current ? `Pick a model for "${label}" (current: ${current})` : `Pick a model for "${label}"`; container.addChild(new Text(theme.fg("accent", titleText), 1, 0)); container.addChild(new Spacer(1)); const selectTheme: SelectListTheme = { selectedPrefix: (t: string) => theme.bg("selectedBg", theme.fg("accent", t)), selectedText: (t: string) => theme.bg("selectedBg", theme.bold(t)), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }; const selectList = new SelectList(items, 12, selectTheme); if (current) { const idx = items.findIndex((i) => i.value === current); if (idx >= 0) selectList.setSelectedIndex(idx); } selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0)); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }); if (!result || result === current) return null; ctx.ui.notify(`"${label}" → ${result}`, "info"); return result; } /** * Interactive editor for a single legacy tier — scrollable model picker. * * Returns the updated tiers object, or null if nothing changed. * * @deprecated Kept for the legacy-tier migration section; new code edits roles. */ export async function editSingleTier( ctx: ExtensionCommandContext, tiers: Record, tierName: string, ): Promise | null> { const current = tiers[tierName]; const picked = await pickModelFor(ctx, `${tierName} tier (legacy)`, current); if (!picked || picked === current) return null; return { ...tiers, [tierName]: picked }; }