@xynogen/pix-core 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +11 -17
  2. package/skills/ask-user/SKILL.md +0 -48
  3. package/src/commands/agent-sop/agent-sop.ts +0 -58
  4. package/src/commands/clear/clear.ts +0 -32
  5. package/src/commands/diff/diff.ts +0 -32
  6. package/src/commands/models/models.test.ts +0 -95
  7. package/src/commands/models/models.ts +0 -367
  8. package/src/commands/models/patch-builtin.test.ts +0 -66
  9. package/src/commands/models/patch-builtin.ts +0 -120
  10. package/src/commands/tools.test.ts +0 -15
  11. package/src/commands/update/update.test.ts +0 -112
  12. package/src/commands/update/update.ts +0 -271
  13. package/src/index.ts +0 -45
  14. package/src/lib/data.ts +0 -33
  15. package/src/nudge/capability.test.ts +0 -258
  16. package/src/nudge/capability.ts +0 -189
  17. package/src/nudge/index.ts +0 -17
  18. package/src/nudge/tools.test.ts +0 -157
  19. package/src/nudge/tools.ts +0 -212
  20. package/src/tool/ask/ask.test.ts +0 -243
  21. package/src/tool/ask/components.ts +0 -55
  22. package/src/tool/ask/helpers.ts +0 -77
  23. package/src/tool/ask/index.ts +0 -130
  24. package/src/tool/ask/questionnaire.ts +0 -693
  25. package/src/tool/ask/rpc.ts +0 -84
  26. package/src/tool/ask/schema.ts +0 -69
  27. package/src/tool/ask/single-select-layout.test.ts +0 -124
  28. package/src/tool/ask/single-select-layout.ts +0 -237
  29. package/src/tool/ask/types.ts +0 -17
  30. package/src/tool/todo/todo.test.ts +0 -646
  31. package/src/tool/todo/todo.ts +0 -218
  32. package/src/tool/toolbox/toolbox.test.ts +0 -314
  33. package/src/tool/toolbox/toolbox.ts +0 -570
  34. package/src/ui/diagnostics.ts +0 -145
  35. package/src/ui/footer.ts +0 -512
  36. package/src/ui/welcome.test.ts +0 -124
  37. package/src/ui/welcome.ts +0 -369
package/package.json CHANGED
@@ -1,26 +1,15 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.2.3",
4
- "description": "Pi extension — core UI/UX bundle (welcome banner, footer, model picker, self-update)",
3
+ "version": "0.3.0",
4
+ "description": "Pi extension bundle installs all core pix-* packages",
5
5
  "type": "module",
6
- "main": "src/index.ts",
7
6
  "scripts": {
8
7
  "test": "bun test"
9
8
  },
10
9
  "files": [
11
- "src",
12
- "skills",
13
10
  "README.md",
14
11
  "LICENSE"
15
12
  ],
16
- "pi": {
17
- "extensions": [
18
- "src/index.ts"
19
- ],
20
- "skills": [
21
- "./skills"
22
- ]
23
- },
24
13
  "keywords": [
25
14
  "pi",
26
15
  "pi-package",
@@ -42,12 +31,17 @@
42
31
  "access": "public"
43
32
  },
44
33
  "dependencies": {
45
- "@xynogen/pix-data": "*",
34
+ "@xynogen/pix-welcome": "*",
35
+ "@xynogen/pix-footer": "*",
36
+ "@xynogen/pix-diagnostics": "*",
37
+ "@xynogen/pix-prompts": "*",
46
38
  "@xynogen/pix-skills": "*",
47
- "typebox": "^1.1.38"
39
+ "@xynogen/pix-models": "*",
40
+ "@xynogen/pix-update": "*",
41
+ "@xynogen/pix-commands": "*",
42
+ "@xynogen/pix-nudge": "*"
48
43
  },
49
44
  "peerDependencies": {
50
- "@earendil-works/pi-coding-agent": "*",
51
- "@earendil-works/pi-tui": "*"
45
+ "@earendil-works/pi-coding-agent": "*"
52
46
  }
53
47
  }
