@pi-archimedes/core 2.3.0 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -27,8 +27,8 @@
27
27
  "@earendil-works/pi-tui": ">=0.1.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@earendil-works/pi-coding-agent": "^0.84.2",
31
- "@earendil-works/pi-tui": "^0.84.2",
30
+ "@earendil-works/pi-coding-agent": "^0.84.4",
31
+ "@earendil-works/pi-tui": "^0.84.4",
32
32
  "typescript": "^6.0.0"
33
33
  },
34
34
  "pi": {
package/src/bus.ts CHANGED
@@ -108,7 +108,7 @@ export const Events = {
108
108
 
109
109
  interface TodoUpdatePayload {
110
110
  source: string; // "main" or "subagent:<agent-name>"
111
- todos: Array<{ id: number; title: string; description: string; status: "not-started" | "in-progress" | "completed" }>;
111
+ todos: Array<{ content: string; description?: string; status: "pending" | "in_progress" | "completed" }>;
112
112
  }
113
113
 
114
114
  interface TodoClearPayload {
package/src/index.ts CHANGED
@@ -169,12 +169,15 @@ export function registerCore(pi: ExtensionAPI): void {
169
169
  });
170
170
  });
171
171
 
172
- // Patch thinking renderer
173
- patchThinkingRenderer(() => ctx.ui.theme);
174
-
175
- // Load config for thinking transformation
172
+ // Load config for thinking transformation + label overrides
176
173
  const config = loadCoreConfig();
177
174
 
175
+ // Patch thinking renderer
176
+ patchThinkingRenderer(() => ctx.ui.theme, {
177
+ labelText: config.labelText,
178
+ labelColor: config.labelColor,
179
+ });
180
+
178
181
  // Register events
179
182
  pi.on("message_end", (event, _ctx) => {
180
183
  // Transform thinking content (unindent code blocks if enabled)
@@ -17,7 +17,148 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
17
17
  }));
18
18
 
19
19
  // Import after mocks are set up
