pi-archimedes 2.10.2 → 2.10.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-archimedes",
3
- "version": "2.10.2",
3
+ "version": "2.10.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -16,18 +16,18 @@
16
16
  ],
17
17
  "main": "./src/index.ts",
18
18
  "dependencies": {
19
- "@pi-archimedes/core": "2.10.2",
20
- "@pi-archimedes/footer": "2.10.2",
21
- "@pi-archimedes/diff": "2.10.2",
22
- "@pi-archimedes/ask": "2.10.2",
23
- "@pi-archimedes/image-paste": "2.10.2",
24
- "@pi-archimedes/subagent": "2.10.2",
25
- "@pi-archimedes/todo": "2.10.2",
26
- "@pi-archimedes/notify": "2.10.2",
27
- "@pi-archimedes/session-name": "2.10.2",
28
- "@pi-archimedes/sudo": "2.10.2",
29
- "@pi-archimedes/ui": "2.10.2",
30
- "@pi-archimedes/web": "2.10.2"
19
+ "@pi-archimedes/core": "2.10.4",
20
+ "@pi-archimedes/footer": "2.10.4",
21
+ "@pi-archimedes/diff": "2.10.4",
22
+ "@pi-archimedes/ask": "2.10.4",
23
+ "@pi-archimedes/image-paste": "2.10.4",
24
+ "@pi-archimedes/subagent": "2.10.4",
25
+ "@pi-archimedes/todo": "2.10.4",
26
+ "@pi-archimedes/notify": "2.10.4",
27
+ "@pi-archimedes/session-name": "2.10.4",
28
+ "@pi-archimedes/sudo": "2.10.4",
29
+ "@pi-archimedes/ui": "2.10.4",
30
+ "@pi-archimedes/web": "2.10.4"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ // ── Panel wiring guard ──────────────────────────────────────────────────────
5
+ //
6
+ // A SettingItem is only changeable in the /archimedes overlay if it either has
7
+ // `values` (←/→ cycles them) or is registered in PROMPTS (Enter opens a
8
+ // free-text/number field). An item with neither renders as a read-only row:
9
+ // the cursor lands on it, every key does nothing, and the user cannot tell the
10
+ // control is inert. `sessionNameModel` shipped that way — it even has a
11
+ // working `onChange` case waiting for a value it could never receive.
12
+ //
13
+ // The second assertion guards the other half of the same class of bug: an item
14
+ // whose id has no `case` in the onChange switch. Such a row can be changed on
15
+ // screen but the edit is never written to settings.json — the same shape as the
16
+ // dead reasoning row reported on #81, caught before it ships again.
17
+
18
+ vi.mock("@pi-archimedes/core/settings-io", () => {
19
+ const store: Record<string, unknown> = {};
20
+ return {
21
+ loadConfig: vi.fn(
22
+ (ns: string, defaults: object) =>
23
+ ({ ...defaults, ...((store[ns] as object) ?? {}) }),
24
+ ),
25
+ saveConfig: vi.fn((ns: string, config: object) => {
26
+ store[ns] = config;
27
+ }),
28
+ removeConfig: vi.fn((ns: string) => {
29
+ delete store[ns];
30
+ }),
31
+ isConfigEnabled: vi.fn(() => true),
32
+ setConfigEnabled: vi.fn(),
33
+ __store: store,
34
+ };
35
+ });
36
+
37
+ import { buildSettingsItems, openSettings, PROMPTS } from "./settings.js";
38
+ import { loadAllConfig } from "./config.js";
39
+
40
+ // Capture what openSettings hands the manager so the onChange switch can be
41
+ // driven directly, and what lands in settings.json after onSave.
42
+ const managerCalls: Array<{
43
+ onChange: (id: string, value: string) => void;
44
+ onSave: () => void;
45
+ items: Array<{ id: string; currentValue: string }>;
46
+ }> = [];
47
+
48
+ vi.mock("./settings-manager.js", async (importOriginal) => ({
49
+ ...(await importOriginal as object),
50
+ createSettingsManager: vi.fn((opts: any) => {
51
+ managerCalls.push({ onChange: opts.onChange, onSave: opts.onSave, items: opts.items });
52
+ return { render: () => [], handleInput: () => {}, invalidate: () => {}, dispose: () => {} };
53
+ }),
54
+ }));
55
+
56
+ const store = (vi.mocked(await import("@pi-archimedes/core/settings-io")) as any).__store;
57
+
58
+ // The mocked settings-io store is module-level, so every test in this file
59
+ // shares it: without this reset a test that saves a model leaks it into
60
+ // whichever test runs next, making the suite order-dependent.
61
+ beforeEach(() => {
62
+ for (const key of Object.keys(store)) delete store[key];
63
+ managerCalls.length = 0;
64
+ });
65
+
66
+ const SOURCE = readFileSync(new URL("./settings.ts", import.meta.url), "utf8");
67
+
68
+ describe("/archimedes settings panel wiring", () => {
69
+ it("every item is editable in the panel (has values or a prompt)", async () => {
70
+ const items = await buildSettingsItems(loadAllConfig());
71
+ expect(items.length).toBeGreaterThan(0);
72
+
73
+ const inert = items
74
+ .filter((item) => item.values === undefined && !(item.id in PROMPTS))
75
+ .map((item) => item.id);
76
+
77
+ // e.g. ["sessionNameModel"] — a row that renders but swallows every key.
78
+ expect(inert).toEqual([]);
79
+ });
80
+
81
+ it("every item persists through onChange", async () => {
82
+ const items = await buildSettingsItems(loadAllConfig());
83
+
84
+ const unhandled = items
85
+ .filter((item) => !SOURCE.includes(`case "${item.id}":`))
86
+ .map((item) => item.id);
87
+
88
+ expect(unhandled).toEqual([]);
89
+ });
90
+
91
+ it("every prompt has a matching label", () => {
92
+ for (const [id, descriptor] of Object.entries(PROMPTS)) {
93
+ expect(descriptor.label, id).not.toBe("");
94
+ expect(["text", "number"]).toContain(descriptor.kind);
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("sessionNameModel editing", () => {
100
+ async function openPanel() {
101
+ managerCalls.length = 0;
102
+ const custom = vi.fn((factory: any) => factory({}, {}, {}, () => {}));
103
+ // openSettings awaits buildSettingsItems (diff is lazy-imported) before
104
+ // it reaches ctx.ui.custom, so this must be awaited, not fired and forgotten.
105
+ await openSettings({} as any, { ui: { custom } } as any);
106
+ expect(custom).toHaveBeenCalledTimes(1);
107
+ const panel = managerCalls.at(-1);
108
+ if (!panel) throw new Error("openSettings never built a settings manager");
109
+ return panel;
110
+ }
111
+
112
+ function savedModel() {
113
+ return (store["archimedes.sessionName"] as any)?.model;
114
+ }
115
+
116
+ it("stores a typed model reference", async () => {
117
+ const panel = await openPanel();
118
+ panel.onChange("sessionNameModel", "openai/gpt-4o-mini");
119
+ panel.onSave();
120
+ expect(savedModel()).toBe("openai/gpt-4o-mini");
121
+ });
122
+
123
+ it("treats a blank field as 'use the current model' and leaves the key unset", async () => {
124
+ const panel = await openPanel();
125
+ panel.onChange("sessionNameModel", " ");
126
+ panel.onSave();
127
+ expect(savedModel()).toBeUndefined();
128
+ expect(JSON.stringify(store["archimedes.sessionName"])).not.toContain('"model"');
129
+ });
130
+
131
+ it("treats the displayed placeholder the same way", async () => {
132
+ const panel = await openPanel();
133
+ panel.onChange("sessionNameModel", "(current model)");
134
+ panel.onSave();
135
+ expect(savedModel()).toBeUndefined();
136
+ });
137
+
138
+ it("seeds the prompt with an empty field, not the '(current model)' placeholder", async () => {
139
+ // Enter seeds from item.currentValue; leaving the placeholder there would
140
+ // prepend it to whatever the user types (the delayMs pitfall).
141
+ const panel = await openPanel();
142
+ const item = panel.items.find((i) => i.id === "sessionNameModel");
143
+ expect(item?.currentValue).toBe("");
144
+ });
145
+
146
+ it("seeds the prompt with the configured model when one is set", async () => {
147
+ store["archimedes.sessionName"] = { model: "openai/gpt-4o-mini" };
148
+ const panel = await openPanel();
149
+ const item = panel.items.find((i) => i.id === "sessionNameModel");
150
+ expect(item?.currentValue).toBe("openai/gpt-4o-mini");
151
+ });
152
+
153
+ it("trims whitespace around a reference", async () => {
154
+ const panel = await openPanel();
155
+ panel.onChange("sessionNameModel", " anthropic/claude ");
156
+ panel.onSave();
157
+ expect(savedModel()).toBe("anthropic/claude");
158
+ });
159
+ });
package/src/settings.ts CHANGED
@@ -19,7 +19,8 @@ import { createSettingsManager, type PromptDescriptor } from "./settings-manager
19
19
 
20
20
  // ── Free-input prompt descriptors (keyed by item.id) ───────────────────────
21
21
 
22
- const PROMPTS: Record<string, PromptDescriptor> = {
22
+ // Exported for the panel-wiring test: every item must be reachable through PROMPTS or values.
23
+ export const PROMPTS: Record<string, PromptDescriptor> = {
23
24
  labelText: { kind: "text", label: "Label text" },
24
25
  editorSpinLabel: { kind: "text", label: "Spinner label" },
25
26
  labelColor: { kind: "text", label: "RGB color (e.g. 255,215,0)" },
@@ -29,6 +30,11 @@ const PROMPTS: Record<string, PromptDescriptor> = {
29
30
  splitThreshold: { kind: "number", label: "Footer split threshold", min: 80 },
30
31
  branchMaxLength: { kind: "number", label: "Branch max length", min: 0 },
31
32
  delayMs: { kind: "number", label: "Notify delay (seconds)", min: 1 },
33
+ // A model reference, so free text: the catalogue is only known at runtime and
34
+ // resolveModel() tolerates canonical provider/id, bare id, and thinking-suffix
35
+ // forms. Without this entry the row renders but swallows every key — inert
36
+ // ever since a2bd4d6 added it alongside an onChange case that could never fire.
37
+ sessionNameModel: { kind: "text", label: "Model for naming (blank = current model)" },
32
38
  };
33
39
 
34
40
  // ── Settings UI ─────────────────────────────────────────────────────────────
@@ -84,6 +90,14 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
84
90
 
85
91
  const items = await buildSettingsItems(allConfig);
86
92
 
93
+ // The row displays "(current model)" when unset, but Enter seeds the prompt
94
+ // from item.currentValue, so a typed reference would be appended to the
95
+ // placeholder — the same pitfall buildSettingsItems works around for delayMs.
96
+ // Show an empty field instead, which is what the row's own description and
97
+ // PROMPTS label both promise ("blank = current model").
98
+ const modelItem = items.find((i) => i.id === "sessionNameModel");
99
+ if (modelItem && modelItem.currentValue === "(current model)") modelItem.currentValue = "";
100
+
87
101
  ctx.ui.custom((_tui, theme, _keybindings, done) => {
88
102
  const settingsManager = createSettingsManager({
89
103
  items,
@@ -144,7 +158,13 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
144
158
  }
145
159
 
146
160
  // ── Session name settings ──
147
- case "sessionNameModel": sessionNameConfig.model = newValue === "(current model)" ? undefined : newValue; break;
161
+ // Blank and the displayed placeholder both mean "use the current
162
+ // model", and are stored as unset rather than "" so the key disappears.
163
+ case "sessionNameModel": {
164
+ const ref = newValue.trim();
165
+ sessionNameConfig.model = !ref || ref === "(current model)" ? undefined : ref;
166
+ break;
167
+ }
148
168
  }
149
169
  },
150
170
  onSave: () => {