feat: add pi model selector extension

This commit is contained in:
sakuradairong
2026-06-23 17:16:29 +08:00
commit fa24b1f37c
5 changed files with 751 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log

21
LICENSE Normal file
View File

@@ -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.

131
README.md Normal file
View File

@@ -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

24
package.json Normal file
View File

@@ -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": "*"
}
}

571
src/index.ts Normal file
View File

@@ -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<Api>;
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<void> => {
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<void> {
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<void>((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<string, { displayName: string; models: ModelInfo[] }>();
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<Api>): 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<Api>): 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<void> {
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}`;
}