@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 +3 -3
- package/src/bus.ts +1 -1
- package/src/index.ts +7 -4
- package/src/settings-io.test.ts +142 -1
- package/src/settings-io.ts +57 -0
- package/src/thinking/patch.test.ts +114 -0
- package/src/thinking/patch.ts +26 -6
- package/src/thinking/theme.ts +25 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/core",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
31
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
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<{
|
|
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
|
-
//
|
|
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)
|
package/src/settings-io.test.ts
CHANGED
|
@@ -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(() => {
|
package/src/settings-io.ts
CHANGED
|
@@ -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
|
});
|
package/src/thinking/patch.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
206
|
-
|
|
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;
|
package/src/thinking/theme.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
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);
|