@faiku/pi-default-model-switcher 0.0.1

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.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # pi-default-model-switcher
2
+
3
+ A [pi](https://github.com/earendil-works/pi) extension that adds commands for changing the **default startup model**, globally or per project, and for switching the model of the running session.
4
+
5
+ ## Commands
6
+
7
+ | Command | Effect |
8
+ |---|---|
9
+ | `/model-session` | Switch the model for this session only. Nothing is written to disk. |
10
+ | `/model-global` | Switch the model and write `defaultProvider` / `defaultModel` / `defaultThinkingLevel` to `<agent-dir>/settings.json` (default `~/.pi/agent/settings.json`). |
11
+ | `/model-project` | Same, but writes to `<cwd>/.pi/settings.json`, so the default applies only in this project directory. |
12
+ | `/model-show` | Print the current session model plus the effective global and project defaults. |
13
+
14
+ All model commands accept an optional argument to skip the pickers:
15
+
16
+ ```
17
+ /model-global anthropic/claude-sonnet-4-5
18
+ /model-project openai/gpt-5:high # ":high" also sets the thinking level
19
+ /model-session opus # bare model id works when unambiguous
20
+ ```
21
+
22
+ ## Behavior
23
+
24
+ - The model picker is a centered overlay with a search box. Typing fuzzy-matches provider, model id, and name (`sonnet`, `openai gpt`, `1m`); `↑↓`/`j`/`k` move, `enter` selects, `esc` cancels, mouse wheel and click work in fullscreen mode.
25
+ - The list is scrollable and clamped: it shows at most 15 rows and never more than the terminal height allows (`min(15, rows - 7)`), with a `(3/87)` position indicator, so navigation cannot push the selection off screen.
26
+ - A trailing `Other…` row appears whenever the search text is not an exact model match, so any `provider/model` in your registry can be entered by hand.
27
+ - Models without extended thinking are stored with `defaultThinkingLevel: "off"`; reasoning models prompt for a level limited to what the model supports.
28
+ - The chosen model is applied to the running session immediately (`pi.setModel` + `pi.setThinkingLevel`), and persisted when the command is `/model-global` or `/model-project`.
29
+ - Settings files are merged, never rewritten: unrelated keys, formatting-free but stable 2-space JSON, and a trailing newline are preserved. Writes go through pi's file mutation queue.
30
+ - `/model-project` refuses to write when the project is not trusted, because pi would ignore `.pi/settings.json`.
31
+ - Project settings override global settings, so a project default wins over your user default in that directory.
32
+ - Non-TUI clients (RPC, print mode) fall back to the plain `ctx.ui.select` dialog.
33
+
34
+ ## Install
35
+
36
+ Personal (all projects):
37
+
38
+ ```bash
39
+ ln -s "$PWD" ~/.pi/agent/extensions/pi-default-model-switcher
40
+ ```
41
+
42
+ Per project, or for one run:
43
+
44
+ ```bash
45
+ pi --extension ./index.ts
46
+ ```
47
+
48
+ ```bash
49
+ # add to <project>/.pi/settings.json
50
+ { "extensions": ["/Users/adam/Documents/Coding/pi-default-model-switcher"] }
51
+ ```
52
+
53
+ Startup defaults are read when a session starts, so run `/reload` or start a new pi session for the persisted default to take effect everywhere; the current session already uses the new model.
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ npm install # dev-only: types for typechecking
59
+ npm test # unit tests for settings, model refs, fuzzy search, layout
60
+ npm run typecheck # tsc --noEmit
61
+ ```
62
+
63
+ Layout: `index.ts` (commands), `lib/settings.ts` (settings paths + merge/write), `lib/models.ts` (model refs, thinking levels), `lib/fuzzy.ts` (search ranking), `lib/picker.ts` (pure item/window math), `lib/model-picker.ts` (overlay component).
64
+ # pi-default-model-switcher
package/index.ts ADDED
@@ -0,0 +1,210 @@
1
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ defaultsFromSettings,
5
+ describeDefaults,
6
+ globalSettingsPath,
7
+ projectSettingsPath,
8
+ readSettings,
9
+ writeSettings,
10
+ } from "./lib/settings.ts";
11
+ import {
12
+ modelRef,
13
+ parseModelRef,
14
+ sortModels,
15
+ supportedThinkingLevels,
16
+ OTHER_OPTION,
17
+ type ModelLike,
18
+ } from "./lib/models.ts";
19
+ import { buildPickerItems } from "./lib/picker.ts";
20
+ import { ModelPickerComponent } from "./lib/model-picker.ts";
21
+
22
+ type Target = "session" | "global" | "project";
23
+
24
+ const TARGET_LABEL: Record<Target, string> = {
25
+ session: "this session (not saved)",
26
+ global: "global settings",
27
+ project: "project settings",
28
+ };
29
+
30
+ export default function defaultModelSwitcher(pi: ExtensionAPI) {
31
+ pi.registerCommand("model-session", {
32
+ description: "Switch model for this session only (not saved to settings)",
33
+ handler: (args, ctx) => runSwitch(pi, "session", args, ctx),
34
+ });
35
+
36
+ pi.registerCommand("model-global", {
37
+ description: "Change the global (agent directory) default startup model",
38
+ handler: (args, ctx) => runSwitch(pi, "global", args, ctx),
39
+ });
40
+
41
+ pi.registerCommand("model-project", {
42
+ description: "Change the default startup model for the current project (.pi/settings.json)",
43
+ handler: (args, ctx) => runSwitch(pi, "project", args, ctx),
44
+ });
45
+
46
+ pi.registerCommand("model-show", {
47
+ description: "Show the current session model plus global and project model defaults",
48
+ handler: async (_args, ctx) => {
49
+ const current = ctx.model as ModelLike | undefined;
50
+ const thinking = ctx.thinkingLevel ?? (await safeThinkingLevel(pi));
51
+ ctx.ui.notify(
52
+ `Session: ${current ? `${modelRef(current)} (${current.name})` : "(none)"} thinking=${thinking ?? "unset"}`,
53
+ "info",
54
+ );
55
+ const global = await readSettings(globalSettingsPath());
56
+ ctx.ui.notify(`Global ${globalSettingsPath()}\n ${describeDefaults(global)}`, "info");
57
+ const projectFile = projectSettingsPath(ctx.cwd);
58
+ const project = await readSettings(projectFile);
59
+ ctx.ui.notify(`Project ${projectFile}\n ${describeDefaults(project)}`, "info");
60
+ const projectOverride = defaultsFromSettings(project);
61
+ if (projectOverride.defaultModel) {
62
+ ctx.ui.notify("Project settings take precedence over global settings in this directory.", "info");
63
+ }
64
+ },
65
+ });
66
+ }
67
+
68
+ async function safeThinkingLevel(pi: ExtensionAPI): Promise<string | undefined> {
69
+ try {
70
+ return pi.getThinkingLevel();
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ async function runSwitch(pi: ExtensionAPI, target: Target, args: string, ctx: ExtensionCommandContext): Promise<void> {
77
+ if (target === "project" && !ctx.isProjectTrusted()) {
78
+ ctx.ui.notify(
79
+ "This project is not trusted, so .pi/settings.json would be ignored. Trust the project first, or use /model-global.",
80
+ "warning",
81
+ );
82
+ return;
83
+ }
84
+ if (!ctx.hasUI) {
85
+ ctx.ui.notify("No interactive UI available. Pass a model reference as an argument, e.g. /model-global anthropic/claude-sonnet-4-5", "warning");
86
+ }
87
+
88
+ const current = ctx.model as ModelLike | undefined;
89
+ const models = sortModels(ctx.modelRegistry.getAvailable() as ModelLike[]);
90
+
91
+ let chosen: ModelLike | undefined;
92
+ let thinkingHint: string | undefined;
93
+
94
+ if (args.trim() !== "") {
95
+ const ref = parseModelRef(args);
96
+ thinkingHint = ref?.thinkingLevel;
97
+ chosen = models.find((m) => modelRef(m) === `${ref?.provider}/${ref?.id}`) ?? (ref ? matchById(models, ref.id, ref.provider) : undefined);
98
+ if (!chosen) {
99
+ ctx.ui.notify(`No configured model matches "${args.trim()}". Use provider/model, e.g. anthropic/claude-sonnet-4-5.`, "error");
100
+ return;
101
+ }
102
+ } else {
103
+ if (!ctx.hasUI) return;
104
+ if (models.length === 0) {
105
+ ctx.ui.notify("No models with configured credentials are available.", "error");
106
+ return;
107
+ }
108
+ const selection = await pickModel(`Select model for ${TARGET_LABEL[target]}`, models, current, ctx);
109
+ if (selection.kind === "cancel") return;
110
+ if (selection.kind === "model") {
111
+ chosen = models.find((m) => modelRef(m) === selection.value);
112
+ } else {
113
+ const ref = parseModelRef(selection.value);
114
+ thinkingHint = ref?.thinkingLevel;
115
+ chosen = ref ? matchById(models, ref.id, ref.provider) : undefined;
116
+ if (!chosen) {
117
+ ctx.ui.notify(
118
+ `No configured model matches "${selection.value}". It must exist in your model registry with credentials.`,
119
+ "error",
120
+ );
121
+ return;
122
+ }
123
+ }
124
+ }
125
+
126
+ if (!chosen) return;
127
+
128
+ let thinkingLevel = thinkingHint;
129
+ if (!thinkingLevel) {
130
+ thinkingLevel = await pickThinkingLevel(chosen, ctx);
131
+ if (thinkingLevel === undefined) return;
132
+ }
133
+
134
+ // Persist when a settings file backs this target.
135
+ if (target !== "session") {
136
+ const file = target === "global" ? globalSettingsPath() : projectSettingsPath(ctx.cwd);
137
+ const patch: Record<string, unknown> = {
138
+ defaultProvider: chosen.provider,
139
+ defaultModel: chosen.id,
140
+ };
141
+ if (thinkingLevel) patch.defaultThinkingLevel = thinkingLevel;
142
+ try {
143
+ await withFileMutationQueue(file, async () => {
144
+ await writeSettings(file, patch);
145
+ });
146
+ } catch (error) {
147
+ ctx.ui.notify(`Failed to write ${file}: ${error instanceof Error ? error.message : String(error)}`, "error");
148
+ return;
149
+ }
150
+ }
151
+
152
+ // Apply to the running session.
153
+ const switched = await pi.setModel(chosen as never);
154
+ if (!switched) {
155
+ ctx.ui.notify(`Pi refused to switch to ${modelRef(chosen)} in this session.`, "warning");
156
+ }
157
+ if (thinkingLevel) pi.setThinkingLevel(thinkingLevel as never);
158
+
159
+ const where = target === "session" ? "this session only" : target === "global" ? globalSettingsPath() : projectSettingsPath(ctx.cwd);
160
+ ctx.ui.notify(
161
+ `Model set to ${modelRef(chosen)} (${chosen.name}), thinking=${thinkingLevel ?? "unchanged"} — ${where}.`,
162
+ "info",
163
+ );
164
+ if (target === "project") {
165
+ ctx.ui.notify("Project .pi/settings.json is read at startup; run /reload after switching projects.", "info");
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Show the searchable, scrollable model picker.
171
+ *
172
+ * TUI sessions get the overlay component; other clients fall back to the plain
173
+ * `ctx.ui.select` dialog.
174
+ */
175
+ async function pickModel(
176
+ title: string,
177
+ models: ModelLike[],
178
+ current: ModelLike | undefined,
179
+ ctx: ExtensionCommandContext,
180
+ ): Promise<{ kind: "model"; value: string } | { kind: "other"; value: string } | { kind: "cancel" }> {
181
+ if (ctx.mode === "tui") {
182
+ return ctx.ui.custom((tui, theme, _kb, done) => new ModelPickerComponent(tui, theme, title, buildPickerItems(models, current), done), {
183
+ overlay: true,
184
+ overlayOptions: { anchor: "center", width: "70%", minWidth: 50, maxHeight: "90%", margin: 1 },
185
+ });
186
+ }
187
+ const options = [...models.map((m) => `${m.provider} ${m.name} [${m.id}]`), OTHER_OPTION];
188
+ const picked = await ctx.ui.select(title, options);
189
+ if (picked === undefined) return { kind: "cancel" };
190
+ if (picked === OTHER_OPTION) {
191
+ const typed = await ctx.ui.input("Model reference", "provider/model");
192
+ if (typed === undefined) return { kind: "cancel" };
193
+ return { kind: "other", value: typed.trim() };
194
+ }
195
+ const model = models[options.indexOf(picked)];
196
+ return model ? { kind: "model", value: modelRef(model) } : { kind: "cancel" };
197
+ }
198
+
199
+ function matchById(models: ModelLike[], id: string, provider?: string): ModelLike | undefined {
200
+ if (provider) return models.find((m) => m.provider === provider && m.id === id);
201
+ const matches = models.filter((m) => m.id === id);
202
+ return matches.length === 1 ? matches[0] : undefined;
203
+ }
204
+
205
+ async function pickThinkingLevel(model: ModelLike, ctx: ExtensionCommandContext): Promise<string | undefined> {
206
+ const levels = supportedThinkingLevels(model);
207
+ if (levels.length <= 1) return levels[0];
208
+ if (!ctx.hasUI) return undefined;
209
+ return ctx.ui.select(`Thinking level for ${modelRef(model)}`, levels);
210
+ }
package/lib/fuzzy.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Small fuzzy matcher used by the model picker.
3
+ *
4
+ * A query matches when every token (whitespace- or slash-separated) appears as a
5
+ * subsequence of the candidate text, in order. Lower scores rank first.
6
+ */
7
+
8
+ function tokenize(query: string): string[] {
9
+ return query
10
+ .toLowerCase()
11
+ .split(/[\s/]+/)
12
+ .filter((token) => token.length > 0);
13
+ }
14
+
15
+ /** Score a query against text. Returns `undefined` when the query does not match. */
16
+ export function fuzzyScore(query: string, text: string): number | undefined {
17
+ const tokens = tokenize(query);
18
+ if (tokens.length === 0) return 0;
19
+ const haystack = text.toLowerCase();
20
+ let total = 0;
21
+ let cursor = 0;
22
+ for (const token of tokens) {
23
+ let tokenIndex = 0;
24
+ let first = -1;
25
+ let found = -1;
26
+ for (let i = cursor; i < haystack.length && tokenIndex < token.length; i++) {
27
+ if (haystack[i] === token[tokenIndex]) {
28
+ if (first < 0) first = i;
29
+ found = i;
30
+ tokenIndex++;
31
+ }
32
+ }
33
+ if (tokenIndex < token.length) return undefined;
34
+ // A literal substring hit always beats a scattered subsequence hit, then
35
+ // earlier and tighter matches win.
36
+ const literal = haystack.indexOf(token, cursor) >= 0;
37
+ const gaps = found - first + 1 - token.length;
38
+ total += (literal ? 0 : 200) + first + gaps * 2;
39
+ cursor = found + 1;
40
+ }
41
+ return total;
42
+ }
43
+
44
+ /** Filter and rank items by fuzzy match quality against `getText(item)`. */
45
+ export function fuzzyFilterItems<T>(items: readonly T[], query: string, getText: (item: T) => string): T[] {
46
+ const tokens = tokenize(query);
47
+ if (tokens.length === 0) return [...items];
48
+ const scored: Array<{ item: T; score: number; index: number }> = [];
49
+ items.forEach((item, index) => {
50
+ const score = fuzzyScore(query, getText(item));
51
+ if (score !== undefined) scored.push({ item, score, index });
52
+ });
53
+ scored.sort((a, b) => (a.score === b.score ? a.index - b.index : a.score - b.score));
54
+ return scored.map((entry) => entry.item);
55
+ }
@@ -0,0 +1,179 @@
1
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Input, type Component, type TuiMouseEvent, type TuiMouseEventResult, type TUI, Text, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import { clampIndex, filterPickerItems, pickerMaxVisible, visibleRange, type PickerItem } from "./picker.ts";
5
+
6
+ const OTHER_LABEL = "Other…";
7
+ const OTHER_DESCRIPTION = "use provider/model typed above";
8
+
9
+ type PickerResult = { kind: "model"; value: string } | { kind: "other"; value: string } | { kind: "cancel" };
10
+
11
+ /**
12
+ * Scrollable, searchable model picker rendered as a centered overlay.
13
+ *
14
+ * The list shows at most `pickerMaxVisible` rows for the current terminal size, so
15
+ * navigation never runs the selection past the bottom of the screen.
16
+ */
17
+ export class ModelPickerComponent implements Component {
18
+ private readonly container = new Container();
19
+ private readonly input: Input;
20
+ private readonly listContainer = new Container();
21
+ private items: PickerItem[];
22
+ private selectedIndex = 0;
23
+ private listStartRow = 0;
24
+ private _focused = false;
25
+
26
+ constructor(
27
+ private readonly tui: TUI,
28
+ private readonly theme: Theme,
29
+ private readonly title: string,
30
+ items: PickerItem[],
31
+ private readonly done: (result: PickerResult) => void,
32
+ ) {
33
+ this.items = items;
34
+ this.selectedIndex = 0;
35
+
36
+ this.container.addChild(new DynamicBorder((str) => this.theme.fg("accent", str)));
37
+ this.container.addChild(new Text(this.theme.fg("accent", this.theme.bold(this.title)), 1, 0));
38
+ this.container.addChild(new Text(this.theme.fg("muted", "type to search · ↑↓ move · enter select · esc cancel"), 1, 0));
39
+
40
+ this.input = new Input({ prompt: this.theme.fg("accent", "> ") });
41
+ this.input.onSubmit = () => this.select();
42
+ this.container.addChild(this.input);
43
+ this.container.addChild(this.listContainer);
44
+ this.container.addChild(new Text(this.theme.fg("muted", " "), 1, 0));
45
+ this.container.addChild(new DynamicBorder((str) => this.theme.fg("accent", str)));
46
+
47
+ this.updateList();
48
+ }
49
+
50
+ get focused(): boolean {
51
+ return this._focused;
52
+ }
53
+
54
+ set focused(value: boolean) {
55
+ this._focused = value;
56
+ this.input.focused = value;
57
+ }
58
+
59
+ /** Filtered items plus the manual-entry row when the query is not an exact match. */
60
+ private visibleItems(): PickerItem[] {
61
+ const query = this.input.getValue();
62
+ const filtered = filterPickerItems(this.items, query);
63
+ const exact = filtered.some((item) => item.value.toLowerCase() === query.trim().toLowerCase());
64
+ if (exact) return filtered;
65
+ return [
66
+ ...filtered,
67
+ {
68
+ value: OTHER_LABEL,
69
+ label: OTHER_LABEL,
70
+ description: query.trim() === "" ? OTHER_DESCRIPTION : `use "${query.trim()}" as provider/model`,
71
+ searchText: OTHER_LABEL,
72
+ },
73
+ ];
74
+ }
75
+
76
+ private updateList(): void {
77
+ const items = this.visibleItems();
78
+ const maxVisible = pickerMaxVisible(items.length, this.tui.terminal.rows);
79
+ this.selectedIndex = clampIndex(this.selectedIndex, items.length);
80
+ const { start, end } = visibleRange(items.length, this.selectedIndex, maxVisible);
81
+
82
+ this.listContainer.clear();
83
+ for (let i = start; i < end; i++) {
84
+ const item = items[i];
85
+ if (!item) continue;
86
+ const selected = i === this.selectedIndex;
87
+ const prefix = selected ? this.theme.fg("accent", "→ ") : " ";
88
+ const label = selected ? this.theme.fg("accent", this.theme.bold(item.label)) : this.theme.fg("text", item.label);
89
+ const description = this.theme.fg("muted", ` ${item.description}`);
90
+ this.listContainer.addChild(new Text(prefix + label + description, 1, 0));
91
+ }
92
+ if (items.length === 0) {
93
+ this.listContainer.addChild(new Text(this.theme.fg("warning", " No matching models"), 1, 0));
94
+ }
95
+
96
+ this.listStartRow = PICKER_LIST_ROW;
97
+ const position = items.length === 0 ? "(0/0)" : `(${this.selectedIndex + 1}/${items.length})`;
98
+ this.listContainer.addChild(new Text(this.theme.fg("dim", ` ${position}`), 1, 0));
99
+ }
100
+
101
+ private move(delta: number): void {
102
+ const items = this.visibleItems();
103
+ if (items.length === 0) return;
104
+ this.selectedIndex = (this.selectedIndex + delta + items.length) % items.length;
105
+ this.updateList();
106
+ this.tui.requestRender();
107
+ }
108
+
109
+ private select(): void {
110
+ const items = this.visibleItems();
111
+ const item = items[this.selectedIndex];
112
+ if (!item) return;
113
+ if (item.value === OTHER_LABEL) {
114
+ const typed = this.input.getValue().trim();
115
+ if (typed === "") return;
116
+ this.done({ kind: "other", value: typed });
117
+ return;
118
+ }
119
+ this.done({ kind: "model", value: item.value });
120
+ }
121
+
122
+ render(width: number): string[] {
123
+ return this.container.render(width).map((line) => truncateToWidth(line, width, "…"));
124
+ }
125
+
126
+ invalidate(): void {
127
+ this.container.invalidate();
128
+ }
129
+
130
+ handleInput(data: string): void {
131
+ if (matchesKey(data, "up")) {
132
+ this.move(-1);
133
+ return;
134
+ }
135
+ if (matchesKey(data, "down")) {
136
+ this.move(1);
137
+ return;
138
+ }
139
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
140
+ this.done({ kind: "cancel" });
141
+ return;
142
+ }
143
+ this.input.handleInput(data);
144
+ // A new query re-ranks the list, so the best match is selected.
145
+ if (this.input.getValue() !== this.lastQuery) {
146
+ this.lastQuery = this.input.getValue();
147
+ this.selectedIndex = 0;
148
+ }
149
+ this.updateList();
150
+ this.tui.requestRender();
151
+ }
152
+
153
+ private lastQuery = "";
154
+
155
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
156
+ const items = this.visibleItems();
157
+ if (items.length === 0) return undefined;
158
+ if (event.type === "wheel" && event.wheelDelta) {
159
+ this.move(event.wheelDelta < 0 ? -1 : 1);
160
+ return { handled: true, render: true };
161
+ }
162
+ if (event.button === "left" && (event.type === "press" || event.type === "click")) {
163
+ const index = this.listStartRow + event.y;
164
+ if (index < 0 || index >= items.length) return undefined;
165
+ this.selectedIndex = index;
166
+ if (event.type === "click") {
167
+ this.select();
168
+ } else {
169
+ this.updateList();
170
+ this.tui.requestRender();
171
+ }
172
+ return { handled: true, focus: true };
173
+ }
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ /** Row index of the first list entry (border, title, hint, search input). */
179
+ const PICKER_LIST_ROW = 4;
package/lib/models.ts ADDED
@@ -0,0 +1,63 @@
1
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
2
+ export type ThinkingLevelName = (typeof THINKING_LEVELS)[number];
3
+
4
+ export interface ModelLike {
5
+ id: string;
6
+ name: string;
7
+ provider: string;
8
+ reasoning?: boolean;
9
+ thinkingLevelMap?: Record<string, unknown> | null;
10
+ }
11
+
12
+ export const OTHER_OPTION = "Other… (enter provider/model manually)";
13
+
14
+ /** Format a model as `provider/id` (the settings-file format). */
15
+ export function modelRef(model: Pick<ModelLike, "provider" | "id">): string {
16
+ return `${model.provider}/${model.id}`;
17
+ }
18
+
19
+ /** Parse `provider/id`, `id`, optionally with a `:thinking` suffix. */
20
+ export function parseModelRef(
21
+ ref: string,
22
+ ): { provider?: string; id: string; thinkingLevel?: string } | undefined {
23
+ const trimmed = ref.trim();
24
+ if (trimmed === "") return undefined;
25
+ let body = trimmed;
26
+ let thinkingLevel: string | undefined;
27
+ const colon = body.lastIndexOf(":");
28
+ if (colon > 0) {
29
+ const suffix = body.slice(colon + 1);
30
+ if ((THINKING_LEVELS as readonly string[]).includes(suffix)) {
31
+ thinkingLevel = suffix;
32
+ body = body.slice(0, colon);
33
+ }
34
+ }
35
+ const slash = body.indexOf("/");
36
+ if (slash > 0) {
37
+ return { provider: body.slice(0, slash), id: body.slice(slash + 1), thinkingLevel };
38
+ }
39
+ return { id: body, thinkingLevel };
40
+ }
41
+
42
+ /** Sort models by provider, then by name. */
43
+ export function sortModels<T extends ModelLike>(models: readonly T[]): T[] {
44
+ return [...models].sort((a, b) => {
45
+ const byProvider = a.provider.localeCompare(b.provider);
46
+ if (byProvider !== 0) return byProvider;
47
+ return a.name.localeCompare(b.name);
48
+ });
49
+ }
50
+
51
+ /** Thinking levels a model supports (all levels when the model declares no map). */
52
+ export function supportedThinkingLevels(model: ModelLike): ThinkingLevelName[] {
53
+ if (!model.reasoning) return ["off"];
54
+ const map = model.thinkingLevelMap as Record<string, unknown> | null | undefined;
55
+ if (!map) return [...THINKING_LEVELS];
56
+ return THINKING_LEVELS.filter((level) => map[level] !== null);
57
+ }
58
+
59
+ /** Label shown in the model picker. */
60
+ export function modelOptionLabel(model: ModelLike, current?: ModelLike): string {
61
+ const currentMark = current && current.provider === model.provider && current.id === model.id ? " (current)" : "";
62
+ return `${model.provider} ${model.name} [${model.id}]${currentMark}`;
63
+ }
package/lib/picker.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { fuzzyFilterItems } from "./fuzzy.ts";
2
+ import { modelRef, sortModels, type ModelLike } from "./models.ts";
3
+
4
+ export interface PickerItem {
5
+ /** `provider/model`, the value written to settings. */
6
+ value: string;
7
+ /** Primary column text. */
8
+ label: string;
9
+ /** Secondary column text (model id, markers). */
10
+ description: string;
11
+ /** Text the fuzzy search runs against. */
12
+ searchText: string;
13
+ }
14
+
15
+ /** Build picker items, current model first, then sorted by provider and name. */
16
+ export function buildPickerItems(models: readonly ModelLike[], current?: ModelLike): PickerItem[] {
17
+ const sorted = sortModels(models);
18
+ return sorted.map((model) => {
19
+ const isCurrent = !!current && current.provider === model.provider && current.id === model.id;
20
+ const description = [model.id, isCurrent ? "current" : undefined].filter(Boolean).join(" ");
21
+ return {
22
+ value: modelRef(model),
23
+ label: model.name,
24
+ description,
25
+ searchText: `${model.provider} ${model.id} ${model.name}`.toLowerCase(),
26
+ };
27
+ });
28
+ }
29
+
30
+ /** Rank items against a search query. An empty query keeps the original order. */
31
+ export function filterPickerItems(items: readonly PickerItem[], query: string): PickerItem[] {
32
+ return fuzzyFilterItems(items, query, (item) => item.searchText);
33
+ }
34
+
35
+ export interface VisibleRange {
36
+ start: number;
37
+ end: number;
38
+ }
39
+
40
+ /** Window of items to render so the selected item stays visible. */
41
+ export function visibleRange(total: number, selected: number, maxVisible: number): VisibleRange {
42
+ if (total <= 0) return { start: 0, end: 0 };
43
+ const size = Math.max(1, Math.min(maxVisible, total));
44
+ const start = Math.max(0, Math.min(selected - Math.floor(size / 2), total - size));
45
+ return { start, end: Math.min(start + size, total) };
46
+ }
47
+
48
+ /** Rows used by everything except the list itself (title, search, hints, borders). */
49
+ export const PICKER_CHROME_ROWS = 7;
50
+ /** Hard cap on visible model rows, regardless of terminal size. */
51
+ export const PICKER_MAX_ROWS = 15;
52
+
53
+ /**
54
+ * How many model rows to show: the item count, capped, and never more than the
55
+ * terminal can display without pushing the picker off screen.
56
+ */
57
+ export function pickerMaxVisible(itemCount: number, terminalRows: number, maxRows = PICKER_MAX_ROWS): number {
58
+ if (itemCount <= 0) return 0;
59
+ const byHeight = Math.max(3, terminalRows - PICKER_CHROME_ROWS);
60
+ return Math.max(1, Math.min(itemCount, maxRows, byHeight));
61
+ }
62
+
63
+ /** Clamp a selected index into the current item list. */
64
+ export function clampIndex(index: number, total: number): number {
65
+ if (total <= 0) return 0;
66
+ return Math.max(0, Math.min(index, total - 1));
67
+ }
@@ -0,0 +1,83 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export interface ModelDefaults {
7
+ defaultProvider?: string;
8
+ defaultModel?: string;
9
+ defaultThinkingLevel?: string;
10
+ }
11
+
12
+ /** Resolve the pi agent directory (`PI_CODING_AGENT_DIR` or `~/.pi/agent`). */
13
+ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
14
+ const configured = env.PI_CODING_AGENT_DIR;
15
+ if (configured && configured.trim() !== "") {
16
+ return expandHome(configured.trim(), env);
17
+ }
18
+ return join(homedir(), ".pi", "agent");
19
+ }
20
+
21
+ export function expandHome(path: string, env: NodeJS.ProcessEnv = process.env): string {
22
+ if (path === "~") return homedir();
23
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
24
+ const home = env.HOME ?? env.USERPROFILE;
25
+ if (path.startsWith("~") && home) return join(home, path.slice(1));
26
+ return path;
27
+ }
28
+
29
+ export function globalSettingsPath(env: NodeJS.ProcessEnv = process.env): string {
30
+ return join(agentDir(env), "settings.json");
31
+ }
32
+
33
+ export function projectSettingsPath(cwd: string): string {
34
+ return join(cwd, ".pi", "settings.json");
35
+ }
36
+
37
+ /** Read a settings file, returning `{}` when it is missing or malformed. */
38
+ export async function readSettings(file: string): Promise<Record<string, unknown>> {
39
+ let text: string;
40
+ try {
41
+ text = await readFile(file, "utf8");
42
+ } catch {
43
+ return {};
44
+ }
45
+ try {
46
+ const parsed: unknown = JSON.parse(text);
47
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
48
+ return parsed as Record<string, unknown>;
49
+ }
50
+ return {};
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ /** Merge `patch` into a settings file, preserving unrelated keys. Returns the merged object. */
57
+ export async function writeSettings(file: string, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
58
+ const current = await readSettings(file);
59
+ const next = { ...current, ...patch };
60
+ await mkdir(dirname(file), { recursive: true });
61
+ await writeFile(file, `${JSON.stringify(next, null, 2)}\n`, "utf8");
62
+ return next;
63
+ }
64
+
65
+ /** Human-readable summary of the model-related keys in a settings object. */
66
+ export function describeDefaults(settings: Record<string, unknown>): string {
67
+ const provider = typeof settings.defaultProvider === "string" ? settings.defaultProvider : undefined;
68
+ const model = typeof settings.defaultModel === "string" ? settings.defaultModel : undefined;
69
+ if (!provider && !model) return "(not set)";
70
+ const parts: string[] = [];
71
+ if (provider) parts.push(`defaultProvider=${provider}`);
72
+ if (model) parts.push(`defaultModel=${model}`);
73
+ if (typeof settings.defaultThinkingLevel === "string") parts.push(`defaultThinkingLevel=${settings.defaultThinkingLevel}`);
74
+ return parts.join(" ");
75
+ }
76
+
77
+ export function defaultsFromSettings(settings: Record<string, unknown>): ModelDefaults {
78
+ const out: ModelDefaults = {};
79
+ if (typeof settings.defaultProvider === "string") out.defaultProvider = settings.defaultProvider;
80
+ if (typeof settings.defaultModel === "string") out.defaultModel = settings.defaultModel;
81
+ if (typeof settings.defaultThinkingLevel === "string") out.defaultThinkingLevel = settings.defaultThinkingLevel;
82
+ return out;
83
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@faiku/pi-default-model-switcher",
3
+ "version": "0.0.1",
4
+ "author": "Faiku",
5
+ "private": false,
6
+ "type": "module",
7
+ "description": "Pi extension for switching the default startup model globally or per project",
8
+ "scripts": {
9
+ "test": "node --experimental-strip-types --test test/*.test.mjs",
10
+ "typecheck": "tsc -p tsconfig.json"
11
+ },
12
+ "devDependencies": {
13
+ "@earendil-works/pi-coding-agent": "*",
14
+ "@earendil-works/pi-tui": "^0.87.1",
15
+ "@types/node": "^22",
16
+ "typescript": "^5"
17
+ }
18
+ }
@@ -0,0 +1,119 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, readFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import {
8
+ agentDir,
9
+ describeDefaults,
10
+ globalSettingsPath,
11
+ projectSettingsPath,
12
+ readSettings,
13
+ writeSettings,
14
+ } from "../lib/settings.ts";
15
+ import {
16
+ modelOptionLabel,
17
+ modelRef,
18
+ parseModelRef,
19
+ sortModels,
20
+ supportedThinkingLevels,
21
+ OTHER_OPTION,
22
+ } from "../lib/models.ts";
23
+
24
+ async function tmp() {
25
+ return mkdtemp(join(tmpdir(), "dms-test-"));
26
+ }
27
+
28
+ test("agentDir honors PI_CODING_AGENT_DIR", () => {
29
+ assert.equal(agentDir({ PI_CODING_AGENT_DIR: "/tmp/agent" }), "/tmp/agent");
30
+ });
31
+
32
+ test("agentDir falls back to ~/.pi/agent", () => {
33
+ assert.equal(agentDir({}), join(process.env.HOME ?? "", ".pi", "agent"));
34
+ });
35
+
36
+ test("settings paths are derived from agent dir and cwd", () => {
37
+ assert.equal(projectSettingsPath("/work/repo"), "/work/repo/.pi/settings.json");
38
+ assert.equal(globalSettingsPath({ PI_CODING_AGENT_DIR: "/tmp/agent" }), "/tmp/agent/settings.json");
39
+ });
40
+
41
+ test("writeSettings creates the file, merges, and keeps unrelated keys", async () => {
42
+ const dir = await tmp();
43
+ const file = join(dir, "nested", "settings.json");
44
+ const merged = await writeSettings(file, { defaultModel: "m1", defaultProvider: "p1" });
45
+ assert.deepEqual(merged, { defaultModel: "m1", defaultProvider: "p1" });
46
+
47
+ await writeSettings(file, { defaultThinkingLevel: "high" });
48
+ assert.deepEqual(await readSettings(file), {
49
+ defaultModel: "m1",
50
+ defaultProvider: "p1",
51
+ defaultThinkingLevel: "high",
52
+ });
53
+ const text = await readFile(file, "utf8");
54
+ assert.ok(text.endsWith("\n"));
55
+ assert.ok(text.includes(' "defaultModel": "m1"'));
56
+ });
57
+
58
+ test("readSettings tolerates missing and malformed files", async () => {
59
+ const dir = await tmp();
60
+ assert.deepEqual(await readSettings(join(dir, "missing.json")), {});
61
+ const bad = join(dir, "bad.json");
62
+ await writeSettings(bad, {});
63
+ const { writeFile } = await import("node:fs/promises");
64
+ await writeFile(bad, "{not json", "utf8");
65
+ assert.deepEqual(await readSettings(bad), {});
66
+ });
67
+
68
+ test("describeDefaults reports set and unset defaults", () => {
69
+ assert.equal(describeDefaults({}), "(not set)");
70
+ assert.equal(
71
+ describeDefaults({ defaultProvider: "anthropic", defaultModel: "sonnet", defaultThinkingLevel: "low" }),
72
+ "defaultProvider=anthropic defaultModel=sonnet defaultThinkingLevel=low",
73
+ );
74
+ });
75
+
76
+ test("parseModelRef handles provider/id, bare id, and thinking suffix", () => {
77
+ assert.deepEqual(parseModelRef(" anthropic/claude-sonnet-4-5 "), { provider: "anthropic", id: "claude-sonnet-4-5", thinkingLevel: undefined });
78
+ assert.deepEqual(parseModelRef("gpt-5"), { id: "gpt-5", thinkingLevel: undefined });
79
+ assert.deepEqual(parseModelRef("openai/gpt-5:high"), { provider: "openai", id: "gpt-5", thinkingLevel: "high" });
80
+ assert.equal(parseModelRef(" "), undefined);
81
+ });
82
+
83
+ test("parseModelRef keeps colons that are part of the model id", () => {
84
+ assert.deepEqual(parseModelRef("openrouter/some-model:exacto"), { provider: "openrouter", id: "some-model:exacto", thinkingLevel: undefined });
85
+ });
86
+
87
+ test("modelRef joins provider and id", () => {
88
+ assert.equal(modelRef({ provider: "anthropic", id: "claude-sonnet-4-5" }), "anthropic/claude-sonnet-4-5");
89
+ });
90
+
91
+ test("sortModels orders by provider then name", () => {
92
+ const sorted = sortModels([
93
+ { provider: "openai", id: "b", name: "B" },
94
+ { provider: "anthropic", id: "a2", name: "Zeta" },
95
+ { provider: "anthropic", id: "a1", name: "Alpha" },
96
+ ]);
97
+ assert.deepEqual(sorted.map((m) => `${m.provider}/${m.id}`), ["anthropic/a1", "anthropic/a2", "openai/b"]);
98
+ });
99
+
100
+ test("supportedThinkingLevels respects reasoning and the level map", () => {
101
+ assert.deepEqual(supportedThinkingLevels({ id: "m", name: "m", provider: "p" }), ["off"]);
102
+ assert.deepEqual(
103
+ supportedThinkingLevels({ id: "m", name: "m", provider: "p", reasoning: true }),
104
+ ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
105
+ );
106
+ assert.deepEqual(
107
+ supportedThinkingLevels({ id: "m", name: "m", provider: "p", reasoning: true, thinkingLevelMap: { off: 0, minimal: null, low: 1, medium: null, high: null, xhigh: null, max: null } }),
108
+ ["off", "low"],
109
+ );
110
+ });
111
+
112
+ test("modelOptionLabel marks the current model", () => {
113
+ const current = { provider: "anthropic", id: "sonnet", name: "Sonnet" };
114
+ const label = modelOptionLabel(current, current);
115
+ assert.match(label, /\(current\)$/);
116
+ assert.match(label, /\[sonnet\]/);
117
+ assert.doesNotMatch(modelOptionLabel({ provider: "openai", id: "gpt", name: "GPT" }, current), /current/);
118
+ assert.notEqual(OTHER_OPTION, "");
119
+ });
@@ -0,0 +1,90 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { fuzzyFilterItems, fuzzyScore } from "../lib/fuzzy.ts";
5
+ import {
6
+ buildPickerItems,
7
+ clampIndex,
8
+ filterPickerItems,
9
+ PICKER_CHROME_ROWS,
10
+ PICKER_MAX_ROWS,
11
+ pickerMaxVisible,
12
+ visibleRange,
13
+ } from "../lib/picker.ts";
14
+
15
+ const models = [
16
+ { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true },
17
+ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true },
18
+ { provider: "anthropic", id: "claude-haiku-4-5", name: "Claude Haiku 4.5", reasoning: false },
19
+ { provider: "openrouter", id: "~anthropic/claude-opus-latest", name: "Claude Opus (latest)", reasoning: true },
20
+ ];
21
+
22
+ test("fuzzyScore matches subsequences and rejects misses", () => {
23
+ assert.equal(fuzzyScore("", "anything"), 0);
24
+ assert.equal(fuzzyScore(" ", "anything"), 0);
25
+ assert.notEqual(fuzzyScore("sonnet", "claude sonnet 4.5"), undefined);
26
+ assert.notEqual(fuzzyScore("claude sonnet", "claude sonnet 4.5"), undefined);
27
+ assert.equal(fuzzyScore("zzz", "claude sonnet"), undefined);
28
+ // tokens must appear in order
29
+ assert.equal(fuzzyScore("sonnet claude", "claude sonnet"), undefined);
30
+ });
31
+
32
+ test("fuzzyScore prefers earlier and tighter matches", () => {
33
+ assert.ok(fuzzyScore("sonnet", "claude sonnet 4.5") < fuzzyScore("sonnet", "claude haiku sonnet preview"));
34
+ });
35
+
36
+ test("fuzzyFilterItems keeps input order for an empty query and ranks matches otherwise", () => {
37
+ const all = fuzzyFilterItems(models, "", (m) => m.id);
38
+ assert.deepEqual(all, models);
39
+ const ranked = fuzzyFilterItems(models, "opus", (m) => `${m.provider} ${m.id} ${m.name}`);
40
+ assert.ok(ranked.length >= 1);
41
+ assert.equal(ranked[0].id, "~anthropic/claude-opus-latest");
42
+ });
43
+
44
+ test("buildPickerItems sorts by provider and marks the current model", () => {
45
+ const items = buildPickerItems(models, { provider: "openai", id: "gpt-5", name: "GPT-5" });
46
+ assert.deepEqual(items.map((i) => i.value), [
47
+ "anthropic/claude-haiku-4-5",
48
+ "anthropic/claude-sonnet-4-5",
49
+ "openai/gpt-5",
50
+ "openrouter/~anthropic/claude-opus-latest",
51
+ ]);
52
+ const current = items.find((i) => i.value === "openai/gpt-5");
53
+ assert.match(current.description, /current/);
54
+ assert.ok(!items[0].description.includes("current"));
55
+ // search text covers provider, id and name
56
+ assert.ok(items[0].searchText.includes("claude haiku"));
57
+ });
58
+
59
+ test("filterPickerItems matches on provider, id, and name", () => {
60
+ const items = buildPickerItems(models);
61
+ assert.equal(filterPickerItems(items, "gpt-5")[0].value, "openai/gpt-5");
62
+ assert.equal(filterPickerItems(items, "haiku")[0].value, "anthropic/claude-haiku-4-5");
63
+ assert.equal(filterPickerItems(items, "openrouter")[0].value, "openrouter/~anthropic/claude-opus-latest");
64
+ assert.equal(filterPickerItems(items, "nothing-here").length, 0);
65
+ });
66
+
67
+ test("visibleRange keeps the selection inside the window", () => {
68
+ assert.deepEqual(visibleRange(0, 0, 10), { start: 0, end: 0 });
69
+ assert.deepEqual(visibleRange(5, 0, 10), { start: 0, end: 5 });
70
+ assert.deepEqual(visibleRange(20, 0, 10), { start: 0, end: 10 });
71
+ assert.deepEqual(visibleRange(20, 19, 10), { start: 10, end: 20 });
72
+ assert.deepEqual(visibleRange(20, 10, 10), { start: 5, end: 15 });
73
+ });
74
+
75
+ test("pickerMaxVisible never exceeds the item count, the cap, or the terminal", () => {
76
+ assert.equal(pickerMaxVisible(0, 40), 0);
77
+ assert.equal(pickerMaxVisible(3, 40), 3);
78
+ assert.equal(pickerMaxVisible(100, 200), PICKER_MAX_ROWS);
79
+ // A short terminal clamps the window so the overlay still fits.
80
+ assert.equal(pickerMaxVisible(100, PICKER_CHROME_ROWS + 4), 4);
81
+ assert.equal(pickerMaxVisible(100, 4), 3, "minimum of three rows");
82
+ assert.equal(pickerMaxVisible(100, 200, 8), 8);
83
+ });
84
+
85
+ test("clampIndex keeps the selection in range", () => {
86
+ assert.equal(clampIndex(-5, 3), 0);
87
+ assert.equal(clampIndex(9, 3), 2);
88
+ assert.equal(clampIndex(1, 3), 1);
89
+ assert.equal(clampIndex(4, 0), 0);
90
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "noEmit": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "types": ["node"],
11
+ "lib": ["es2023"]
12
+ },
13
+ "include": ["index.ts", "lib/**/*.ts"]
14
+ }