20
- const { loadConfig, saveConfig } = await import("./settings-io.js");
20
+ const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled } = await import("./settings-io.js");
21
+
22
+ describe("removeConfig", () => {
23
+ beforeEach(() => {
24
+ const settingsPath = join(tempDir, "settings.json");
25
+ if (fs.existsSync(settingsPath)) {
26
+ fs.unlinkSync(settingsPath);
27
+ }
28
+ });
29
+
30
+ afterEach(() => {
31
+ const settingsPath = join(tempDir, "settings.json");
32
+ const tmpPath = settingsPath + ".tmp";
33
+ try { fs.unlinkSync(settingsPath); } catch { /* ignore */ }
34
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
35
+ });
36
+
37
+ it("deletes an existing namespace key and leaves sibling keys intact", () => {
38
+ const settingsPath = join(tempDir, "settings.json");
39
+ fs.writeFileSync(
40
+ settingsPath,
41
+ JSON.stringify({ "test.ns": { foo: "bar" }, "other.ns": { baz: 1 }, "third.ns": { qux: "y" } }),
42
+ "utf-8",
43
+ );
44
+ removeConfig("test.ns");
45
+ const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
46
+ expect(data["test.ns"]).toBeUndefined();
47
+ expect(data["other.ns"]).toEqual({ baz: 1 });
48
+ expect(data["third.ns"]).toEqual({ qux: "y" });
49
+ expect(fs.existsSync(settingsPath + ".tmp")).toBe(false);
50
+ });
51
+
52
+ it("is a no-op when the key is absent (no rewrite)", () => {
53
+ const settingsPath = join(tempDir, "settings.json");
54
+ fs.writeFileSync(
55
+ settingsPath,
56
+ JSON.stringify({ "other.ns": { baz: 1 } }),
57
+ "utf-8",
58
+ );
59
+ const beforeMtimeMs = fs.statSync(settingsPath).mtimeMs;
60
+ removeConfig("test.ns");
61
+ const afterMtimeMs = fs.statSync(settingsPath).mtimeMs;
62
+ expect(afterMtimeMs).toBe(beforeMtimeMs);
63
+ const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
64
+ expect(data).toEqual({ "other.ns": { baz: 1 } });
65
+ });
66
+
67
+ it("does not create the file when settings.json does not exist", () => {
68
+ const settingsPath = join(tempDir, "settings.json");
69
+ expect(fs.existsSync(settingsPath)).toBe(false);
70
+ removeConfig("test.ns");
71
+ expect(fs.existsSync(settingsPath)).toBe(false);
72
+ expect(fs.existsSync(settingsPath + ".tmp")).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe("isConfigEnabled", () => {
77
+ const settingsPath = () => join(tempDir, "settings.json");
78
+
79
+ beforeEach(() => {
80
+ if (fs.existsSync(settingsPath())) {
81
+ fs.unlinkSync(settingsPath());
82
+ }
83
+ });
84
+
85
+ afterEach(() => {
86
+ const path = settingsPath();
87
+ const tmpPath = path + ".tmp";
88
+ try { fs.unlinkSync(path); } catch { /* ignore */ }
89
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
90
+ });
91
+
92
+ it("missing file/namespace → true", () => {
93
+ expect(isConfigEnabled("test.ns")).toBe(true);
94
+ });
95
+
96
+ it("empty namespace object → true", () => {
97
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": {} }), "utf-8");
98
+ expect(isConfigEnabled("test.ns")).toBe(true);
99
+ });
100
+
101
+ it("{ enabled: true } → true", () => {
102
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: true } }), "utf-8");
103
+ expect(isConfigEnabled("test.ns")).toBe(true);
104
+ });
105
+
106
+ it("{ enabled: false } → false", () => {
107
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: false } }), "utf-8");
108
+ expect(isConfigEnabled("test.ns")).toBe(false);
109
+ });
110
+
111
+ it("{ enabled: \"false\" } → true (strict === false check)", () => {
112
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: "false" } }), "utf-8");
113
+ expect(isConfigEnabled("test.ns")).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("setConfigEnabled", () => {
118
+ const settingsPath = () => join(tempDir, "settings.json");
119
+
120
+ beforeEach(() => {
121
+ if (fs.existsSync(settingsPath())) {
122
+ fs.unlinkSync(settingsPath());
123
+ }
124
+ });
125
+
126
+ afterEach(() => {
127
+ const path = settingsPath();
128
+ const tmpPath = path + ".tmp";
129
+ try { fs.unlinkSync(path); } catch { /* ignore */ }
130
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
131
+ });
132
+
133
+ it("set to false adds enabled:false, keeping other keys and sibling namespaces", () => {
134
+ fs.writeFileSync(
135
+ settingsPath(),
136
+ JSON.stringify({ "test.ns": { foo: "bar", count: 42 }, "other.ns": { baz: 1 } }),
137
+ "utf-8",
138
+ );
139
+ setConfigEnabled("test.ns", false);
140
+ const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
141
+ expect(data["test.ns"]).toEqual({ foo: "bar", count: 42, enabled: false });
142
+ expect(data["other.ns"]).toEqual({ baz: 1 });
143
+ expect(isConfigEnabled("test.ns")).toBe(false);
144
+ });
145
+
146
+ it("set to true on { enabled: false } removes the namespace entirely (zero keys)", () => {
147
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: false } }), "utf-8");
148
+ setConfigEnabled("test.ns", true);
149
+ const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
150
+ expect(data["test.ns"]).toBeUndefined();
151
+ expect(isConfigEnabled("test.ns")).toBe(true);
152
+ });
153
+
154
+ it("set to true on { enabled: false, other: 1 } leaves the namespace as { other: 1 }", () => {
155
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: false, other: 1 } }), "utf-8");
156
+ setConfigEnabled("test.ns", true);
157
+ const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
158
+ expect(data["test.ns"]).toEqual({ other: 1 });
159
+ expect(isConfigEnabled("test.ns")).toBe(true);
160
+ });
161
+ });
21
162
 
