@xynogen/pix-models 0.2.7 → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xynogen
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
@@ -10,6 +10,7 @@ Registers a `/models` slash command — a richer TUI picker replacing Pi's built
10
10
  - **Sorting** — by coding score (best first), then alphabetically for unscored models. Fuzzy search filters as you type.
11
11
  - **Thinking level** — left/right cycles `off` → `minimal` → `low` → `medium` → `high` → `xhigh`, shown live in the header.
12
12
  - **Select** — switches the active model for the session.
13
+ - **Per-project prefs** — every model / thinking change is saved with `defaultProvider`, `defaultModel`, `defaultThinkingLevel` in one file: `<project>/.pi/settings.json` for trusted projects (created on first change), otherwise `~/.pi/agent/settings.json`. Pi merges the project file over the user file, so other projects fall back to user defaults. Each save shows a short notice with the target file.
13
14
 
14
15
  Model metadata comes from `~/.cache/pi/` via `pix-data`; the coding score/rank is computed locally from the modelgrep catalog (best = #1).
15
16
 
@@ -19,22 +20,22 @@ Model metadata comes from `~/.cache/pi/` via `pix-data`; the coding score/rank i
19
20
  pi install npm:@xynogen/pix-models
20
21
  ```
21
22
 
22
- > Also included in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core):
23
- >
24
- > ```bash
25
- > pi install npm:@xynogen/pix-core
26
- > ```
23
+ > Bundled in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core). Install it alone only if you do not use pix-core.
27
24
 
28
25
  ## Full distro
29
26
 
30
- Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
31
-
32
- To install the complete pix suite (all packages + Pi itself):
27
+ This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
33
28
 
34
29
  ```bash
30
+ # Linux / macOS
35
31
  curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
36
32
  ```
37
33
 
34
+ ```powershell
35
+ # Windows
36
+ irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
37
+ ```
38
+
38
39
  ## License
39
40
 
40
41
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-models",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension — enhanced /models picker with BenchLM ranks",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -43,9 +43,9 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@xynogen/pix-data": "^0.5.0",
47
- "@xynogen/pix-pretty": "^1.11.2",
48
- "@xynogen/pix-runtime": "^0.10.0"
46
+ "@xynogen/pix-data": "^0.5.3",
47
+ "@xynogen/pix-pretty": "^1.29.0",
48
+ "@xynogen/pix-runtime": "^0.12.2"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@earendil-works/pi-coding-agent": "*",
package/src/models.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  SelectList,
19
19
  visibleWidth,
20
20
  } from "@earendil-works/pi-tui";
21
- import { benchScoreColor, lookupBenchmark, lookupModelsDev } from "@xynogen/pix-data";
21
+ import { benchScoreColor, formatCost, lookupBenchmark, lookupModelsDev } from "@xynogen/pix-data";
22
22
  import { icon } from "@xynogen/pix-pretty/icon-catalog";
23
23
  import {
24
24
  frameModal,
@@ -31,6 +31,7 @@ import {
31
31
  import { dotJoin } from "@xynogen/pix-pretty/utils";
32
32
  import { SPINNER } from "@xynogen/pix-pretty/widget-format";
33
33
  import { patchOutBuiltinModelCommand } from "./patch-builtin";
34
+ import { registerProjectPrefs } from "./project-prefs";
34
35
 
35
36
  // ─── Pure logic (exported for tests) ─────────────────────────────────────────
36
37
 
@@ -57,12 +58,10 @@ export function fmtCtx(n: number): string {
57
58
  return `${Math.round(n / 1_000)}k`;
58
59
  }
59
60
 
61
+ /** Table cell: pix-data formatCost, or an em dash when the price is unknown. */
60
62
  export function fmtCost(entry: { cost?: { input?: number; output?: number } } | undefined): string {
61
63
  if (!entry?.cost) return "\u2014";
62
- const i = entry.cost.input ?? 0;
63
- const o = entry.cost.output ?? 0;
64
- if (i === 0 && o === 0) return "free";
65
- return `${i.toFixed(2)}/${o.toFixed(2)}`;
64
+ return formatCost(entry.cost.input ?? 0, entry.cost.output ?? 0) ?? "\u2014";
66
65
  }
67
66
 
68
67
  export function benchStars(score: number | null | undefined): {
@@ -111,6 +110,16 @@ export function sortModels<T extends SortableModel>(models: T[]): T[] {
111
110
  });
112
111
  }
113
112
 
113
+ /** Short protocol tag for a classifier model's api. */
114
+ export function classifierProtocol(api: string): string {
115
+ if (api.endsWith("system-one")) return "System One";
116
+ if (api === "llama-cpp-classify") return "llama.cpp classify";
117
+ return api;
118
+ }
119
+
120
+ /** Picker value prefix for classifier rows. They cannot be chat models. */
121
+ export const CLASSIFIER_VALUE_PREFIX = "classifier:";
122
+
114
123
  /** Lowercase and strip all non-alphanumerics: "glm-5.2" → "glm52". */
115
124
  export function normalizeModelText(s: string): string {
116
125
  return s.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -236,9 +245,23 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
236
245
  const registry = ctx.modelRegistry as unknown as {
237
246
  refresh?: () => void;
238
247
  getAvailable(): AvailableModels | Promise<AvailableModels>;
248
+ getAvailableOfType?: (type: "classifier") => Promise<
249
+ readonly {
250
+ provider: string;
251
+ id: string;
252
+ name?: string;
253
+ api: string;
254
+ contextWindow?: number;
255
+ }[]
256
+ >;
239
257
  };
258
+ // Classifier models (System One etc.) answer typed questions, not chat.
259
+ // Show them in their own section so they are visible but never picked as the chat model.
260
+ const loadClassifiers = async () =>
261
+ (await registry.getAvailableOfType?.("classifier").catch(() => [])) ?? [];
240
262
  registry.refresh?.();
241
263
  let available = await registry.getAvailable();
264
+ let classifiers = await loadClassifiers();
242
265
  if (available.length === 0) {
243
266
  ctx.ui.notify("No models with configured auth.", "warning");
244
267
  return;
@@ -300,15 +323,11 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
300
323
  theme.fg("text", key) + theme.fg("muted", ` ${action}`);
301
324
  const guideSep = theme.fg("muted", " · ");
302
325
 
303
- const buildPickerData = (sourceRows: Row[]) => {
304
- const maxRankWidth = Math.max(
305
- ...sourceRows.map((row) => (row.localRank ? String(row.localRank).length : 0)),
306
- 1,
307
- );
308
- const maxCostWidth = Math.max(
309
- ...sourceRows.map((row) => fmtCost(row.dev).length),
310
- "free".length,
311
- );
326
+ const buildPickerData = (sourceRows: Row[], classifierModels: typeof classifiers) => {
327
+ // Classifier rows continue the numbering after the last ranked chat row.
328
+ const firstClassifierRank = Math.max(0, ...sourceRows.map((row) => row.localRank ?? 0)) + 1;
329
+ const lastRank = firstClassifierRank - 1 + classifierModels.length;
330
+ const maxRankWidth = Math.max(String(lastRank).length, 1);
312
331
  const rankByValue = new Map<string, number>();
313
332
  const searchTextByValue = new Map<string, string>();
314
333
  const normalizedByValue = new Map<string, string>();
@@ -320,13 +339,25 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
320
339
  normalizedByValue.set(value, normalizeModelText(text));
321
340
  }
322
341
 
323
- const items: SelectItem[] = sourceRows.map(({ m, dev, bench, localRank }) => {
342
+ const classifierRows = classifierModels.map((c) => ({
343
+ m: c,
344
+ dev: lookupModelsDev(c.provider, c.id),
345
+ bench: lookupBenchmark(c.id),
346
+ }));
347
+ const maxCostWidth = Math.max(
348
+ ...[...sourceRows, ...classifierRows].map((row) => fmtCost(row.dev).length),
349
+ "free".length,
350
+ );
351
+ const renderRow = (
352
+ m: { provider: string; id: string; contextWindow?: number },
353
+ dev: Row["dev"],
354
+ bench: Row["bench"],
355
+ rankPrefix: string,
356
+ value: string,
357
+ kindMark?: string,
358
+ ): SelectItem => {
324
359
  const isCurrent = current && m.provider === current.provider && m.id === current.id;
325
360
  const marker = isCurrent ? theme.fg(accent, "▶") : " ";
326
- const rankPrefix = localRank
327
- ? mute("#") +
328
- theme.fg(benchScoreColor(bench?.overallScore), String(localRank).padEnd(maxRankWidth))
329
- : mute("#") + mute("—".padEnd(maxRankWidth, " "));
330
361
  const nameColor = bench ? benchScoreColor(bench.overallScore) : accent;
331
362
  const label = `${marker} ${rankPrefix} ${theme.fg(nameColor, m.id)}`;
332
363
  const ctxRaw = fmtCtx(
@@ -348,10 +379,34 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
348
379
  benchSeg = `⚡${theme.fg(scoreColor, String(score))} ${stars}`;
349
380
  }
350
381
  return {
351
- value: `${m.provider}/${m.id}`,
382
+ value,
352
383
  label,
353
- description: dotJoin([mute(ctxRaw.padStart(4)), costSeg, benchSeg], mute),
384
+ description: dotJoin(
385
+ [mute(ctxRaw.padStart(4)), costSeg, [benchSeg, kindMark].filter(Boolean).join(" ")],
386
+ mute,
387
+ ),
354
388
  };
389
+ };
390
+
391
+ const items: SelectItem[] = sourceRows.map(({ m, dev, bench, localRank }) => {
392
+ const rankPrefix = localRank
393
+ ? mute("#") +
394
+ theme.fg(benchScoreColor(bench?.overallScore), String(localRank).padEnd(maxRankWidth))
395
+ : mute("#") + mute("—".padEnd(maxRankWidth, " "));
396
+ return renderRow(m, dev, bench, rankPrefix, `${m.provider}/${m.id}`);
397
+ });
398
+ // Classifier rows: same columns and numbering. The symbol takes the
399
+ // empty score slot at the end of the row.
400
+ const classifierMark = theme.fg("warning", icon("model.classifier"));
401
+ classifierRows.forEach(({ m: c, dev, bench }, i) => {
402
+ const rank = firstClassifierRank + i;
403
+ const value = `${CLASSIFIER_VALUE_PREFIX}${c.provider}/${c.id}`;
404
+ const text = `${c.id} ${c.name ?? ""} classifier ${classifierProtocol(c.api)}`;
405
+ rankByValue.set(value, rank);
406
+ searchTextByValue.set(value, text);
407
+ normalizedByValue.set(value, normalizeModelText(text));
408
+ const rankPrefix = mute("#") + mute(String(rank).padEnd(maxRankWidth));
409
+ items.push(renderRow(c, dev, bench, rankPrefix, value, classifierMark));
355
410
  });
356
411
  return {
357
412
  items,
@@ -362,7 +417,7 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
362
417
  };
363
418
  };
364
419
 
365
- let pickerData = buildPickerData(buildRows(available));
420
+ let pickerData = buildPickerData(buildRows(available), classifiers);
366
421
  const currentIdx = current
367
422
  ? pickerData.items.findIndex((item) => item.value === `${current.provider}/${current.id}`)
368
423
  : 0;
@@ -427,7 +482,8 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
427
482
  return;
428
483
  }
429
484
  available = refreshed;
430
- pickerData = buildPickerData(buildRows(available));
485
+ classifiers = await loadClassifiers();
486
+ pickerData = buildPickerData(buildRows(available), classifiers);
431
487
  listInternal.items = pickerData.items;
432
488
  listInternal.maxVisible = Math.max(1, pickerData.items.length);
433
489
  listInternal.layout = {
@@ -565,6 +621,15 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
565
621
 
566
622
  if (!result) return;
567
623
 
624
+ if (result.startsWith(CLASSIFIER_VALUE_PREFIX)) {
625
+ const ref = result.slice(CLASSIFIER_VALUE_PREFIX.length);
626
+ ctx.ui.notify(
627
+ `${ref} is a classifier model, not a chat model. Call it with models.classify() in codemode or ctx.modelRegistry.classify().`,
628
+ "info",
629
+ );
630
+ return;
631
+ }
632
+
568
633
  // Apply selection
569
634
  const [provider, ...rest] = result.split("/");
570
635
  const id = rest.join("/");
@@ -574,8 +639,11 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
574
639
  return;
575
640
  }
576
641
  const ok = await pi.setModel(picked);
577
- if (ok) ctx.ui.notify(`Switched to ${picked.name ?? picked.id}`, "info");
578
- else ctx.ui.notify(`Failed to switch to ${picked.id}`, "error");
642
+ if (!ok) {
643
+ ctx.ui.notify(`Failed to switch to ${picked.id}`, "error");
644
+ return;
645
+ }
646
+ // Persistence happens in project-prefs.ts via the model_select event.
579
647
  }
580
648
 
581
649
  export default function modelPickerExtension(pi: ExtensionAPI) {
@@ -586,6 +654,7 @@ export default function modelPickerExtension(pi: ExtensionAPI) {
586
654
  const handler = async (_args: unknown, ctx: ExtensionContext) => {
587
655
  await showEnhancedPicker(pi, ctx);
588
656
  };
657
+ registerProjectPrefs(pi);
589
658
  pi.registerCommand("models", {
590
659
  description: "Enhanced model picker — shows benchlm rank + score",
591
660
  handler,
@@ -14,17 +14,46 @@
14
14
  * only fall back to dist/core/ for older hosts that lack a bundle dir.
15
15
  *
16
16
  * Package-root resolution (in order):
17
- * 1. `pi` binary via PATH → realpath → split at /dist/ to get the pkg root.
18
- * 2. Well-known global install locations (bun, npm).
19
- * 3. createRequire against the extension's own node_modules.
17
+ * 1. The running CLI script (`process.argv[1]`) → split at /dist/.
18
+ * 2. Pi managed install: `<PI_MANAGED_INSTALL_ROOT | agentDir/install>/
19
+ * releases/<current-version>/node_modules/@earendil-works/pi-coding-agent`.
20
+ * 3. `pi` binary via PATH → realpath → split at /dist/ to get the pkg root.
21
+ * 4. Well-known global install locations (bun, npm).
22
+ * 5. createRequire against the extension's own node_modules.
20
23
  */
21
24
 
22
25
  import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
23
26
  import { createRequire } from "node:module";
24
- import { homedir } from "node:os";
25
27
  import { join, sep } from "node:path";
28
+ import { agentDir, homeDir } from "@xynogen/pix-runtime/paths";
26
29
  import { findExecutableSync } from "@xynogen/pix-runtime/which";
27
30
 
31
+ const PI_PACKAGE = ["@earendil-works", "pi-coding-agent"] as const;
32
+
33
+ /** Package root before `/dist/` in a path, if any. */
34
+ function rootBeforeDist(path: string | undefined): string | undefined {
35
+ if (!path) return undefined;
36
+ const idx = path.indexOf(`${sep}dist${sep}`);
37
+ return idx >= 0 ? path.slice(0, idx) : undefined;
38
+ }
39
+
40
+ /**
41
+ * Package root of Pi's managed install (`releases-v1` layout), or undefined
42
+ * when the install dir or its `current-version` file is absent.
43
+ */
44
+ export function managedInstallRoot(
45
+ installRoot: string = process.env.PI_MANAGED_INSTALL_ROOT || join(agentDir(), "install"),
46
+ ): string | undefined {
47
+ try {
48
+ const version = readFileSync(join(installRoot, "current-version"), "utf8").trim();
49
+ if (!/^[0-9A-Za-z._+-]+$/.test(version) || version === "." || version === "..")
50
+ return undefined;
51
+ return join(installRoot, "releases", version, "node_modules", ...PI_PACKAGE);
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
28
57
  /** Locate `pi` on PATH, returning its real (symlink-resolved) path. */
29
58
  function resolvePiBinary(): string | undefined {
30
59
  const found = findExecutableSync("pi");
@@ -53,30 +82,34 @@ function packageRoots(): string[] {
53
82
  if (r && !roots.includes(r)) roots.push(r);
54
83
  };
55
84
 
56
- // 1. Resolve via the `pi` binary on PATH → realpath → strip at /dist/.
57
- const piReal = resolvePiBinary();
58
- if (piReal) {
59
- const idx = piReal.indexOf(`${sep}dist${sep}`);
60
- if (idx >= 0) pushRoot(piReal.slice(0, idx));
85
+ // 1. The CLI script this process is running (most specific).
86
+ try {
87
+ pushRoot(rootBeforeDist(process.argv[1] ? realpathSync(process.argv[1]) : undefined));
88
+ } catch {
89
+ pushRoot(rootBeforeDist(process.argv[1]));
61
90
  }
62
91
 
63
- // 2. Well-known global install locations.
64
- const home = homedir();
92
+ // 2. Pi managed install (pi on PATH is a launcher shim there).
93
+ pushRoot(managedInstallRoot());
94
+
95
+ // 3. Resolve via the `pi` binary on PATH → realpath → strip at /dist/.
96
+ pushRoot(rootBeforeDist(resolvePiBinary()));
97
+
98
+ // 4. Well-known global install locations.
99
+ const home = homeDir();
65
100
  for (const root of [
66
101
  join(home, ".bun", "install", "global", "node_modules"),
67
102
  join(home, ".npm-global", "lib", "node_modules"),
68
103
  "/usr/local/lib/node_modules",
69
104
  "/usr/lib/node_modules",
70
105
  ]) {
71
- pushRoot(join(root, "@earendil-works", "pi-coding-agent"));
106
+ pushRoot(join(root, ...PI_PACKAGE));
72
107
  }
73
108
 
74
- // 3. Fallback: createRequire from this file (co-installed extension).
109
+ // 5. Fallback: createRequire from this file (co-installed extension).
75
110
  try {
76
111
  const require = createRequire(import.meta.url);
77
- const entry = require.resolve("@earendil-works/pi-coding-agent");
78
- const idx = entry.indexOf(`${sep}dist${sep}`);
79
- if (idx >= 0) pushRoot(entry.slice(0, idx));
112
+ pushRoot(rootBeforeDist(require.resolve("@earendil-works/pi-coding-agent")));
80
113
  } catch {
81
114
  // local resolution failed — skip
82
115
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * project-prefs.ts — persist model/provider/thinking per project.
3
+ *
4
+ * All three prefs live together in Pi's own project settings file
5
+ * (`<cwd>/.pi/settings.json`). Pi deep-merges that file over the user-level
6
+ * `<agentDir>/settings.json` at startup, so projects without their own prefs
7
+ * fall back to the user defaults with no extra loader.
8
+ *
9
+ * Untrusted projects (Pi refuses to read their settings) and a cwd whose
10
+ * `.pi` IS the user config root (cwd === home) write the user-level file.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { showTransientMessage } from "@xynogen/pix-pretty/transient-error";
17
+ import { getErrorMessage } from "@xynogen/pix-pretty/utils";
18
+ import { writeFileAtomicSync } from "@xynogen/pix-runtime/atomic-write";
19
+ import { agentDir, projectDir } from "@xynogen/pix-runtime/paths";
20
+
21
+ export type ModelPrefs = {
22
+ defaultProvider?: string;
23
+ defaultModel?: string;
24
+ defaultThinkingLevel?: string;
25
+ };
26
+
27
+ export type PrefsTarget = { path: string; scope: "project" | "user" };
28
+
29
+ /** Pick the settings file that should own this cwd's prefs. */
30
+ export function prefsTarget(cwd: string, trusted: boolean, agent = agentDir()): PrefsTarget {
31
+ const userFile = join(agent, "settings.json");
32
+ const project = projectDir(resolve(cwd));
33
+ // cwd/.pi is the parent of the agent dir → "project" file would be ~/.pi/settings.json,
34
+ // which is confusing and not a real project. Use the user file instead.
35
+ if (!trusted || resolve(project) === resolve(dirname(agent))) {
36
+ return { path: userFile, scope: "user" };
37
+ }
38
+ return { path: join(project, "settings.json"), scope: "project" };
39
+ }
40
+
41
+ /** Merge `prefs` into the JSON settings file at `path`. Returns true if it changed. */
42
+ export function writePrefs(path: string, prefs: ModelPrefs): boolean {
43
+ let current: Record<string, unknown> = {};
44
+ if (existsSync(path)) {
45
+ const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
46
+ if (raw.trim()) {
47
+ const parsed: unknown = JSON.parse(raw);
48
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
49
+ current = parsed as Record<string, unknown>;
50
+ }
51
+ }
52
+ }
53
+ let changed = false;
54
+ for (const [key, value] of Object.entries(prefs)) {
55
+ if (value !== undefined && current[key] !== value) {
56
+ current[key] = value;
57
+ changed = true;
58
+ }
59
+ }
60
+ if (changed) writeFileAtomicSync(path, `${JSON.stringify(current, null, 2)}\n`, 0o644);
61
+ return changed;
62
+ }
63
+
64
+ /** Save prefs for ctx.cwd and surface where they went. */
65
+ export function savePrefs(
66
+ ctx: ExtensionContext,
67
+ prefs: ModelPrefs,
68
+ label: string,
69
+ ): PrefsTarget | undefined {
70
+ const target = prefsTarget(ctx.cwd, ctx.isProjectTrusted());
71
+ try {
72
+ if (writePrefs(target.path, prefs) && ctx.hasUI) {
73
+ const where = target.scope === "project" ? ".pi/settings.json" : "user settings.json";
74
+ showTransientMessage(ctx.ui, `${label} saved to ${where}`, "info");
75
+ }
76
+ return target;
77
+ } catch (err) {
78
+ if (ctx.hasUI) {
79
+ const msg = getErrorMessage(err);
80
+ showTransientMessage(ctx.ui, `Failed to save ${label}: ${msg}`, "warning");
81
+ }
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ /** Persist every user-driven model / thinking change to the owning settings file. */
87
+ export function registerProjectPrefs(pi: ExtensionAPI): void {
88
+ pi.on("model_select", (event, ctx) => {
89
+ // "restore" = session resume; not a user choice.
90
+ if (event.source === "restore") return;
91
+ const { provider, id } = event.model;
92
+ savePrefs(ctx, { defaultProvider: provider, defaultModel: id }, `Model ${provider}/${id}`);
93
+ });
94
+ pi.on("thinking_level_select", (event, ctx) => {
95
+ // Non-reasoning models force "off"; don't let that clobber the saved preference.
96
+ if (!ctx.model?.reasoning) return;
97
+ savePrefs(ctx, { defaultThinkingLevel: event.level }, `Thinking ${event.level}`);
98
+ });
99
+ }