@@ -1,48 +0,0 @@
1
- ---
2
- name: ask-user
3
- description: "MUST use before high-stakes/irreversible decisions or when requirements are ambiguous. Gather context, present 2-5 options via ask_user, get explicit choice, then proceed."
4
- metadata:
5
- short-description: Decision gate for ambiguity and high-stakes choices
6
- ---
7
-
8
- # ask_user decision gate
9
-
10
- Decision control, not chit-chat.
11
-
12
- ## Gate (call ask_user before proceeding if ANY true)
13
- - changes architecture/schema/API/deploy/security
14
- - costly to undo (big refactor, migration, destructive edit, prod behavior)
15
- - requirements unclear/conflicting/missing
16
- - multiple valid options, trade-off is preference-dependent
17
- - about to assume something that changes implementation
18
-
19
- Skip only if user already gave an explicit decision for THIS exact trade-off.
20
-
21
- ## Handshake
22
- 1. classify step: high_stakes | ambiguous | both | clear. clear → no gate.
23
- 2. gather evidence first (read/bash/web/ref). don't ask blind.
24
- 3. synthesize: 3-7 bullets — state, constraints, trade-offs, recommendation.
25
- 4. ask ONE focused question: `question`, `context`(summary), `options`(2-5),
26
- `allowMultiple:false` unless truly independent, `allowFreeform:true`.
27
- `inline` displayMode when the preceding summary must stay visible.
28
- 5. commit: restate decision, say next step, proceed.
29
- 6. re-ask only on materially new ambiguity. no confirm loops.
30
-
31
- ## Budget (anti-overasking)
32
- - max 1 call per boundary normally; max 2 if first is unclear/cancelled.
33
- - never re-ask same trade-off without new evidence.
34
- - attempt 2 = narrower question: [Proceed w/ recommended] [Choose other (freeform)] [Stop].
35
- - after attempt 2: high_stakes/both → STOP, mark blocked.
36
- ambiguous-only + user says "your call" → take most reversible default, state assumptions.
37
-
38
- ## Quality
39
- - question: concrete decision boundary, one decision only.
40
- - options: short, outcome-oriented, explicit trade-offs; add description when non-obvious.
41
-
42
- ## Anti-patterns
43
- asking without context · trivial formatting choices · forcing options when freeform fits ·
44
- repeat questions w/o new info · proceeding high-stakes after unclear/cancelled answer.
45
-
46
- ## On cancel / unclear
47
- Pause, explain what's blocked. At most one narrower follow-up.
48
- Then: high-stakes → stay blocked until explicit decision; ambiguity-only → proceed only if user delegated.
@@ -1,58 +0,0 @@
1
- /**
2
- * agent-sop — inject AGENT.md (from pix-skills) into system prompt
3
- *
4
- * Reads AGENT.md from the @xynogen/pix-skills package and appends it to the
5
- * system prompt on every agent start via `before_agent_start`.
6
- *
7
- * This is the "register skill" mechanism for the agent operating spec — no
8
- * static SKILL.md file needed. The content becomes part of the model's
9
- * standing instructions.
10
- */
11
-
12
- import { existsSync, readFileSync } from "node:fs";
13
- import { createRequire } from "node:module";
14
- import { resolve } from "node:path";
15
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
-
17
- /** Resolve the absolute path to AGENT.md inside @xynogen/pix-skills. */
18
- function resolveAgentMdPath(): string | null {
19
- try {
20
- const require = createRequire(import.meta.url);
21
- const pkgJson = require.resolve("@xynogen/pix-skills/package.json");
22
- return resolve(pkgJson, "..", "AGENT.md");
23
- } catch {
24
- return null;
25
- }
26
- }
27
-
28
- /** Read and return AGENT.md content, or null if unavailable. */
29
- function loadAgentMd(): string | null {
30
- const p = resolveAgentMdPath();
31
- if (!p || !existsSync(p)) return null;
32
- try {
33
- return readFileSync(p, "utf-8");
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
- export default function registerAgentSop(pi: ExtensionAPI): void {
40
- // Load once at startup (content is static per session).
41
- const agentMdContent = loadAgentMd();
42
-
43
- if (!agentMdContent) {
44
- // Silent skip — pix-skills might not be installed.
45
- return;
46
- }
47
-
48
- pi.on("before_agent_start", async (event) => {
49
- // Skip if already injected (idempotent check via a simple marker).
50
- if (event.systemPrompt.includes("pix-agent-sop")) {
51
- return;
52
- }
53
-
54
- return {
55
- systemPrompt: `${event.systemPrompt}\n\n<pix-agent-sop>\n${agentMdContent}\n</pix-agent-sop>`,
56
- };
57
- });
58
- }
@@ -1,32 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- } from "@earendil-works/pi-coding-agent";
5
-
6
- async function clearCache(pi: ExtensionAPI, ctx: ExtensionCommandContext) {
7
- ctx.ui.notify("Clearing ~/.cache/pi", "info");
8
- const result = await pi.exec("/bin/sh", ["-lc", 'rm -rf "$HOME/.cache/pi"'], {
9
- timeout: 10_000,
10
- });
11
- const output = [result.stdout, result.stderr]
12
- .filter(Boolean)
13
- .join("\n")
14
- .trim();
15
- if ((result.code ?? 0) !== 0) {
16
- ctx.ui.notify(`Cache clear failed. ${output || "No output."}`, "error");
17
- return;
18
- }
19
- ctx.ui.notify(
20
- "~/.cache/pi cleared. Run /reload to apply changes.",
21
- "warning",
22
- );
23
- }
24
-
25
- export default function (pi: ExtensionAPI) {
26
- pi.registerCommand("clear", {
27
- description: "Remove ~/.cache/pi and reload",
28
- handler: async (_args, ctx) => {
29
- await clearCache(pi, ctx);
30
- },
31
- });
32
- }
@@ -1,32 +0,0 @@
1
- /**
2
- * /diff — explain unstaged git diff with per-file +/- counts.
3
- *
4
- * The agent runs `git status` + `git diff`, then replies with:
5
- * 1. 1–2 sentence explanation of what changed
6
- * 2. Per-file +/- line counts
7
- * 3. Total +/- line count
8
- */
9
-
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
-
12
- const DIFF_PROMPT = `Run git status and inspect the unstaged git diff, then respond with only:
13
-
14
- 1. A short 1-2 sentence explanation of what changed and why it matters.
15
- 2. A list of changed unstaged files with their +/- line counts.
16
- 3. A total +/- line count at the bottom.
17
-
18
- Keep it concise. Use git commands to calculate the line counts. Base the summary on the actual diff, not only filenames. Do not include staged changes unless they also have unstaged modifications.`;
19
-
20
- export default function (pi: ExtensionAPI) {
21
- pi.registerCommand("diff", {
22
- description: "Explain unstaged git diff with per-file +/- counts",
23
- handler: async (_args, ctx) => {
24
- if (!ctx.isIdle()) {
25
- pi.sendUserMessage(DIFF_PROMPT, { deliverAs: "followUp" });
26
- ctx.ui.notify("Queued /diff after the current turn finishes.", "info");
27
- return;
28
- }
29
- pi.sendUserMessage(DIFF_PROMPT);
30
- },
31
- });
32
- }
@@ -1,95 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { benchStars, fmtCost, fmtCtx, sortModels } from "./models.ts";
3
-
4
- describe("fmtCtx", () => {
5
- it("formats 0 as 0", () => expect(fmtCtx(0)).toBe("0"));
6
- it("formats small numbers as-is", () => expect(fmtCtx(512)).toBe("512"));
7
- it("formats thousands as Nk", () => {
8
- expect(fmtCtx(128_000)).toBe("128k");
9
- expect(fmtCtx(8_192)).toBe("8k");
10
- });
11
- it("formats millions as NM", () => {
12
- expect(fmtCtx(1_000_000)).toBe("1M");
13
- expect(fmtCtx(2_000_000)).toBe("2M");
14
- expect(fmtCtx(1_500_000)).toBe("1.5M");
15
- });
16
- });
17
-
18
- describe("fmtCost", () => {
19
- it("returns — for undefined entry", () =>
20
- expect(fmtCost(undefined)).toBe("—"));
21
- it("returns — when no cost field", () => expect(fmtCost({})).toBe("—"));
22
- it("returns free when both 0", () => {
23
- expect(fmtCost({ cost: { input: 0, output: 0 } })).toBe("free");
24
- });
25
- it("formats input/output costs", () => {
26
- expect(fmtCost({ cost: { input: 3, output: 15 } })).toBe("3.00/15.00");
27
- });
28
- it("handles missing input/output as 0", () => {
29
- expect(fmtCost({ cost: {} })).toBe("free");
30
- });
31
- });
32
-
33
- describe("benchStars", () => {
34
- it("gives 5 stars for score >= 90", () => {
35
- expect(benchStars(95).filled).toBe(5);
36
- expect(benchStars(90).filled).toBe(5);
37
- });
38
- it("gives 4 stars for 80-89", () => {
39
- expect(benchStars(85).filled).toBe(4);
40
- expect(benchStars(80).filled).toBe(4);
41
- });
42
- it("gives 3 stars for 70-79", () => {
43
- expect(benchStars(75).filled).toBe(3);
44
- });
45
- it("gives 2 stars for 50-69", () => {
46
- expect(benchStars(60).filled).toBe(2);
47
- expect(benchStars(50).filled).toBe(2);
48
- });
49
- it("gives 1 star for score < 50", () => {
50
- expect(benchStars(30).filled).toBe(1);
51
- expect(benchStars(0).filled).toBe(1);
52
- });
53
- it("gives 1 star for null/undefined", () => {
54
- expect(benchStars(null).filled).toBe(1);
55
- expect(benchStars(undefined).filled).toBe(1);
56
- });
57
- it("filled + empty always = 5", () => {
58
- for (const s of [0, 50, 70, 80, 90, 100]) {
59
- const { filled, empty } = benchStars(s);
60
- expect(filled + empty).toBe(5);
61
- }
62
- });
63
- });
64
-
65
- describe("sortModels", () => {
66
- const models = [
67
- { provider: "a", id: "m1", name: "Zebra", score: 80 },
68
- { provider: "a", id: "m2", name: "Alpha", score: 95 },
69
- { provider: "a", id: "m3", name: "Middle", score: null },
70
- { provider: "a", id: "m4", name: "Beta", score: 80 },
71
- ];
72
-
73
- it("sorts by score descending", () => {
74
- const sorted = sortModels(models);
75
- expect(sorted[0].name).toBe("Alpha"); // score 95
76
- });
77
-
78
- it("breaks score ties alphabetically by name", () => {
79
- const sorted = sortModels(models);
80
- const tiedIdx = sorted.findIndex((m) => m.name === "Beta");
81
- const zebraIdx = sorted.findIndex((m) => m.name === "Zebra");
82
- expect(tiedIdx).toBeLessThan(zebraIdx); // Beta before Zebra, both score 80
83
- });
84
-
85
- it("puts null score models last", () => {
86
- const sorted = sortModels(models);
87
- expect(sorted[sorted.length - 1].name).toBe("Middle");
88
- });
89
-
90
- it("does not mutate the original array", () => {
91
- const original = [...models];
92
- sortModels(models);
93
- expect(models).toEqual(original);
94
- });
95
- });
@@ -1,367 +0,0 @@
1
- /**
2
- * models.ts — enhanced /models command with benchlm rank + score
3
- *
4
- * Replaces (or supplements) the built-in /model selector by registering
5
- * /models (plural). Each row shows:
6
- * <name> <provider> · <ctx> · <cost> · 🏅 #rank score
7
- *
8
- * Sorted by benchlm rank when available (best first), then alphabetical.
9
- */
10
-
11
- import type {
12
- ExtensionAPI,
13
- ExtensionContext,
14
- } from "@earendil-works/pi-coding-agent";
15
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
16
- import {
17
- Container,
18
- fuzzyFilter,
19
- Input,
20
- matchesKey,
21
- type SelectItem,
22
- SelectList,
23
- Text,
24
- visibleWidth,
25
- } from "@earendil-works/pi-tui";
26
- import { lookupBenchmark, lookupModelsDev } from "../../lib/data";
27
- import { patchOutBuiltinModelCommand } from "./patch-builtin";
28
-
29
- // ─── Pure logic (exported for tests) ─────────────────────────────────────────
30
-
31
- export function fmtCtx(n: number): string {
32
- if (!n || n < 1_000) return `${n}`;
33
- if (n >= 1_000_000) {
34
- const m = n / 1_000_000;
35
- return `${Number.isInteger(m) ? m.toFixed(0) : m.toFixed(1).replace(/\.0$/, "")}M`;
36
- }
37
- return `${Math.round(n / 1_000)}k`;
38
- }
39
-
40
- export function fmtCost(
41
- entry: { cost?: { input?: number; output?: number } } | undefined,
42
- ): string {
43
- if (!entry?.cost) return "\u2014";
44
- const i = entry.cost.input ?? 0;
45
- const o = entry.cost.output ?? 0;
46
- if (i === 0 && o === 0) return "free";
47
- return `${i.toFixed(2)}/${o.toFixed(2)}`;
48
- }
49
-
50
- export function benchStars(score: number | null | undefined): {
51
- filled: number;
52
- empty: number;
53
- } {
54
- const total = 5;
55
- let filled = 1;
56
- if (typeof score === "number") {
57
- if (score >= 90) filled = 5;
58
- else if (score >= 80) filled = 4;
59
- else if (score >= 70) filled = 3;
60
- else if (score >= 50) filled = 2;
61
- }
62
- return { filled, empty: total - filled };
63
- }
64
-
65
- export type SortableModel = {
66
- provider: string;
67
- id: string;
68
- name?: string;
69
- score?: number | null;
70
- };
71
-
72
- export function sortModels<T extends SortableModel>(models: T[]): T[] {
73
- return [...models].sort((a, b) => {
74
- const sa = a.score ?? -1;
75
- const sb = b.score ?? -1;
76
- if (sa !== sb) return sb - sa;
77
- return (a.name ?? a.id).localeCompare(b.name ?? b.id);
78
- });
79
- }
80
-
81
- export async function showEnhancedPicker(
82
- pi: ExtensionAPI,
83
- ctx: ExtensionContext,
84
- ): Promise<void> {
85
- // Mirror the built-in /model selector, which calls refresh() then awaits
86
- // getAvailable() (see model-selector.js). Without refresh(), this extension
87
- // reads whatever `this.models` was last loaded into — which, depending on
88
- // extension load order vs oauth/auth resolution, can omit oauth providers
89
- // whose models were registered as built-ins but resolved after the last
90
- // load (notably `openai-codex`). 9router survives because it's registered
91
- // as a custom provider with an env-key apiKey. refresh() rebuilds the model
92
- // list (resetOAuthProviders → loadModels → re-apply registered providers)
93
- // so oauth-backed codex models reappear, exactly as the built-in does.
94
- //
95
- // The public ExtensionContext type narrows modelRegistry to a sync
96
- // getAvailable() only; at runtime ctx.modelRegistry is the full
97
- // ModelRegistry instance (verified in runner.js) with refresh() and an
98
- // async-capable getAvailable(). Reach through the narrowed type.
99
- type AvailableModels = ReturnType<typeof ctx.modelRegistry.getAvailable>;
100
- const registry = ctx.modelRegistry as unknown as {
101
- refresh?: () => void;
102
- getAvailable(): AvailableModels | Promise<AvailableModels>;
103
- };
104
- registry.refresh?.();
105
- const available = await registry.getAvailable();
106
- if (available.length === 0) {
107
- ctx.ui.notify("No models with configured auth.", "warning");
108
- return;
109
- }
110
-
111
- const current = ctx.model;
112
-
113
- // Build items with benchmark data
114
- type Row = {
115
- m: (typeof available)[number];
116
- dev: ReturnType<typeof lookupModelsDev>;
117
- bench: ReturnType<typeof lookupBenchmark>;
118
- };
119
- const rows: Row[] = available.map((m) => ({
120
- m,
121
- dev: lookupModelsDev(m.provider, m.id),
122
- bench: lookupBenchmark(m.name ?? m.id),
123
- }));
124
-
125
- // Sort: by score desc (highest first), unscored last alphabetical
126
- rows.sort((a, b) => {
127
- const sa = a.bench?.overallScore ?? -1;
128
- const sb = b.bench?.overallScore ?? -1;
129
- if (sa !== sb) return sb - sa;
130
- return (a.m.name ?? a.m.id).localeCompare(b.m.name ?? b.m.id);
131
- });
132
-
133
- // Show all models (no deduplication)
134
- const dedupedRows = rows;
135
-
136
- // items built inside the custom() factory so we have theme access for colors
137
-
138
- const result = await ctx.ui.custom<string | null>(
139
- (_tui, theme, _kb, done) => {
140
- const container = new Container();
141
- const accent = "accent";
142
-
143
- // Find max rank width across all benchmarked rows for # padding
144
- const maxRankWidth = Math.max(
145
- ...dedupedRows.map((r) => (r.bench ? String(r.bench.rank).length : 0)),
146
- 1,
147
- );
148
-
149
- // Mute low-info parts (separators, padding, #, ☆) so the actual values pop.
150
- const mute = (s: string) => theme.fg("muted", s);
151
- const sep = mute(" · ");
152
-
153
- // Track rank per item value so fuzzy results can prioritize ranked models.
154
- const rankByValue = new Map<string, number>();
155
- for (const { m, bench } of dedupedRows) {
156
- if (bench) rankByValue.set(`${m.provider}/${m.id}`, bench.rank);
157
- }
158
-
159
- const items: SelectItem[] = dedupedRows.map(({ m, dev, bench }) => {
160
- const isCurrent =
161
- current && m.provider === current.provider && m.id === current.id;
162
-
163
- // Label: marker + muted '#' + bright rank + accent-colored model name
164
- const marker = isCurrent ? theme.fg(accent, "▶") : " ";
165
- let rankPrefix: string;
166
- if (bench) {
167
- const rankStr = String(bench.rank).padEnd(maxRankWidth);
168
- rankPrefix = mute("#") + theme.fg("warning", rankStr);
169
- } else {
170
- rankPrefix = " ".repeat(maxRankWidth + 1);
171
- }
172
- // Display model id only; m.provider is routing provider, not part of id.
173
- const idColored = theme.fg(accent, m.id);
174
- const label = `${marker} ${rankPrefix} ${idColored}`;
175
-
176
- // Description: ctx · cost · score stars
177
- // Colors: ctx muted · cost success (free muted) · score+stars warning
178
- const ctxRaw = fmtCtx(dev?.limit?.context ?? 0);
179
- const ctxStr = mute(ctxRaw.padStart(4));
180
- const rawCost = fmtCost(dev);
181
- let costSeg: string;
182
- if (rawCost === "—") {
183
- costSeg = theme.fg("dim", "—".padEnd(10));
184
- } else if (rawCost === "free") {
185
- costSeg = mute("free".padEnd(10));
186
- } else {
187
- costSeg = theme.fg("success", rawCost.padEnd(10));
188
- }
189
- let benchSeg = "";
190
- if (bench) {
191
- const score = bench.overallScore ?? "?";
192
- const s = bench.overallScore;
193
- let filled = 1;
194
- if (typeof s === "number") {
195
- if (s >= 90) filled = 5;
196
- else if (s >= 80) filled = 4;
197
- else if (s >= 70) filled = 3;
198
- else if (s >= 50) filled = 2;
199
- }
200
- const starBar =
201
- theme.fg("warning", "★".repeat(filled)) +
202
- mute("☆".repeat(5 - filled));
203
- benchSeg = `⚡${theme.fg("warning", String(score))} ${starBar}`;
204
- }
205
- const desc = [ctxStr, costSeg, benchSeg].filter(Boolean).join(sep);
206
-
207
- return {
208
- value: `${m.provider}/${m.id}`,
209
- label,
210
- description: desc,
211
- };
212
- });
213
-
214
- const currentIdx = current
215
- ? items.findIndex(
216
- (it) => it.value === `${current.provider}/${current.id}`,
217
- )
218
- : 0;
219
-
220
- container.addChild(new DynamicBorder((s) => theme.fg(accent, s)));
221
- container.addChild(
222
- new Text(theme.fg(accent, theme.bold("󰚩 Select model"))),
223
- );
224
- container.addChild(
225
- new Text(
226
- theme.fg(
227
- "dim",
228
- "context & pricing from models.dev · ranks from benchlm.ai",
229
- ),
230
- ),
231
- );
232
-
233
- // Widest label (visible width, ANSI-stripped) so the model name
234
- // column never truncates to "…". Add gap headroom.
235
- const widestLabel = items.reduce(
236
- (w, it) => Math.max(w, visibleWidth(it.label)),
237
- 0,
238
- );
239
-
240
- const search = new Input();
241
- const list = new SelectList(
242
- items,
243
- Math.min(items.length, 14),
244
- {
245
- selectedPrefix: (t) => theme.fg(accent, t),
246
- selectedText: (t) => theme.fg(accent, t),
247
- description: (t) => t, // raw — per-segment colors set in items.map
248
- scrollInfo: (t) => theme.fg("dim", t),
249
- noMatch: (t) => theme.fg("warning", t),
250
- },
251
- {
252
- minPrimaryColumnWidth: widestLabel + 2,
253
- maxPrimaryColumnWidth: widestLabel + 2,
254
- },
255
- );
256
- if (currentIdx >= 0) list.setSelectedIndex(currentIdx);
257
-
258
- list.onSelect = (item) => done(item.value);
259
- list.onCancel = () => done(null);
260
- search.onEscape = () => done(null);
261
-
262
- const applyFuzzy = (query: string) => {
263
- const internal = list as unknown as {
264
- items: SelectItem[];
265
- filteredItems: SelectItem[];
266
- selectedIndex: number;
267
- invalidate(): void;
268
- };
269
- const q = query.trim();
270
- let next: SelectItem[];
271
- if (q.length === 0) {
272
- next = internal.items;
273
- } else if (/^\d+$/.test(q)) {
274
- // Pure number → match by benchlm rank, not name.
275
- const wanted = Number(q);
276
- next = internal.items.filter(
277
- (it) => rankByValue.get(it.value) === wanted,
278
- );
279
- } else {
280
- next = fuzzyFilter(
281
- internal.items,
282
- q,
283
- (it) => `${it.label} ${it.description ?? ""}`,
284
- );
285
- // Stable sort: ranked models (by rank asc) before unranked.
286
- next = next
287
- .map((it, i) => ({ it, i }))
288
- .sort((a, b) => {
289
- const ra = rankByValue.get(a.it.value) ?? Infinity;
290
- const rb = rankByValue.get(b.it.value) ?? Infinity;
291
- if (ra !== rb) return ra - rb;
292
- return a.i - b.i;
293
- })
294
- .map(({ it }) => it);
295
- }
296
- internal.filteredItems = next;
297
- internal.selectedIndex = 0;
298
- internal.invalidate();
299
- };
300
-
301
- container.addChild(new Text(theme.fg("muted", "Search:")));
302
- container.addChild(search);
303
- container.addChild(list);
304
- container.addChild(
305
- new Text(
306
- theme.fg(
307
- "dim",
308
- "fuzzy search · ↑↓ navigate · enter select · esc cancel",
309
- ),
310
- ),
311
- );
312
- container.addChild(new DynamicBorder((s) => theme.fg(accent, s)));
313
-
314
- return {
315
- render(w: number) {
316
- return container.render(w);
317
- },
318
- invalidate() {
319
- container.invalidate();
320
- },
321
- handleInput(data: string) {
322
- // Detect keys via pi-tui's own parser — the same recognition
323
- // SelectList uses. Arrows arrive as named keys ("up"/"down"),
324
- // not raw escape sequences, so string-equality checks fail.
325
- const isNav = matchesKey(data, "up") || matchesKey(data, "down");
326
- if (isNav || matchesKey(data, "enter")) {
327
- list.handleInput?.(data);
328
- } else if (matchesKey(data, "escape")) {
329
- done(null);
330
- } else {
331
- search.handleInput?.(data);
332
- applyFuzzy(search.getValue?.() ?? "");
333
- }
334
- container.invalidate();
335
- },
336
- };
337
- },
338
- );
339
-
340
- if (!result) return;
341
-
342
- // Apply selection
343
- const [provider, ...rest] = result.split("/");
344
- const id = rest.join("/");
345
- const picked = available.find((m) => m.provider === provider && m.id === id);
346
- if (!picked) {
347
- ctx.ui.notify(`Model not found: ${result}`, "error");
348
- return;
349
- }
350
- const ok = await pi.setModel(picked);
351
- if (ok) ctx.ui.notify(`Switched to ${picked.name ?? picked.id}`, "info");
352
- else ctx.ui.notify(`Failed to switch to ${picked.id}`, "error");
353
- }
354
-
355
- export default function modelPickerExtension(pi: ExtensionAPI) {
356
- // Remove Pi's built-in /model so only the enhanced /models picker remains.
357
- // Self-healing: re-applies on every load, so a Pi upgrade can't restore it.
358
- patchOutBuiltinModelCommand();
359
-
360
- const handler = async (_args: unknown, ctx: ExtensionContext) => {
361
- await showEnhancedPicker(pi, ctx);
362
- };
363
- pi.registerCommand("models", {
364
- description: "Enhanced model picker — shows benchlm rank + score",
365
- handler,
366
- });
367
- }