@bachi/pi-coder 1.0.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 (101) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/config/AGENTS.md +100 -0
  5. package/config/pi-statusline.json +140 -0
  6. package/config/settings.json +38 -0
  7. package/config/web-search.json +5 -0
  8. package/docs/README.md +14 -0
  9. package/docs/configuration.md +123 -0
  10. package/docs/development.md +177 -0
  11. package/docs/extensions.md +292 -0
  12. package/docs/handbook.zh.md +432 -0
  13. package/docs/installation.md +124 -0
  14. package/docs/themes.md +107 -0
  15. package/extensions/ask-user-question/answers.test.ts +104 -0
  16. package/extensions/ask-user-question/answers.ts +72 -0
  17. package/extensions/ask-user-question/dialog.test.ts +180 -0
  18. package/extensions/ask-user-question/dialog.ts +102 -0
  19. package/extensions/ask-user-question/index.ts +253 -0
  20. package/extensions/ask-user-question/model.test.ts +275 -0
  21. package/extensions/ask-user-question/model.ts +259 -0
  22. package/extensions/ask-user-question/schema.ts +49 -0
  23. package/extensions/ask-user-question/types.ts +86 -0
  24. package/extensions/ask-user-question/validate.test.ts +183 -0
  25. package/extensions/ask-user-question/validate.ts +110 -0
  26. package/extensions/ask-user-question/view.ts +262 -0
  27. package/extensions/auto-default-model/default-model.test.ts +268 -0
  28. package/extensions/auto-default-model/index.ts +87 -0
  29. package/extensions/bash-command-collapse.ts +1476 -0
  30. package/extensions/below-editor-after-statusline.ts +118 -0
  31. package/extensions/clear-command.ts +29 -0
  32. package/extensions/cwd-statusline.ts +39 -0
  33. package/extensions/exit-command.ts +59 -0
  34. package/extensions/fenceless-code-block/index.test.ts +208 -0
  35. package/extensions/fenceless-code-block/index.ts +28 -0
  36. package/extensions/fenceless-code-block/render.test.ts +177 -0
  37. package/extensions/fenceless-code-block/render.ts +142 -0
  38. package/extensions/folder-history.ts +197 -0
  39. package/extensions/init-command.ts +163 -0
  40. package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
  41. package/extensions/prompt-editor/bash-prompt.ts +59 -0
  42. package/extensions/prompt-editor/render.test.ts +283 -0
  43. package/extensions/prompt-editor.ts +212 -0
  44. package/extensions/read-path-collapse.ts +474 -0
  45. package/extensions/recap/index.test.ts +348 -0
  46. package/extensions/recap/index.ts +462 -0
  47. package/extensions/recap/subagents.test.ts +144 -0
  48. package/extensions/recap/subagents.ts +128 -0
  49. package/extensions/rewind/README.md +229 -0
  50. package/extensions/rewind/checkpoints.test.ts +560 -0
  51. package/extensions/rewind/checkpoints.ts +820 -0
  52. package/extensions/rewind/flow.test.ts +756 -0
  53. package/extensions/rewind/flow.ts +362 -0
  54. package/extensions/rewind/index.ts +400 -0
  55. package/extensions/rewind/picker.ts +135 -0
  56. package/extensions/rewind/viewport.test.ts +76 -0
  57. package/extensions/rewind/viewport.ts +48 -0
  58. package/extensions/simple-task/gap.test.ts +147 -0
  59. package/extensions/simple-task/gap.ts +122 -0
  60. package/extensions/simple-task/index.ts +439 -0
  61. package/extensions/simple-task/types.ts +53 -0
  62. package/extensions/simple-task/widget.ts +86 -0
  63. package/extensions/startup-logo/header-guard.test.ts +274 -0
  64. package/extensions/startup-logo/header-guard.ts +166 -0
  65. package/extensions/startup-logo/index.test.ts +305 -0
  66. package/extensions/startup-logo/index.ts +194 -0
  67. package/extensions/startup-logo/loaded-sections.test.ts +257 -0
  68. package/extensions/startup-logo/loaded-sections.ts +267 -0
  69. package/extensions/startup-logo/logo.test.ts +124 -0
  70. package/extensions/startup-logo/logo.ts +124 -0
  71. package/extensions/statusline/footer-guard.test.ts +273 -0
  72. package/extensions/statusline/footer-guard.ts +171 -0
  73. package/extensions/statusline/git.test.ts +174 -0
  74. package/extensions/statusline/git.ts +142 -0
  75. package/extensions/statusline/index.ts +294 -0
  76. package/extensions/statusline/line.test.ts +316 -0
  77. package/extensions/statusline/line.ts +201 -0
  78. package/extensions/subagent-log-guard/filter.test.ts +85 -0
  79. package/extensions/subagent-log-guard/filter.ts +32 -0
  80. package/extensions/subagent-log-guard/index.ts +112 -0
  81. package/extensions/theme-command.ts +263 -0
  82. package/extensions/thinking-collapse/window.test.ts +321 -0
  83. package/extensions/thinking-collapse/window.ts +354 -0
  84. package/extensions/thinking-collapse.ts +60 -0
  85. package/extensions/tool-diff/title-row.test.ts +254 -0
  86. package/extensions/tool-diff/title-row.ts +191 -0
  87. package/extensions/tool-diff.ts +1276 -0
  88. package/extensions/working-indicator/bash-spinner.test.ts +135 -0
  89. package/extensions/working-indicator/bash-spinner.ts +114 -0
  90. package/extensions/working-indicator/index.test.ts +579 -0
  91. package/extensions/working-indicator/index.ts +940 -0
  92. package/extensions/working-indicator/spinner-frames.test.ts +219 -0
  93. package/extensions/working-indicator/spinner-frames.ts +156 -0
  94. package/extensions/working-indicator/summary-request.test.ts +195 -0
  95. package/extensions/working-indicator/summary-request.ts +207 -0
  96. package/extensions/working-indicator/working-summary.test.ts +499 -0
  97. package/extensions/working-indicator/working-summary.ts +375 -0
  98. package/package.json +71 -0
  99. package/themes/ayu.json +97 -0
  100. package/themes/catppuccin.json +103 -0
  101. package/themes/summer-night.json +87 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * view.ts — 问卷的 TUI 外壳:按键解码 + 渲染。状态语义全在 model.ts(纯),
