@faiku/pi-default-model-switcher 0.0.1 → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +21 -4
- package/package.json +38 -4
- package/test/extension.test.mjs +0 -119
- package/test/picker.test.mjs +0 -90
- package/tsconfig.json +0 -14
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Faiku
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# pi-default-model-switcher
|
|
1
|
+
# @faiku/pi-default-model-switcher
|
|
2
2
|
|
|
3
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
4
|
|
|
@@ -33,7 +33,19 @@ All model commands accept an optional argument to skip the pickers:
|
|
|
33
33
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
From npm (recommended):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pi install npm:@faiku/pi-default-model-switcher
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Try it for a single run without installing:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pi -e npm:@faiku/pi-default-model-switcher
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
From a local checkout:
|
|
37
49
|
|
|
38
50
|
```bash
|
|
39
51
|
ln -s "$PWD" ~/.pi/agent/extensions/pi-default-model-switcher
|
|
@@ -47,7 +59,7 @@ pi --extension ./index.ts
|
|
|
47
59
|
|
|
48
60
|
```bash
|
|
49
61
|
# add to <project>/.pi/settings.json
|
|
50
|
-
{ "extensions": ["/
|
|
62
|
+
{ "extensions": ["/absolute/path/to/pi-default-model-switcher"] }
|
|
51
63
|
```
|
|
52
64
|
|
|
53
65
|
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.
|
|
@@ -61,4 +73,9 @@ npm run typecheck # tsc --noEmit
|
|
|
61
73
|
```
|
|
62
74
|
|
|
63
75
|
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
|
-
|
|
76
|
+
|
|
77
|
+
The extension ships as TypeScript source — pi loads `.ts` entry points directly, so there is no build step.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faiku/pi-default-model-switcher",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension for switching the default startup model globally or per project",
|
|
4
5
|
"author": "Faiku",
|
|
5
|
-
"
|
|
6
|
+
"license": "MIT",
|
|
6
7
|
"type": "module",
|
|
7
|
-
"
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"model",
|
|
13
|
+
"settings"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Faikuu/pi-default-model-switcher.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/Faikuu/pi-default-model-switcher#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/Faikuu/pi-default-model-switcher/issues"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./index.ts"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"index.ts",
|
|
30
|
+
"lib/",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*"
|
|
40
|
+
},
|
|
8
41
|
"scripts": {
|
|
9
42
|
"test": "node --experimental-strip-types --test test/*.test.mjs",
|
|
10
|
-
"typecheck": "tsc -p tsconfig.json"
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
44
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
11
45
|
},
|
|
12
46
|
"devDependencies": {
|
|
13
47
|
"@earendil-works/pi-coding-agent": "*",
|
package/test/extension.test.mjs
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
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
|
-
});
|
package/test/picker.test.mjs
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
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
|
-
}
|