22
163
  describe("loadConfig", () => {
23
164
  beforeEach(() => {
@@ -40,3 +40,60 @@ export function saveConfig(namespace: string, config: object): void {
40
40
  writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
41
41
  }
42
42
  }
43
+
44
+ /**
45
+ * Delete a namespace section from settings.json entirely.
46
+ * No-op if the key is absent (including when the file doesn't exist — the file
47
+ * is never created). Atomic write, same pattern as saveConfig.
48
+ */
49
+ export function removeConfig(namespace: string): void {
50
+ const full = readSettings();
51
+ // No file at all (readSettings returns {} for missing AND for corrupt,
52
+ // but only for missing is existsSync false) and no key → never create the file.
53
+ if (!existsSync(SETTINGS_PATH)) return;
54
+ if (!(namespace in full)) return;
55
+ delete full[namespace];
56
+ const tmpPath = SETTINGS_PATH + ".tmp";
57
+ writeFileSync(tmpPath, JSON.stringify(full, null, 2), "utf-8");
58
+ try {
59
+ renameSync(tmpPath, SETTINGS_PATH);
60
+ } catch {
61
+ try { unlinkSync(tmpPath); } catch { /* ignore */ }
62
+ writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
63
+ }
64
+ }
65
+
66
+ /**
67
+ * True unless settings[namespace].enabled === false (strict comparison — a
68
+ * string "false" or 0 does NOT disable). Missing file/namespace/key ⇒ true.
69
+ */
70
+ export function isConfigEnabled(namespace: string): boolean {
71
+ const cfg = loadConfig(namespace, {}) as { enabled?: boolean };
72
+ return cfg.enabled !== false;
73
+ }
74
+
75
+ /**
76
+ * Turn a namespace's `enabled` gate on or off without touching the
77
+ * namespace's other keys or sibling namespaces.
78
+ *
79
+ * off: write settings[ns].enabled = false (merged — other keys survive).
80
+ * on: delete the `enabled` key; if that leaves the namespace with zero keys,
81
+ * remove the whole namespace key from the file.
82
+ *
83
+ * A namespace omitted from settings.json counts as enabled.
84
+ */
85
+ export function setConfigEnabled(namespace: string, enabled: boolean): void {
86
+ if (enabled) {
87
+ const cfg = loadConfig(namespace, {}) as Record<string, unknown>;
88
+ delete cfg.enabled;
89
+ if (Object.keys(cfg).length === 0) {
90
+ removeConfig(namespace);
91
+ } else {
92
+ saveConfig(namespace, cfg);
93
+ }
94
+ } else {
95
+ const cfg = loadConfig(namespace, {}) as Record<string, unknown>;
96
+ cfg.enabled = false;
97
+ saveConfig(namespace, cfg);
98
+ }
99
+ }
@@ -272,4 +272,118 @@ describe("patchThinkingRenderer", () => {
272
272
  // The patched function must be a new closure (not the same reference)
273
273
  expect(MockClass.prototype.updateContent).not.toBe(first);
274
274
  });
275
+
276
+ // ── Configurable thinking label (issue #36) ────────────────────────────
277
+
278
+ // Patches with a valid AssistantMessageComponent + mocked pi-tui so the
279
+ // Markdown content rendered for a thinking block can be inspected.
280
+ async function patchAndRender(
281
+ config?: { labelText?: string; labelColor?: string },
282
+ thinkingText = "Let me consider this carefully.",
283
+ ) {
284
+ const MockClass = function AssistantMessageComponent() {};
285
+ MockClass.prototype.updateContent = function updateContent() {
286
+ if (this.content.type === "thinking") {
287
+ this.markdownTheme.codeBlockIndent = "";
288
+ }
289
+ };
290
+
291
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
292
+ AssistantMessageComponent: MockClass,
293
+ VERSION: "1.0.0",
294
+ highlightCode: vi.fn(),
295
+ }));
296
+
297
+ const capturedMarkdown: any[] = [];
298
+ class MockMarkdown {
299
+ content: string;
300
+ constructor(content: string, ..._rest: any[]) {
301
+ this.content = content;
302
+ capturedMarkdown.push(this);
303
+ }
304
+ }
305
+ class MockSpacer {}
306
+ class MockText {}
307
+ vi.doMock("@earendil-works/pi-tui", () => ({
308
+ Markdown: MockMarkdown,
309
+ Spacer: MockSpacer,
310
+ Text: MockText,
311
+ }));
312
+
313
+ vi.resetModules();
314
+ const mod = await import("./patch.js");
315
+ mod.patchThinkingRenderer(
316
+ () => ({ getFgAnsi: () => "", fg: (_t: string, text: string) => text }) as any,
317
+ config,
318
+ );
319
+
320
+ const instance = {
321
+ contentContainer: { clear: vi.fn(), addChild: vi.fn() },
322
+ isStreaming: false,
323
+ markdownTheme: { codeBlockIndent: "" },
324
+ markdownTransformers: [],
325
+ hideThinkingBlock: false,
326
+ outputPad: 1,
327
+ };
328
+ const message = {
329
+ content: [{ type: "thinking", thinking: thinkingText }],
330
+ stopReason: undefined,
331
+ };
332
+ MockClass.prototype.updateContent.call(instance, message, false);
333
+ return capturedMarkdown;
334
+ }
335
+
336
+ it("uses configured labelText/labelColor for the thinking label", async () => {
337
+ const captured = await patchAndRender({
338
+ labelText: "Yapping...",
339
+ labelColor: "255,215,0",
340
+ });
341
+
342
+ expect(captured).toHaveLength(1);
343
+ expect(
344
+ captured[0]!.content.startsWith("\x1b[1m\x1b[38;2;255;215;0mYapping...\x1b[39m\x1b[22m\n\n"),
345
+ ).toBe(true);
346
+ });
347
+
348
+ it("defaults to the original Thinking... label when no config is given", async () => {
349
+ const captured = await patchAndRender();
350
+
351
+ expect(captured).toHaveLength(1);
352
+ // Byte-identical to the previous hardcoded THINKING_LABEL
353
+ expect(
354
+ captured[0]!.content.startsWith("\x1b[1m\x1b[38;2;255;215;0mThinking...\x1b[39m\x1b[22m\n\n"),
355
+ ).toBe(true);
356
+ });
357
+
358
+ it("trims whitespace from configured labelText and labelColor", async () => {
359
+ const captured = await patchAndRender({
360
+ labelText: " Yapping... ",
361
+ labelColor: " 255, 215, 0 ",
362
+ });
363
+
364
+ expect(
365
+ captured[0]!.content.startsWith("\x1b[1m\x1b[38;2;255;215;0mYapping...\x1b[39m\x1b[22m\n\n"),
366
+ ).toBe(true);
367
+ });
368
+
369
+ it("falls back to 255,215,0 when labelColor is not a valid RGB triple", async () => {
370
+ const captured = await patchAndRender({
371
+ labelText: "Hmm",
372
+ labelColor: "not-a-color",
373
+ });
374
+
375
+ expect(
376
+ captured[0]!.content.startsWith("\x1b[1m\x1b[38;2;255;215;0mHmm\x1b[39m\x1b[22m\n\n"),
377
+ ).toBe(true);
378
+ });
379
+
380
+ it("does not double-prepend the label when content already starts with it", async () => {
381
+ const label = "\x1b[1m\x1b[38;2;255;215;0mYapping...\x1b[39m\x1b[22m";
382
+ const captured = await patchAndRender(
383
+ { labelText: "Yapping...", labelColor: "255,215,0" },
384
+ `${label}\n\nAlready labelled body.`,
385
+ );
386
+
387
+ expect(captured[0]!.content).toBe(`${label}\n\nAlready labelled body.`);
388
+ });
275
389
  });