3
+ * 这里只做两件事:把 pi-tui 的按键序列映射成 Intent、把 state 画成行。
4
+ *
5
+ * 用 `ctx.ui.custom()` 装载(非 overlay):custom 组件临时接管编辑器区,
6
+ * 返回 `{ render, invalidate, handleInput }` 的普通对象即满足 pi-tui 的
7
+ * Component 结构(examples/extensions/question.ts 同款做法,不依赖 pi-tui
8
+ * 组件类)。自由输入行复用 pi-tui 的 Editor(多行、Shift+Enter 换行、
9
+ * 粘贴折叠),Enter 提交、Esc 退回选项列表。
10
+ *
11
+ * 本文件不进 `node --test`(真 TUI 无法无头构建,仓库约定);可单测的逻辑
12
+ * 都在 model.ts / validate.ts / answers.ts / dialog.ts。
13
+ */
14
+
15
+ import type { Theme } from "@earendil-works/pi-coding-agent";
16
+ import {
17
+ type Component,
18
+ Editor,
19
+ type EditorTheme,
20
+ Key,
21
+ matchesKey,
22
+ type TUI,
23
+ visibleWidth,
24
+ wrapTextWithAnsi,
25
+ } from "@earendil-works/pi-tui";
26
+ import { CUSTOM_ANSWER_LABEL, type AskParams, type AskResult } from "./types.ts";
27
+ import { formatAnswerScalar } from "./answers.ts";
28
+ import {
29
+ allAnswered,
30
+ createState,
31
+ hasSubmitTab,
32
+ type Intent,
33
+ isSubmitTab,
34
+ reduce,
35
+ unansweredHeaders,
36
+ } from "./model.ts";
37
+
38
+ /** 数字键 1-9 直接跳到对应行(含哨兵行)。 */
39
+ const DIGIT_KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] as const;
40
+
41
+ export function createQuestionnaireView(
42
+ tui: TUI,
43
+ theme: Theme,
44
+ params: AskParams,
45
+ done: (result: AskResult) => void,
46
+ ): Component {
47
+ const state = createState(params);
48
+ let cachedLines: string[] | undefined;
49
+
50
+ const editorTheme: EditorTheme = {
51
+ borderColor: (s) => theme.fg("accent", s),
52
+ selectList: {
53
+ selectedPrefix: (t) => theme.fg("accent", t),
54
+ selectedText: (t) => theme.fg("accent", t),
55
+ description: (t) => theme.fg("muted", t),
56
+ scrollInfo: (t) => theme.fg("dim", t),
57
+ noMatch: (t) => theme.fg("warning", t),
58
+ },
59
+ };
60
+ const editor = new Editor(tui, editorTheme);
61
+
62
+ function refresh(): void {
63
+ cachedLines = undefined;
64
+ tui.requestRender();
65
+ }
66
+
67
+ function dispatch(intent: Intent): void {
68
+ const outcome = reduce(state, intent);
69
+ switch (outcome.status) {
70
+ case "submit":
71
+ case "cancel":
72
+ done(outcome.result);
73
+ return;
74
+ case "enterInput": {
75
+ // 重答时把上次打的字放回编辑器(切走再切回来不丢草稿)。
76
+ const previous = state.answers[state.currentTab];
77
+ editor.setText(previous?.kind === "custom" ? (previous.answer ?? "") : "");
78
+ refresh();
79
+ return;
80
+ }
81
+ case "exitInput":
82
+ editor.setText("");
83
+ refresh();
84
+ return;
85
+ case "continue":
86
+ refresh();
87
+ return;
88
+ }
89
+ }
90
+
91
+ editor.onSubmit = (value) => dispatch({ type: "inputSubmit", text: value });
92
+
93
+ function handleInput(data: string): void {
94
+ if (state.inputMode) {
95
+ if (matchesKey(data, Key.escape)) {
96
+ dispatch({ type: "inputCancel" });
97
+ return;
98
+ }
99
+ editor.handleInput(data);
100
+ refresh();
101
+ return;
102
+ }
103
+
104
+ if (matchesKey(data, Key.up)) return dispatch({ type: "up" });
105
+ if (matchesKey(data, Key.down)) return dispatch({ type: "down" });
106
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) return dispatch({ type: "nextTab" });
107
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) return dispatch({ type: "prevTab" });
108
+ if (matchesKey(data, Key.space)) return dispatch({ type: "toggle" });
109
+ if (matchesKey(data, Key.enter)) return dispatch({ type: "confirm" });
110
+ if (matchesKey(data, Key.escape)) return dispatch({ type: "cancel" });
111
+ for (let i = 0; i < DIGIT_KEYS.length; i++) {
112
+ if (matchesKey(data, DIGIT_KEYS[i])) return dispatch({ type: "digit", digit: i + 1 });
113
+ }
114
+ }
115
+
116
+ function render(width: number): string[] {
117
+ if (cachedLines) return cachedLines;
118
+ const lines: string[] = [];
119
+ const renderWidth = Math.max(1, width);
120
+
121
+ const addWrapped = (text: string): void => {
122
+ lines.push(...wrapTextWithAnsi(text, renderWidth));
123
+ };
124
+ const addWrappedWithPrefix = (prefix: string, text: string): void => {
125
+ const prefixWidth = visibleWidth(prefix);
126
+ if (prefixWidth >= renderWidth) {
127
+ addWrapped(prefix + text);
128
+ return;
129
+ }
130
+ const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
131
+ const continuation = " ".repeat(prefixWidth);
132
+ for (let i = 0; i < wrapped.length; i++) {
133
+ lines.push(`${i === 0 ? prefix : continuation}${wrapped[i]}`);
134
+ }
135
+ };
136
+
137
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
138
+
139
+ if (hasSubmitTab(state)) {
140
+ addWrappedWithPrefix(" ", renderTabBar());
141
+ lines.push("");
142
+ }
143
+
144
+ if (isSubmitTab(state)) {
145
+ renderSubmitTab();
146
+ } else {
147
+ renderQuestionTab();
148
+ }
149
+
150
+ lines.push("");
151
+ addWrappedWithPrefix(" ", theme.fg("dim", helpLine()));
152
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
153
+
154
+ cachedLines = lines;
155
+ return lines;
156
+
157
+ function renderTabBar(): string {
158
+ const parts: string[] = [];
159
+ for (let i = 0; i < state.questions.length; i++) {
160
+ const q = state.questions[i];
161
+ const answered = state.answers[i] !== undefined;
162
+ const box = answered ? "■" : "□";
163
+ const label = ` ${box} ${q.header || `Q${i + 1}`} `;
164
+ const styled =
165
+ i === state.currentTab
166
+ ? theme.bg("selectedBg", theme.fg("text", label))
167
+ : theme.fg(answered ? "success" : "muted", label);
168
+ parts.push(styled);
169
+ }
170
+ const submitActive = isSubmitTab(state);
171
+ const submitLabel = " ✓ Submit ";
172
+ const submitStyled = submitActive
173
+ ? theme.bg("selectedBg", theme.fg("text", submitLabel))
174
+ : theme.fg(allAnswered(state) ? "success" : "dim", submitLabel);
175
+ parts.push(submitStyled);
176
+ return parts.join("");
177
+ }
178
+
179
+ function renderQuestionTab(): void {
180
+ const q = state.questions[state.currentTab];
181
+ if (!q) return;
182
+
183
+ for (const line of q.question.split("\n")) {
184
+ addWrappedWithPrefix(" ", theme.fg("text", line));
185
+ }
186
+ lines.push("");
187
+
188
+ for (let i = 0; i < q.options.length; i++) {
189
+ const opt = q.options[i];
190
+ const selected = i === state.cursor;
191
+ const prefix = selected ? theme.fg("accent", "> ") : " ";
192
+ const check = q.multiSelect ? `[${state.toggles[state.currentTab][i] ? "x" : " "}] ` : "";
193
+ const label = `${i + 1}. ${check}${opt.label}`;
194
+ addWrappedWithPrefix(prefix, theme.fg(selected ? "accent" : "text", label));
195
+ if (opt.description) {
196
+ addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
197
+ }
198
+ }
199
+
200
+ const sentinelSelected = state.cursor === q.options.length;
201
+ const sentinelPrefix = sentinelSelected ? theme.fg("accent", "> ") : " ";
202
+ const sentinel = `${q.options.length + 1}. ${CUSTOM_ANSWER_LABEL}${state.inputMode ? " ✎" : ""}`;
203
+ addWrappedWithPrefix(sentinelPrefix, theme.fg(sentinelSelected || state.inputMode ? "accent" : "dim", sentinel));
204
+
205
+ if (state.inputMode) {
206
+ lines.push("");
207
+ addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
208
+ for (const line of editor.render(Math.max(1, renderWidth - 2))) {
209
+ lines.push(` ${line}`);
210
+ }
211
+ }
212
+ }
213
+
214
+ function renderSubmitTab(): void {
215
+ addWrappedWithPrefix(" ", theme.fg("accent", theme.bold("Review your answers")));
216
+ lines.push("");
217
+ for (let i = 0; i < state.questions.length; i++) {
218
+ const q = state.questions[i];
219
+ const answer = state.answers[i];
220
+ const head = theme.fg("muted", `${q.header || `Q${i + 1}`}: `);
221
+ if (answer) {
222
+ const text = answer.kind === "custom" ? `✎ ${formatAnswerScalar(answer)}` : formatAnswerScalar(answer);
223
+ addWrappedWithPrefix(` ${theme.fg("success", "✓")} `, head + theme.fg("text", text));
224
+ } else {
225
+ addWrappedWithPrefix(` ${theme.fg("warning", "○")} `, head + theme.fg("dim", "(unanswered)"));
226
+ }
227
+ }
228
+ lines.push("");
229
+ if (allAnswered(state)) {
230
+ addWrappedWithPrefix(" ", theme.fg("success", "Press Enter to submit"));
231
+ } else {
232
+ addWrappedWithPrefix(
233
+ " ",
234
+ theme.fg("warning", `Unanswered: ${unansweredHeaders(state).join(", ")} — Tab back to answer`),
235
+ );
236
+ }
237
+ }
238
+
239
+ function helpLine(): string {
240
+ if (state.inputMode) return "Enter submit · Esc back";
241
+ if (isSubmitTab(state)) return "Enter submit · Tab back · Esc cancel";
242
+ const q = state.questions[state.currentTab];
243
+ const parts: string[] = [];
244
+ if (hasSubmitTab(state)) parts.push("Tab switch question");
245
+ parts.push("↑↓ move");
246
+ if (q?.multiSelect) parts.push("Space toggle");
247
+ parts.push(`1-${q ? q.options.length + 1 : 9} jump`);
248
+ parts.push("Enter confirm");
249
+ parts.push("Esc cancel");
250
+ return parts.join(" · ");
251
+ }
252
+ }
253
+
254
+ return {
255
+ render,
256
+ invalidate: () => {
257
+ cachedLines = undefined;
258
+ editor.invalidate();
259
+ },
260
+ handleInput,
261
+ };
262
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * auto-default-model 的验证:**不 mock pi**,而是用 pi 自己的扩展加载器(
3
+ * `discoverAndLoadExtensions`)真加载 `index.ts`,再直接调用它注册的 `model_select` handler,
4
+ * 最后检查假 agent 目录里的 `settings.json`。
5
+ *
6
+ * 这样测到的是真链路:扩展里 `import { SettingsManager } from "@earendil-works/pi-coding-agent"`
7
+ * 这句走的正是 pi 加载器给扩展准备的 alias / virtualModules(import 失败会让 pi 的 loader 报错、
8
+ * `extensions` 为空 —— 第一个用例就是在断言它),落盘走的也是真 `FileSettingsStorage`
9
+ * (proper-lockfile + 只合并改动字段的读改写),而不是测试自己拼的假对象。
10
+ *
11
+ * 需要本机装过 pi(先从 `pi` 可执行文件的 shim 反查真正安装位置,再退回
12
+ * `~/.pi/agent/npm/node_modules/`,或直接给 `PI_TEST_PI_ENTRY` 指路);找不到就整体 skip,
13
+ * 不假装通过。`~/.pi/agent/npm` 那份副本不能只看文件在不在:`pi update --extensions` 会把
14
+ * 自动装进去的 `@earendil-works/pi-*` 同伴包剪掉,副本会变成 import 就报错的空壳。
15
+ *
16
+ * node --test clients/pi/extensions/auto-default-model/default-model.test.ts
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import test from "node:test";
24
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
+
26
+ const EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
27
+
28
+ /**
29
+ * pi 的库入口(非 CLI):bundle 是 `pi` 实际跑的形态,dist 是 node 构建形态,两个都能加载扩展。
30
+ *
31
+ * 先从 `pi` 可执行文件反查**真正的安装位置**(pnpm/npm 的 shim 脚本里留有
32
+ * `# cmd-shim-target=<绝对路径>`;npm 在 Unix 上则是符号链接,两种都试),再退回
33
+ * `~/.pi/agent/npm` 那份副本 —— 后者是**扩展包的安装根**,`pi update --extensions` 会用
34
+ * `--config.auto-install-peers=false` 把自动装进去的 `@earendil-works/pi-*` 同伴包剪掉,
35
+ * 于是那份副本只剩空壳(文件都在、import 报 `ERR_MODULE_NOT_FOUND`;2026-09 升
36
+ * pi-subagents 0.68.0 时实测踩到)。
37
+ *
38
+ * 判定方式是**能不能真 import**,不是路径存不存在 —— 只有真 import 一次才分得清空壳。
39
+ * 全都不行就返回 undefined,调用方整体 skip(不假装通过)。
40
+ */
41
+ async function findPiLibraryEntry(): Promise<string | undefined> {
42
+ const candidates: string[] = [];
43
+ if (process.env.PI_TEST_PI_ENTRY) candidates.push(process.env.PI_TEST_PI_ENTRY);
44
+
45
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
46
+ if (!dir) continue;
47
+ const shimPath = path.join(dir, "pi");
48
+ try {
49
+ // npm 的 shim 是符号链接
50
+ const real = fs.realpathSync(shimPath);
51
+ if (real !== shimPath) candidates.push(path.join(path.dirname(real), "index.js"));
52
+ } catch {
53
+ // 不是符号链接 / 不存在:看下面的 shim 脚本
54
+ }
55
+ try {
56
+ const match = /^# cmd-shim-target=(.+)$/m.exec(fs.readFileSync(shimPath, "utf8"));
57
+ if (match?.[1]) candidates.push(path.join(path.dirname(match[1].trim()), "index.js"));
58
+ } catch {
59
+ // 读不到这个 shim:跳过
60
+ }
61
+ }
62
+
63
+ const packageDir = path.join(os.homedir(), ".pi/agent/npm/node_modules/@earendil-works/pi-coding-agent");
64
+ candidates.push(path.join(packageDir, "dist/bundle/index.js"), path.join(packageDir, "dist/index.js"));
65
+
66
+ for (const candidate of candidates) {
67
+ if (!fs.existsSync(candidate)) continue;
68
+ try {
69
+ await import(pathToFileURL(candidate).href);
70
+ return candidate;
71
+ } catch {
72
+ // 空壳副本:换下一个候选
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ const piEntry = await findPiLibraryEntry();
79
+ const skip = piEntry === undefined ? "找不到本机 pi 的库入口(装过 pi 才有)" : false;
80
+
81
+ type Handler = (event: unknown, ctx: unknown) => Promise<unknown> | unknown;
82
+ type Notify = { type: string | undefined; message: string };
83
+
84
+ /** 真加载扩展,返回它注册的 model_select handler。 */
85
+ async function loadModelSelectHandler(agentDir: string, projectDir: string): Promise<Handler> {
86
+ const pi = (await import(pathToFileURL(piEntry as string).href)) as {
87
+ discoverAndLoadExtensions: (
88
+ configuredPaths: string[],
89
+ cwd: string,
90
+ agentDir?: string,
91
+ ) => Promise<{ extensions: Array<{ handlers: Map<string, Handler[]> }>; errors: Array<{ path: string; error: string }> }>;
92
+ };
93
+ const loaded = await pi.discoverAndLoadExtensions([EXTENSION_PATH], projectDir, agentDir);
94
+ assert.deepEqual(loaded.errors, [], "pi 的扩展加载器不应该报错");
95
+ assert.equal(loaded.extensions.length, 1, "应该只加载到 auto-default-model 这一个扩展");
96
+ const handler = loaded.extensions[0]?.handlers.get("model_select")?.[0];
97
+ assert.ok(handler, "扩展应该注册了 model_select handler");
98
+ return handler;
99
+ }
100
+
101
+ function makeWorkspace(): { agentDir: string; projectDir: string; cleanup: () => void } {
102
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-auto-default-model-"));
103
+ const agentDir = path.join(root, "agent");
104
+ const projectDir = path.join(root, "project");
105
+ fs.mkdirSync(agentDir);
106
+ fs.mkdirSync(projectDir);
107
+ return {
108
+ agentDir,
109
+ projectDir,
110
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
111
+ };
112
+ }
113
+
114
+ function writeSettings(agentDir: string, settings: unknown, raw?: string): void {
115
+ fs.writeFileSync(path.join(agentDir, "settings.json"), raw ?? `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
116
+ }
117
+
118
+ function readSettings(agentDir: string): Record<string, unknown> {
119
+ return JSON.parse(fs.readFileSync(path.join(agentDir, "settings.json"), "utf-8"));
120
+ }
121
+
122
+ /** 驱动一次模型切换(默认 `/model` 选择器那条路径),返回 UI 收到的通知。 */
123
+ async function switchModel(
124
+ agentDir: string,
125
+ projectDir: string,
126
+ provider: string,
127
+ id: string,
128
+ options: { source?: string; hasUI?: boolean } = {},
129
+ ): Promise<{ notifications: Notify[]; fileExisted: boolean }> {
130
+ const handler = await loadModelSelectHandler(agentDir, projectDir);
131
+ const notifications: Notify[] = [];
132
+ const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
133
+ process.env.PI_CODING_AGENT_DIR = agentDir;
134
+ try {
135
+ await handler(
136
+ {
137
+ type: "model_select",
138
+ model: { provider, id },
139
+ previousModel: { provider: "litellm-any", id: "qwen3.8-df-qd-claude" },
140
+ source: options.source ?? "set",
141
+ },
142
+ {
143
+ cwd: projectDir,
144
+ hasUI: options.hasUI ?? false,
145
+ ui: {
146
+ notify: (message: string, type?: string) => {
147
+ notifications.push({ message, type });
148
+ },
149
+ },
150
+ },
151
+ );
152
+ } finally {
153
+ if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
154
+ else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
155
+ }
156
+ return { notifications, fileExisted: fs.existsSync(path.join(agentDir, "settings.json")) };
157
+ }
158
+
159
+ test("切模型后把 defaultProvider/defaultModel 写成启动默认,settings.json 其它字段不动", { skip }, async () => {
160
+ const workspace = makeWorkspace();
161
+ try {
162
+ writeSettings(workspace.agentDir, {
163
+ theme: "coffee",
164
+ defaultProvider: "litellm-any",
165
+ defaultModel: "qwen3.8-df-qd-claude",
166
+ markdown: { mermaid: "streaming" },
167
+ packages: ["npm:pi-subagents"],
168
+ });
169
+
170
+ const { notifications, fileExisted } = await switchModel(workspace.agentDir, workspace.projectDir, "litellm-any", "deepseek-flash");
171
+
172
+ assert.deepEqual(notifications, []);
173
+ assert.equal(fileExisted, true);
174
+ assert.deepEqual(readSettings(workspace.agentDir), {
175
+ theme: "coffee",
176
+ defaultProvider: "litellm-any",
177
+ defaultModel: "deepseek-flash",
178
+ markdown: { mermaid: "streaming" },
179
+ packages: ["npm:pi-subagents"],
180
+ });
181
+ } finally {
182
+ workspace.cleanup();
183
+ }
184
+ });
185
+
186
+ test("settings.json 还不存在时按需创建(新机器)", { skip }, async () => {
187
+ const workspace = makeWorkspace();
188
+ try {
189
+ assert.equal(fs.existsSync(path.join(workspace.agentDir, "settings.json")), false);
190
+
191
+ await switchModel(workspace.agentDir, workspace.projectDir, "litellm-any", "deepseek-flash");
192
+
193
+ assert.deepEqual(readSettings(workspace.agentDir), {
194
+ defaultProvider: "litellm-any",
195
+ defaultModel: "deepseek-flash",
196
+ });
197
+ } finally {
198
+ workspace.cleanup();
199
+ }
200
+ });
201
+
202
+ test("磁盘上已经是同一个模型就不写(不白动 settings.json)", { skip }, async () => {
203
+ const workspace = makeWorkspace();
204
+ try {
205
+ // 紧凑格式 + 无换行:pi 自己的写入会重排成 2 空格缩进,所以「字节不变」= 真的一次都没写。
206
+ const raw = '{"defaultProvider":"litellm-any","defaultModel":"deepseek-flash"}';
207
+ writeSettings(workspace.agentDir, undefined, raw);
208
+
209
+ await switchModel(workspace.agentDir, workspace.projectDir, "litellm-any", "deepseek-flash");
210
+
211
+ assert.equal(fs.readFileSync(path.join(workspace.agentDir, "settings.json"), "utf-8"), raw);
212
+ } finally {
213
+ workspace.cleanup();
214
+ }
215
+ });
216
+
217
+ test("source=restore(恢复旧会话带回的模型)不改全局默认", { skip }, async () => {
218
+ const workspace = makeWorkspace();
219
+ try {
220
+ const raw = '{"defaultProvider":"litellm-any","defaultModel":"qwen3.8-df-qd-claude"}';
221
+ writeSettings(workspace.agentDir, undefined, raw);
222
+
223
+ await switchModel(workspace.agentDir, workspace.projectDir, "litellm-any", "deepseek-flash", { source: "restore" });
224
+
225
+ assert.equal(fs.readFileSync(path.join(workspace.agentDir, "settings.json"), "utf-8"), raw);
226
+ } finally {
227
+ workspace.cleanup();
228
+ }
229
+ });
230
+
231
+ test("PI_AUTO_DEFAULT_MODEL=off 整体关闭", { skip }, async () => {
232
+ const workspace = makeWorkspace();
233
+ try {
234
+ const raw = '{"defaultProvider":"litellm-any","defaultModel":"qwen3.8-df-qd-claude"}';
235
+ writeSettings(workspace.agentDir, undefined, raw);
236
+ process.env.PI_AUTO_DEFAULT_MODEL = "off";
237
+ try {
238
+ await switchModel(workspace.agentDir, workspace.projectDir, "litellm-any", "deepseek-flash");
239
+ } finally {
240
+ delete process.env.PI_AUTO_DEFAULT_MODEL;
241
+ }
242
+
243
+ assert.equal(fs.readFileSync(path.join(workspace.agentDir, "settings.json"), "utf-8"), raw);
244
+ } finally {
245
+ workspace.cleanup();
246
+ }
247
+ });
248
+
249
+ test("settings.json 坏掉时不当成写成功(notify 警告,文件保持原样)", { skip }, async () => {
250
+ const workspace = makeWorkspace();
251
+ try {
252
+ const raw = "{ this is not json }";
253
+ writeSettings(workspace.agentDir, undefined, raw);
254
+
255
+ const { notifications } = await switchModel(workspace.agentDir, workspace.projectDir, "litellm-any", "deepseek-flash", {
256
+ hasUI: true,
257
+ });
258
+
259
+ assert.equal(notifications.length, 1);
260
+ assert.equal(notifications[0]?.type, "warning");
261
+ // 警告里要带上是哪个模型、以及 pi 记下的真实错误(解析失败原文),否则用户无从下手。
262
+ assert.match(notifications[0]?.message ?? "", /deepseek-flash/);
263
+ assert.match(notifications[0]?.message ?? "", /启动默认模型/);
264
+ assert.equal(fs.readFileSync(path.join(workspace.agentDir, "settings.json"), "utf-8"), raw);
265
+ } finally {
266
+ workspace.cleanup();
267
+ }
268
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * auto-default-model — 换模型时顺手把它写成「启动默认模型」,省掉那次 Ctrl+S。
3
+ *
4
+ * 为什么需要它:pi 的 `/model` 选择器**只改当前会话**的模型,想让下次启动停在某个模型上,
5
+ * 必须额外按一次 Ctrl+S(`app.models.save` → `setModel(model, {persist: true})` →
6
+ * `settingsManager.setDefaultModelAndProvider`,写 `~/.pi/agent/settings.json` 的
7
+ * `defaultProvider` / `defaultModel`)。这是上游刻意的行为(CHANGELOG:「Fixed `/model` and
8
+ * `/thinking` selections being persisted globally unless explicitly saved with Ctrl+S」),
9
+ * 但对单人机器是多余的仪式:换完模型常常忘了按 Ctrl+S,下次启动又回到旧模型。
10
+ * 本扩展把这一步自动化 —— 任何一次模型切换(`/model` 选择器、Ctrl+P 循环、
11
+ * `pi-subagents` 的 profile 切换这类 `pi.setModel()` 调用)都立刻落盘。
12
+ *
13
+ * 落盘走的是 **pi 自己的公开 API**(`SettingsManager`,从 `@earendil-works/pi-coding-agent`
14
+ * 导入,加载器对这个 specifier 做了 alias / virtualModules 映射,所以扩展里 on-demand import 它
15
+ 就是 pi 自己那份实现),也就是和 Ctrl+S 完全相同的那条持久化路径:
16
+ * `FileSettingsStorage` + proper-lockfile 文件锁 + 「只把本次改动的字段合并进磁盘上最新内容」的
17
+ * 读改写。所以①settings.json 里的其它字段(包括本扩展不认识的未来字段)不会被覆盖,
18
+ * ②和 pi 自己的写入共用同一把锁,不会互相丢更新。**刻意不自己 `fs.writeFile`**:那样就会绕过
19
+ * 那把锁,在用户同时改设置(`/theme`、`/settings`)时丢掉对方刚写进去的内容。
20
+ *
21
+ * 三个刻意的取舍:
22
+ * - `source === "restore"` **不落盘**。恢复会话时 pi 会把模型带回该会话自己的那条,
23
+ * 那是「打开旧会话」而不是「我换了个模型」,让它改写全局默认会很意外。
24
+ * (pi 0.85.1 只在 `setModel()` / `cycleModel()` 里发 `model_select`,恢复走的是直接设
25
+ * `agent.state.model`,所以当前版本这个分支根本不会触发;写在这里是为了将来 pi 把
26
+ * `restore` 接上以后,语义仍然明确。)
27
+ * - 值没变就不写。选中同一个模型、或 pi 自己已经 persist 过一次(Ctrl+S、`/login` 后自动选
28
+ * 该 provider 的默认模型)时 `model_select` 也会触发,重复写只会白白动 settings.json。
29
+ * - 只在失败时提示(`ctx.ui.notify`),成功不打扰:当前模型名在 statusline 上本来就看得见,
30
+ * 换模型时再弹一条通知是噪音。
31
+ *
32
+ * 关掉:`PI_AUTO_DEFAULT_MODEL=off`(每次切换时读,不缓存)。
33
+ */
34
+
35
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
36
+ import type { ExtensionAPI, ExtensionContext, ModelSelectEvent } from "@earendil-works/pi-coding-agent";
37
+
38
+ type PersistOutcome = "written" | "unchanged" | "failed";
39
+ type PersistResult = { outcome: PersistOutcome; detail?: string };
40
+
41
+ function isDisabled(): boolean {
42
+ return (process.env.PI_AUTO_DEFAULT_MODEL ?? "").trim().toLowerCase() === "off";
43
+ }
44
+
45
+ function modelLabel(event: ModelSelectEvent): string {
46
+ return `${event.model.provider}/${event.model.id}`;
47
+ }
48
+
49
+ function notify(ctx: ExtensionContext, message: string): void {
50
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
51
+ }
52
+
53
+ /**
54
+ * 把 `provider/modelId` 写成全局启动默认模型。返回 "unchanged" 表示磁盘上已经是它。
55
+ */
56
+ async function persistAsStartupDefault(cwd: string, provider: string, modelId: string): Promise<PersistResult> {
57
+ const settings = SettingsManager.create(cwd);
58
+ // 比的是**全局**设置而不是合并后的值:项目级 `.pi/settings.json` 里的 defaultProvider /
59
+ // defaultModel 会盖过全局值(那条启动路径本扩展也管不了),拿合并值去比会误判成「已经写过了」。
60
+ const global = settings.getGlobalSettings();
61
+ if (global.defaultProvider === provider && global.defaultModel === modelId) {
62
+ return { outcome: "unchanged" };
63
+ }
64
+ settings.setDefaultModelAndProvider(provider, modelId);
65
+ await settings.flush();
66
+ // settings.json 解析失败 / 写不进去时 `save()` 是**静默跳过**的(`globalSettingsLoadError`
67
+ // 直接 return,`enqueueWrite` 的 catch 只记错误),`flush()` 也不抛 —— 结果只能从
68
+ // `drainErrors()` 里读,否则扩展会假装写成功了。错误原文一并带出去,警告才有诊断价值。
69
+ const failure = settings.drainErrors()[0];
70
+ if (failure) return { outcome: "failed", detail: failure.error.message };
71
+ return { outcome: "written" };
72
+ }
73
+
74
+ export default function (pi: ExtensionAPI) {
75
+ pi.on("model_select", async (event, ctx) => {
76
+ if (isDisabled()) return;
77
+ if (event.source === "restore") return;
78
+ try {
79
+ const result = await persistAsStartupDefault(ctx.cwd, event.model.provider, event.model.id);
80
+ if (result.outcome === "failed") {
81
+ notify(ctx, `${modelLabel(event)} 没能写成启动默认模型:${result.detail ?? "settings.json 读取或写入失败"}`);
82
+ }
83
+ } catch (error) {
84
+ notify(ctx, `写启动默认模型失败:${error instanceof Error ? error.message : String(error)}`);
85
+ }
86
+ });
87
+ }