From fa24b1f37c27316fc731c9e8436fcf22e4a3cd2a Mon Sep 17 00:00:00 2001 From: sakuradairong Date: Tue, 23 Jun 2026 17:16:29 +0800 Subject: [PATCH] feat: add pi model selector extension --- .gitignore | 4 + LICENSE | 21 ++ README.md | 131 ++++++++++++ package.json | 24 +++ src/index.ts | 571 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 751 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9451024 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..47744a2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sakuradairong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ff1665 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# pi-model-selector + +Interactive model selector for [pi](https://pi.dev/) with a provider-focused terminal UI. + +`pi-model-selector` replaces noisy full-provider model lists with a compact selector that only shows models available in your current pi environment. It is useful when you have many built-in providers installed but only want to switch among models with configured auth/API keys/OAuth credentials. + +## Features + +- Provider-focused navigation with `Tab` / `Shift+Tab` +- Model navigation with `↑` / `↓` +- Confirm selection with `Enter` +- Cancel with `Esc` +- Shows only available models from `ctx.modelRegistry.getAvailable()` +- Displays provider count, model range, selected index, model price, and context window +- Highlights the currently selected row +- Marks current active model with `*` +- Marks reasoning-capable models with `R` +- Avoids overriding pi's built-in `/model` command + +## UI preview + +```text ++--------------------------------------------------------------+ +|Model Selector 3 providers| +| < Anthropic 2/3 OpenAI Google > | +|Models 1-10/18 selected 3/18| ++--------------------------------------------------------------+ +| MODEL PRICE / CONTEXT | +|> R gpt-5.2-codex $1.25/$10 · 400K | +| * R gpt-5.2 $1.25/$10 · 400K | +| gpt-4.1-mini $0.4/$1.6 · 1M | ++--------------------------------------------------------------+ +| Tab/Shift+Tab provider • Up/Down navigate • Enter select| ++--------------------------------------------------------------+ +``` + +## Install + +### Install as a pi package from GitHub + +```bash +pi install git:github.com/sakuradairong/pi-model-selector +``` + +Then restart pi, or run: + +```text +/reload +``` + +### Try without installing + +```bash +pi -e git:github.com/sakuradairong/pi-model-selector +``` + +### Manual install + +Copy the extension file into your global pi extensions directory: + +```bash +mkdir -p ~/.pi/agent/extensions +curl -fsSL https://raw.githubusercontent.com/sakuradairong/pi-model-selector/main/src/index.ts \ + -o ~/.pi/agent/extensions/pi-model-selector.ts +``` + +Then restart pi or run `/reload`. + +## Usage + +Open the selector with any of these commands: + +```text +/ms +/wow-model +/select-model +/model-selector +``` + +Keyboard controls inside the selector: + +| Key | Action | +| --- | --- | +| `Tab` | Next provider | +| `Shift+Tab` | Previous provider | +| `↑` / `↓` | Move through models | +| `Enter` | Select model | +| `Esc` | Cancel | + +A shortcut is also registered: + +```text +Ctrl+Shift+M +``` + +## Important note about `/model` + +This extension intentionally **does not override** pi's built-in `/model` command. + +Pi treats `/model` as a built-in interactive command, so extension shadowing is unreliable. Use `/ms` or `/wow-model` for this selector. + +## Requirements + +- pi latest version +- Interactive TUI mode +- At least one model with configured auth/API key/OAuth credentials + +The selector only opens in interactive TUI mode. In RPC, JSON, or print mode it will not attempt to render terminal UI. + +## Development + +This package follows pi's package manifest format: + +```json +{ + "keywords": ["pi-package"], + "pi": { + "extensions": ["./src/index.ts"] + } +} +``` + +Local test: + +```bash +pi -e ./src/index.ts +``` + +## License + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..283f584 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "pi-model-selector", + "version": "0.1.0", + "description": "Interactive provider-tabbed model selector extension for pi.", + "type": "module", + "keywords": [ + "pi-package", + "pi", + "pi-extension", + "model-selector", + "tui" + ], + "license": "MIT", + "pi": { + "extensions": [ + "./src/index.ts" + ] + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9648b24 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,571 @@ +/** + * pi-model-selector - Interactive model selection with tabbed provider UI. + * + * Features: + * - Tab bar for switching between providers (Tab / Shift+Tab) + * - Arrow keys to navigate models (↑↓) + * - Enter to confirm selection + * - Escape to cancel + * - Shows cost, context window, and reasoning badges per model + * - Marks unavailable models with LOCK + * - Highlights the currently active model with ● + * + * Usage: + * /model-selector - open the selector + * Ctrl+Shift+M - open the selector + */ + +import type { Api, Model } from "@earendil-works/pi-ai"; +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, + Theme, + ThemeColor, +} from "@earendil-works/pi-coding-agent"; +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; + +interface Provider { + name: string; // internal provider id (e.g., "anthropic") + displayName: string; // human-readable name + models: ModelInfo[]; +} + +interface ModelInfo { + model: Model; + name: string; // display name + cost: string; // e.g., "$3/$3 · 200K ctx" or "free" / "local" + reasoning: boolean; + available: boolean; // has API key configured +} + +export default function (pi: ExtensionAPI) { + const handler = async (_args: string, ctx: ExtensionContext): Promise => { + await openModelSelector(ctx, pi); + }; + + // Register commands. Do NOT register /model: pi treats built-in /model as + // a special interactive command, so extension shadowing is unreliable. + pi.registerCommand("wow-model", { + description: "Open custom interactive model selector", + handler, + }); + pi.registerCommand("ms", { + description: "Open custom interactive model selector", + handler, + }); + pi.registerCommand("select-model", { + description: "Open custom interactive model selector", + handler, + }); + pi.registerCommand("model-selector", { + description: "Open custom interactive model selector with provider tabs", + handler, + }); + + // Register shortcut: Ctrl+Shift+M opens selector. + // Avoid Ctrl+M: many terminals encode it as Enter, which can break prompt submission. + pi.registerShortcut(Key.ctrlShift("m"), { + description: "Open model selector", + handler: async (ctx) => { + await openModelSelector(ctx, pi); + }, + }); + + pi.on("session_start", async (_event, ctx) => { + if (ctx.mode === "tui") { + ctx.ui.setStatus("pi-model-selector", ctx.ui.theme.fg("accent", "ms:/ms")); + } + }); +} + +async function openModelSelector( + ctx: ExtensionContext, + pi: ExtensionAPI, +): Promise { + if (ctx.mode !== "tui") { + if (ctx.hasUI) { + ctx.ui.notify("Model selector UI is only available in interactive TUI mode", "warning"); + } + return; + } + + const providers = buildProviderList(ctx); + if (providers.length === 0) { + ctx.ui.notify("No registered/available models found", "warning"); + return; + } + + const currentModelKey = ctx.model ? modelKey(ctx.model) : undefined; + const state: SelectorState = { + providers, + currentProviderIndex: 0, + modelIndex: 0, + currentModelKey, + // Error to show after a failed selection attempt + lastError: undefined, + }; + + // Ensure modelIndex points to the current model on first render + ensureCurrentModelSelected(state); + + await ctx.ui.custom((tui, theme, _kb, done) => { + const component = new ModelSelectorComponent(state, theme, pi, ctx, done); + return { + render(width: number): string[] { + return component.render(width); + }, + invalidate(): void { + component.invalidate(); + }, + handleInput(data: string): void { + component.handleInput(data); + tui.requestRender(); + }, + }; + }); +} + +// ─── Provider List Builder ──────────────────────────────────────────────────── + +function buildProviderList(ctx: ExtensionContext): Provider[] { + // Show only models with configured auth/OAuth/API key. `getAll()` includes + // every built-in provider, which makes the selector noisy on installs with + // many packaged model definitions. + const availableModels = ctx.modelRegistry.getAvailable(); + + const byProvider = new Map(); + + for (const model of availableModels) { + const key = model.provider; + if (!byProvider.has(key)) { + byProvider.set(key, { + displayName: ctx.modelRegistry.getProviderDisplayName(key), + models: [], + }); + } + + const info: ModelInfo = { + model, + name: model.name ?? model.id, + cost: buildCostLabel(model), + reasoning: model.reasoning ?? false, + available: true, + }; + + byProvider.get(key)!.models.push(info); + } + + // Sort providers alphabetically by display name + const sorted = Array.from(byProvider.entries()).sort(([, aVal], [, bVal]) => + aVal.displayName.localeCompare(bVal.displayName), + ); + + return sorted.map(([name, { displayName, models }]) => ({ + name, + displayName, + // Sort models within each provider alphabetically + models: models.sort((a, b) => a.name.localeCompare(b.name)), + })); +} + +function buildCostLabel(model: Model): string { + const c = model.cost; + if (!c) return "free"; + + const inputCost = c.input > 0 ? `$${c.input}` : "free"; + const outputCost = c.output > 0 ? `$${c.output}` : "free"; + + const ctxWin = model.contextWindow; + const ctxLabel = ctxWin ? formatContextWindow(ctxWin) : ""; + + if (inputCost === "free" && outputCost === "free") { + return ctxLabel || "free"; + } + + if (inputCost === outputCost) { + return ctxLabel ? `$${c.input}/${ctxLabel}` : `$${c.input}`; + } + + return ctxLabel + ? `${inputCost}/${outputCost} · ${ctxLabel}` + : `${inputCost}/${outputCost}`; +} + +function modelKey(model: Model): string { + return `${model.provider}/${model.id}`; +} + +function formatContextWindow(tokens: number): string { + if (tokens >= 1_000_000) { + const m = tokens / 1_000_000; + return `${Number.isInteger(m) ? m : m.toFixed(1)}M ctx`; + } + if (tokens >= 1_000) { + return `${Math.round(tokens / 1_000)}K ctx`; + } + return `${tokens} ctx`; +} + +// ─── Selector State ─────────────────────────────────────────────────────────── + +interface SelectorState { + providers: Provider[]; + currentProviderIndex: number; + modelIndex: number; + currentModelKey: string | undefined; + lastError?: string; +} + +function ensureCurrentModelSelected(state: SelectorState): void { + if (!state.currentModelKey) return; + + for (let providerIndex = 0; providerIndex < state.providers.length; providerIndex++) { + const provider = state.providers[providerIndex]; + const modelIndex = provider.models.findIndex( + (model) => modelKey(model.model) === state.currentModelKey, + ); + if (modelIndex >= 0) { + state.currentProviderIndex = providerIndex; + state.modelIndex = modelIndex; + return; + } + } +} + +// ─── Model Selector Component ──────────────────────────────────────────────── + +class ModelSelectorComponent { + private readonly state: SelectorState; + private readonly theme: Theme; + private readonly pi: ExtensionAPI; + private readonly ctx: ExtensionContext; + private readonly onDone: (value: void) => void; + private cachedLines?: string[]; + private cachedWidth?: number; + + constructor( + state: SelectorState, + theme: Theme, + pi: ExtensionAPI, + ctx: ExtensionContext, + onDone: (value: void) => void, + ) { + this.state = state; + this.theme = theme; + this.pi = pi; + this.ctx = ctx; + this.onDone = onDone; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + handleInput(data: string): void { + const s = this.state; + + // Tab / Shift+Tab: switch provider + if (matchesKey(data, Key.tab)) { + s.currentProviderIndex = (s.currentProviderIndex + 1) % s.providers.length; + s.modelIndex = 0; + s.lastError = undefined; + this.invalidate(); + return; + } + + if (matchesKey(data, Key.shift("tab"))) { + s.currentProviderIndex = + (s.currentProviderIndex - 1 + s.providers.length) % s.providers.length; + s.modelIndex = 0; + s.lastError = undefined; + this.invalidate(); + return; + } + + // Arrow keys: navigate model list + const provider = s.providers[s.currentProviderIndex]; + if (!provider) return; + + if (matchesKey(data, Key.up)) { + s.modelIndex = Math.max(0, s.modelIndex - 1); + s.lastError = undefined; + this.invalidate(); + return; + } + + if (matchesKey(data, Key.down)) { + s.modelIndex = Math.min(provider.models.length - 1, s.modelIndex + 1); + s.lastError = undefined; + this.invalidate(); + return; + } + + // Enter: select model + if (matchesKey(data, Key.enter)) { + const model = provider.models[s.modelIndex]; + if (model) { + void this.selectModel(model); + } + return; + } + + // Escape: cancel + if (matchesKey(data, Key.escape)) { + this.onDone(); + return; + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const lines = this.buildLines(width); + this.cachedLines = lines; + this.cachedWidth = width; + return lines; + } + + private buildLines(width: number): string[] { + const s = this.state; + const safeWidth = Math.max(56, width); + const innerWidth = Math.max(1, safeWidth - 2); + const lines: string[] = []; + const provider = s.providers[s.currentProviderIndex]; + const visibleRange = provider ? this.visibleModelRange(provider.models.length) : { start: 0, end: -1 }; + + lines.push(this.frameTop(safeWidth)); + lines.push( + this.frameLine( + twoColumn( + this.styled("accent", this.bold("Model Selector")), + this.dim(`${s.providers.length} provider${s.providers.length === 1 ? "" : "s"}`), + innerWidth, + ), + innerWidth, + ), + ); + lines.push(this.frameLine(this.buildProviderBar(innerWidth), innerWidth)); + + if (provider) { + const modelRange = provider.models.length > 0 + ? `${visibleRange.start + 1}-${visibleRange.end + 1}/${provider.models.length}` + : "0/0"; + lines.push( + this.frameLine( + twoColumn( + this.dim(`Models ${modelRange}`), + this.dim(`selected ${Math.min(s.modelIndex + 1, provider.models.length)}/${provider.models.length}`), + innerWidth, + ), + innerWidth, + ), + ); + } + + lines.push(this.frameDivider(safeWidth)); + + if (s.lastError) { + lines.push(this.frameLine(this.warning(`LOCK ${s.lastError} — select a different model`), innerWidth)); + lines.push(this.frameDivider(safeWidth)); + } + + lines.push(this.frameLine(this.buildListHeader(innerWidth), innerWidth)); + + if (provider && provider.models.length > 0) { + if (visibleRange.start > 0) { + lines.push(this.frameLine(this.dim(` ... ${visibleRange.start} more above`), innerWidth)); + } + + for (let index = visibleRange.start; index <= visibleRange.end; index++) { + const model = provider.models[index]; + const isActive = modelKey(model.model) === s.currentModelKey; + const isSelected = index === s.modelIndex; + lines.push(this.frameLine(this.buildModelLine(model, isActive, isSelected, innerWidth), innerWidth)); + } + + const below = provider.models.length - visibleRange.end - 1; + if (below > 0) { + lines.push(this.frameLine(this.dim(` ... ${below} more below`), innerWidth)); + } + } else { + lines.push(this.frameLine(this.muted(" (no models)"), innerWidth)); + } + + lines.push(this.frameDivider(safeWidth)); + const helpText = "Tab/Shift+Tab provider • Up/Down navigate • Enter select • Esc cancel"; + lines.push(this.frameLine(centerText(this.dim(helpText), innerWidth), innerWidth)); + lines.push(this.frameBottom(safeWidth)); + + return lines.map((line) => this.theme.bg("customMessageBg", line)); + } + + private buildProviderBar(width: number): string { + const s = this.state; + const total = s.providers.length; + const current = s.providers[s.currentProviderIndex]; + if (!current) return ""; + + const previous = total > 1 + ? s.providers[(s.currentProviderIndex - 1 + total) % total] + : undefined; + const next = total > 1 + ? s.providers[(s.currentProviderIndex + 1) % total] + : undefined; + + const parts: string[] = []; + if (previous && previous.name !== current.name) { + parts.push(this.dim(`< ${truncatePlain(previous.displayName, 18)}`)); + } + + parts.push( + this.theme.inverse( + this.styled( + "accent", + this.bold(` ${s.currentProviderIndex + 1}/${total} ${truncatePlain(current.displayName, 30)} `), + ), + ), + ); + + if (next && next.name !== current.name) { + parts.push(this.dim(`${truncatePlain(next.displayName, 18)} >`)); + } + + return centerText(truncateToWidth(parts.join(" "), width), width); + } + + private buildListHeader(width: number): string { + return twoColumn(this.dim(" MODEL"), this.dim("PRICE / CONTEXT"), width); + } + + private visibleModelRange(total: number): { start: number; end: number } { + const maxVisible = 10; + if (total <= 0) return { start: 0, end: -1 }; + if (total <= maxVisible) return { start: 0, end: total - 1 }; + + const half = Math.floor(maxVisible / 2); + const start = Math.max(0, Math.min(this.state.modelIndex - half, total - maxVisible)); + return { start, end: start + maxVisible - 1 }; + } + + private buildModelLine( + model: ModelInfo, + isActive: boolean, + isSelected: boolean, + width: number, + ): string { + const cursor = isSelected ? ">" : " "; + const activeMark = isActive ? "*" : " "; + const unavailableMark = model.available ? "" : "LOCK "; + const reasoningMark = model.reasoning ? "R " : ""; + + const leftPlain = `${cursor}${activeMark} ${unavailableMark}${reasoningMark}${model.name}`; + const rightPlain = model.cost || ""; + + const rightWidth = Math.min(26, Math.max(16, Math.floor(width * 0.34))); + const leftWidth = Math.max(8, width - rightWidth - 1); + + const leftColor: ThemeColor = isActive ? "success" : isSelected ? "accent" : "text"; + const left = this.styled(leftColor, truncateToWidth(leftPlain, leftWidth)); + const right = this.dim(truncateToWidth(rightPlain, rightWidth)); + const line = `${padVisible(left, leftWidth)} ${right.padStart(Math.max(0, rightWidth))}`; + + return isSelected ? this.theme.bg("selectedBg", padVisible(line, width)) : line; + } + + private frameTop(width: number): string { + return this.borderMuted(`+${"-".repeat(Math.max(0, width - 2))}+`); + } + + private frameDivider(width: number): string { + return this.borderMuted(`+${"-".repeat(Math.max(0, width - 2))}+`); + } + + private frameBottom(width: number): string { + return this.borderMuted(`+${"-".repeat(Math.max(0, width - 2))}+`); + } + + private frameLine(content: string, innerWidth: number): string { + const clipped = truncateToWidth(content, innerWidth); + const padded = padVisible(clipped, innerWidth); + return `${this.borderMuted("|")}${padded}${this.borderMuted("|")}`; + } + + private async selectModel(model: ModelInfo): Promise { + const s = this.state; + + const success = await this.pi.setModel(model.model); + + if (success) { + this.ctx.ui.notify(`Model selected: ${model.model.provider}/${model.model.id}`, "info"); + this.onDone(); + } else { + // No API key or other failure — stay in selector, show error + s.lastError = `No API key for ${model.model.provider}/${model.name}`; + this.ctx.ui.notify(s.lastError, "warning"); + this.invalidate(); + } + } + + // ─── Theme helpers (avoids verbose theme.fg(...) calls) ─────────────────── + + private styled(color: ThemeColor, text: string): string { + return this.theme.fg(color, text); + } + + private bold(text: string): string { + return this.theme.bold(text); + } + + private muted(text: string): string { + return this.theme.fg("muted", text); + } + + private dim(text: string): string { + return this.theme.fg("dim", text); + } + + private warning(text: string): string { + return this.theme.fg("warning", text); + } + + private borderMuted(text: string): string { + return this.theme.fg("borderMuted", text); + } +} + +function hsep(width: number): string { + return "-".repeat(Math.max(0, width)); +} + +function padVisible(text: string, width: number): string { + const remaining = width - visibleWidth(text); + return remaining > 0 ? `${text}${" ".repeat(remaining)}` : text; +} + +function truncatePlain(text: string, width: number): string { + return truncateToWidth(text, width); +} + +function centerText(text: string, width: number): string { + const textWidth = visibleWidth(text); + if (textWidth >= width) return truncateToWidth(text, width); + const left = Math.floor((width - textWidth) / 2); + return `${" ".repeat(left)}${text}`; +} + +function twoColumn(left: string, right: string, width: number): string { + const rightWidth = visibleWidth(right); + const leftWidth = Math.max(0, width - rightWidth - 1); + const clippedLeft = truncateToWidth(left, leftWidth); + const gap = Math.max(1, width - visibleWidth(clippedLeft) - rightWidth); + return `${clippedLeft}${" ".repeat(gap)}${right}`; +}