@@ -3,9 +3,6 @@ import { AssistantMessageComponent, VERSION } from "@earendil-works/pi-coding-ag
3
3
  import { Markdown, type MarkdownOptions, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
4
4
  import { buildMutedMarkdownTheme } from "./theme.js";
5
5
 
6
- // The label we prepend to visible thinking content.
7
- const THINKING_LABEL = "\x1b[1m\x1b[38;2;255;215;0mThinking...\x1b[39m\x1b[22m";
8
-
9
6
  // Track which pi version we patched against to detect incompatibility
10
7
  const PATCHED_KEY = Symbol.for("archimedes:thinkingPatched");
11
8
  const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
@@ -16,8 +13,15 @@ const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
16
13
  * to capture a fresh `getTheme` closure (required for /resume).
17
14
  *
18
15
  * Re-patches when pi version changes to catch breaking upstream changes.
16
+ *
17
+ * @param config Optional labelText/labelColor overrides for the thinking
18
+ * block header. When omitted (or empty/invalid), the original defaults
19
+ * ("Thinking..." / "255,215,0") are used, producing byte-identical output.
19
20
  */
20
- export function patchThinkingRenderer(getTheme: () => Theme): void {
21
+ export function patchThinkingRenderer(
22
+ getTheme: () => Theme,
23
+ config?: { labelText?: string; labelColor?: string },
24
+ ): void {
21
25
  if (!AssistantMessageComponent) return;
22
26
 
23
27
  const proto = AssistantMessageComponent.prototype;
@@ -84,6 +88,21 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
84
88
  this.markdownTheme.codeBlockIndent = "";
85
89
  this.contentContainer.clear();
86
90
 
91
+ // Build the thinking-block header from the closure config once per render.
92
+ // Defaults preserve the original byte-identical output ("Thinking..." in
93
+ // bold truecolor 255,215,0).
94
+ const buildThinkingLabel = (): string => {
95
+ const label = config?.labelText?.trim() ? config.labelText.trim() : "Thinking...";
96
+ const color = config?.labelColor?.trim() ? config.labelColor.trim() : "255,215,0";
97
+ const parts = color.split(",").map((p) => p.trim());
98
+ // Valid iff exactly three 0..255 components (an "R,G,B" triple).
99
+ const valid =
100
+ parts.length === 3 &&
101
+ parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) >= 0 && Number(p) <= 255);
102
+ const [r, g, b] = valid ? parts : ["255", "215", "0"];
103
+ return `\x1b[1m\x1b[38;2;${r};${g};${b}m${label}\x1b[39m\x1b[22m`;
104
+ };
105
+
87
106
  const hasVisibleContent = message.content.some(
88
107
  (c: any) =>
89
108
  (c.type === "text" && c.text.trim()) ||
@@ -202,8 +221,9 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
202
221
  }
203
222
  } else {
204
223
  let thinkingContent = thinkBlocks.join("\n\n");
205
- if (!thinkingContent.startsWith(THINKING_LABEL)) {
206
- thinkingContent = `${THINKING_LABEL}\n\n${thinkingContent}`;
224
+ const label = buildThinkingLabel();
225
+ if (!thinkingContent.startsWith(label)) {
226
+ thinkingContent = `${label}\n\n${thinkingContent}`;
207
227
  }
208
228
  const t = ensureTheme();
209
229
  if (!t) continue;
@@ -1,5 +1,8 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { highlightCode as piHighlightCode } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ highlightCode as piHighlightCode,
4
+ initTheme as piInitTheme,
5
+ } from "@earendil-works/pi-coding-agent";
3
6
  import type { MarkdownTheme } from "@earendil-works/pi-tui";
4
7
  import {
5
8
  deriveDimColor,
@@ -23,6 +26,26 @@ const DEFAULT_CODE_DEFAULT_L = 0.85;
23
26
  // (bold/italic/reset/etc.) are left untouched.
24
27
  const FG_COLOR_ESCAPE_RE = /\x1b\[38;(?:2;\d{1,3};\d{1,3};\d{1,3}|5;\d{1,3})m/g;
25
28
 
29
+ // pi 0.84.4's module-level `highlightCode` reads a *global* Theme
30
+ // singleton that throws until `initTheme()` runs. pi's interactive
31
+ // mode initializes it at startup, but contexts that build the muted
32
+ // theme without going through that initialization (e.g. unit tests)
33
+ // would otherwise get "Theme not initialized. Call initTheme() first."
34
+ // We probe on first use and, only when the global is not set, call
35
+ // `initTheme("dark")` — built-in theme, no file watcher attached.
36
+ let piThemeReady = false;
37
+ function ensurePiHighlightable(): void {
38
+ if (piThemeReady) return;
39
+ piThemeReady = true;
40
+ try {
41
+ piHighlightCode(""); // probe: throws when the global is unset
42
+ } catch (err) {
43
+ // Only fall back when the probe failed for the known reason, so an
44
+ // already-established pi theme is never clobbered by a surprise.
45
+ if (String(err).includes("Theme not initialized")) piInitTheme("dark");
46
+ }
47
+ }
48
+
26
49
  /**
27
50
  * Rewrite every foreground-color SGR escape in `line` to its dimmed truecolor
28
51
  * variant, preserving all other content (text, non-color escapes, resets).
@@ -114,6 +137,7 @@ export function buildMutedMarkdownTheme(
114
137
  strikethrough: (text) => `\x1b[9m${fg("dim", text)}\x1b[29m`,
115
138
  underline: (text) => `\x1b[4m${fg("thinkingText", text)}\x1b[24m`,
116
139
  highlightCode: (code, lang) => {
140
+ ensurePiHighlightable();
117
141
  const lines = piHighlightCode(code, lang);
118
142
  return lines.map((l) => {
119
143
  const dimmed = dimAnsiLine(l, anchorL, saturationFactor, dimCache);