import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { Input, Container, TUI, Text, Spacer, fuzzyFilter, getKeybindings } from "@earendil-works/pi-tui"; import type { Focusable } from "@earendil-works/pi-tui"; import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { getSupportedLevels, displayToPi } from "./shared.js"; interface ModelEntry { provider: string; model: string; } function loadEnabledModels(): Set | null { const dir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"); const path = join(dir, "settings.json"); if (existsSync(path)) { try { const raw = readFileSync(path, "utf-8"); const data = JSON.parse(raw); if (Array.isArray(data.enabledModels)) return new Set(data.enabledModels); } catch { /* fall through */ } } return null; } function getEnabledModels(ctx: ExtensionCommandContext): ModelEntry[] { const allModels = ctx.modelRegistry.getAll(); const enabledSet = loadEnabledModels(); const seen = new Set(); const entries: ModelEntry[] = []; for (const m of allModels) { const key = `${m.provider}/${m.id}`; if (seen.has(key)) continue; seen.add(key); if (enabledSet && !enabledSet.has(key)) continue; entries.push({ provider: m.provider, model: m.id }); } return entries; } /** * Универсальный searchable-список для TUI. * Рендерит `renderLine` для каждого элемента, фильтрует по `filterKey`. */ class SearchableSelector extends Container implements Focusable { private searchInput: Input; private listContainer: Container; private allItems: T[]; private filteredItems: T[]; private selectedIndex = 0; private onSelectCallback: (item: T) => void; private onCancelCallback: () => void; private theme: Theme; private headerText: Text; private hintText: Text; private headerLabel: string; private filterKey: (item: T) => string; private renderLine: (item: T, isSelected: boolean) => string; private emptyLabel: string; private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.searchInput.focused = value; } constructor(opts: { theme: Theme; items: T[]; headerLabel: string; emptyLabel: string; filterKey: (item: T) => string; renderLine: (item: T, isSelected: boolean) => string; onSelect: (item: T) => void; onCancel: () => void; }) { super(); this.theme = opts.theme; this.allItems = opts.items; this.filteredItems = opts.items; this.headerLabel = opts.headerLabel; this.emptyLabel = opts.emptyLabel; this.filterKey = opts.filterKey; this.renderLine = opts.renderLine; this.onSelectCallback = opts.onSelect; this.onCancelCallback = opts.onCancel; this.addChild(new DynamicBorder((s: string) => opts.theme.fg("accent", s))); this.addChild(new Spacer(1)); this.headerText = new Text("", 1, 0); this.addChild(this.headerText); this.addChild(new Spacer(1)); this.searchInput = new Input(); this.searchInput.onSubmit = () => { const selected = this.filteredItems[this.selectedIndex]; if (selected) this.onSelectCallback(selected); }; this.addChild(this.searchInput); this.addChild(new Spacer(1)); this.listContainer = new Container(); this.addChild(this.listContainer); this.addChild(new Spacer(1)); this.hintText = new Text("", 1, 0); this.addChild(this.hintText); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder((s: string) => opts.theme.fg("accent", s))); this.updateHeader(); this.updateHints(); this.applyFilter(""); } private updateHeader(): void { this.headerText.setText( this.theme.fg("accent", this.theme.bold(`${this.headerLabel} (${this.allItems.length})`)), ); } private updateHints(): void { this.hintText.setText( this.theme.fg("dim", "Type to search · ↑↓ select · Enter confirm · Esc cancel"), ); } private applyFilter(query: string): void { if (!query.trim()) { this.filteredItems = this.allItems; } else { this.filteredItems = fuzzyFilter(this.allItems, query, this.filterKey); } this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1)); this.updateList(); } private updateList(): void { this.listContainer.clear(); if (this.filteredItems.length === 0) { this.listContainer.addChild(new Text(this.theme.fg("muted", ` ${this.emptyLabel}`), 0, 0)); return; } const maxVisible = 12; const startIndex = Math.max( 0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredItems.length - maxVisible), ); const endIndex = Math.min(startIndex + maxVisible, this.filteredItems.length); for (let i = startIndex; i < endIndex; i++) { const item = this.filteredItems[i]; if (!item) continue; const isSelected = i === this.selectedIndex; const prefix = isSelected ? this.theme.fg("accent", "→ ") : " "; this.listContainer.addChild(new Text(`${prefix}${this.renderLine(item, isSelected)}`, 0, 0)); } if (startIndex > 0 || endIndex < this.filteredItems.length) { const scrollInfo = this.theme.fg( "dim", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`, ); this.listContainer.addChild(new Text(scrollInfo, 0, 0)); } } handleInput(keyData: string): void { const kb = getKeybindings(); if (kb.matches(keyData, "tui.select.up")) { if (this.filteredItems.length === 0) return; this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; this.updateList(); return; } if (kb.matches(keyData, "tui.select.down")) { if (this.filteredItems.length === 0) return; this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; this.updateList(); return; } if (kb.matches(keyData, "tui.select.confirm")) { const selected = this.filteredItems[this.selectedIndex]; if (selected) this.onSelectCallback(selected); return; } if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); return; } this.searchInput.handleInput(keyData); this.applyFilter(this.searchInput.getValue()); } override invalidate(): void { super.invalidate(); this.updateHeader(); this.updateHints(); this.updateList(); } } async function pickModel( ctx: ExtensionCommandContext, models: ModelEntry[], ): Promise { return ctx.ui.custom((tui: TUI, theme, _kb, done) => { const selector = new SearchableSelector({ theme, items: models, headerLabel: "Models", emptyLabel: "No matching models", filterKey: (m) => `${m.provider} ${m.model}`, renderLine: (m, isSelected) => { const color = isSelected ? "accent" : "text"; const dim = isSelected ? "accent" : "muted"; return `${theme.fg(color, m.model)} ${theme.fg(dim, `(${m.provider})`)}`; }, onSelect: (m) => done(m), onCancel: () => done(null), }); return { get focused() { return true; }, set focused(value: boolean) { selector.focused = value; }, render(width: number) { return selector.render(width); }, invalidate() { selector.invalidate(); }, handleInput(data: string) { selector.handleInput(data); }, }; }); } async function pickLevel( ctx: ExtensionCommandContext, levels: string[], ): Promise { return ctx.ui.custom((tui: TUI, theme, _kb, done) => { const selector = new SearchableSelector({ theme, items: levels, headerLabel: "Thinking level", emptyLabel: "No matching levels", filterKey: (l) => l, renderLine: (l, isSelected) => theme.fg(isSelected ? "accent" : "text", l), onSelect: (l) => done(l), onCancel: () => done(null), }); return { get focused() { return true; }, set focused(value: boolean) { selector.focused = value; }, render(width: number) { return selector.render(width); }, invalidate() { selector.invalidate(); }, handleInput(data: string) { selector.handleInput(data); }, }; }); } export default function (pi: ExtensionAPI) { pi.registerCommand("variants", { description: "Switch model + thinking level (searchable, two-step)", async handler(_args: string, ctx: ExtensionCommandContext) { const models = getEnabledModels(ctx); if (models.length === 0) { ctx.ui.notify("No enabled models found", "error"); return; } const chosenModel = await pickModel(ctx, models); if (!chosenModel) return; const model = ctx.modelRegistry.find(chosenModel.provider, chosenModel.model); if (!model) { ctx.ui.notify(`Model ${chosenModel.provider}/${chosenModel.model} not found`, "error"); return; } const levels = getSupportedLevels(model); let chosenLevel: string; if (levels.length <= 1) { // У модели нет выбора уровня (или только "default") — не дёргаем UI зря chosenLevel = levels[0] ?? "default"; } else { const picked = await pickLevel(ctx, levels); if (picked === null) return; chosenLevel = picked; } await pi.setModel(model); pi.setThinkingLevel(displayToPi(chosenLevel) as any); ctx.ui.notify(`✓ ${chosenModel.model} → ${chosenLevel}`, "info"); }, }); }