@pi-archimedes/core 2.2.0 → 2.4.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.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -19,13 +19,16 @@
19
19
  "./overlay": "./src/overlay.ts",
20
20
  "./config": "./src/config.ts",
21
21
  "./settings-io": "./src/settings-io.ts",
22
- "./profiler": "./src/profiler.ts"
22
+ "./profiler": "./src/profiler.ts",
23
+ "./tool-render": "./src/tool-render.ts"
23
24
  },
24
25
  "peerDependencies": {
25
26
  "@earendil-works/pi-coding-agent": ">=0.1.0",
26
27
  "@earendil-works/pi-tui": ">=0.1.0"
27
28
  },
28
29
  "devDependencies": {
30
+ "@earendil-works/pi-coding-agent": "^0.84.2",
31
+ "@earendil-works/pi-tui": "^0.84.2",
29
32
  "typescript": "^6.0.0"
30
33
  },
31
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
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
- import { TUI, type EditorTheme, type Component, type SettingItem } from "@earendil-works/pi-tui";
3
+ import { type TUI, type EditorTheme, type Component, type SettingItem } from "@earendil-works/pi-tui";
4
4
 
5
5
  import { HephaestusEditor } from "./editor/index.js";
6
6
 
@@ -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
+ }
@@ -4,7 +4,7 @@ import { loadCoreConfig } from "../config.js";
4
4
  import { detectSection, parseSectionText, parseModelScope, formatColumns, buildItemWrapper, type ParsedSection, SECTION_KEYS } from "./sections.js";
5
5
  import { fetchLatestVersion, compareVersions } from "./version.js";
6
6
  import { stripAnsi } from "../text.js";
7
- import { Text, Spacer, Container, TUI, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
7
+ import { Text, Spacer, Container, type TUI, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
8
8
 
9
9
  // Symbol keys (survive hot-reload)
10
10
  const LISTING_REF = Symbol.for("splashscreen:listingRef");
package/src/text.ts CHANGED
@@ -29,7 +29,7 @@ export function clampLine(line: string, maxW: number): string {
29
29
  }
30
30
 
31
31
  /** Clamp an array of lines to maxW visible characters each. */
32
- export function clampLines(lines: string[], maxW: number): string[] {
32
+ function clampLines(lines: string[], maxW: number): string[] {
33
33
  return lines.map((l) => clampLine(l, maxW));
34
34
  }
35
35
 
@@ -196,6 +196,54 @@ describe("patchThinkingRenderer", () => {
196
196
  expect(MockClassV2.prototype[PATCH_VERSION_KEY]).toBe("2.0.0");
197
197
  });
198
198
 
199
+ it("accepts a minified thinking-check variant (no whitespace around ===)", async () => {
200
+ const MockClass = function AssistantMessageComponent() {};
201
+ MockClass.prototype.updateContent = function updateContent() {
202
+ // Minified dist-chunk shape: no whitespace around ===, single quotes
203
+ const content = { type: "thinking" };
204
+ if (content.type==="thinking") {
205
+ this.markdownTheme.codeBlockIndent="";
206
+ }
207
+ };
208
+
209
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
210
+ AssistantMessageComponent: MockClass,
211
+ VERSION: "1.0.0",
212
+ highlightCode: vi.fn(),
213
+ }));
214
+
215
+ const patch = await importPatch();
216
+ patch(() => ({} as any));
217
+
218
+ // The minification-safe regex probe must accept the minified variant
219
+ expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
220
+ });
221
+
222
+ it("rejects a negated thinking check", async () => {
223
+ const MockClass = function AssistantMessageComponent() {};
224
+ MockClass.prototype.updateContent = function updateContent() {
225
+ // pi 0.84.3's own minified chunk contains
226
+ // `thinkingContent.type!=="thinking"` (inner batch-loop break). A source whose
227
+ // ONLY thinking-relations are negations must NOT pass the probe.
228
+ const content = { type: "thinking" };
229
+ if (content.type!=="thinking") {
230
+ this.markdownTheme.codeBlockIndent="";
231
+ }
232
+ };
233
+
234
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
235
+ AssistantMessageComponent: MockClass,
236
+ VERSION: "1.0.0",
237
+ highlightCode: vi.fn(),
238
+ }));
239
+
240
+ const patch = await importPatch();
241
+ patch(() => ({} as any));
242
+
243
+ // PATCHED_KEY must NOT be set — the probe must not match `!==`
244
+ expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
245
+ });
246
+
199
247
  it("re-patches on same version to update getTheme closure", async () => {
200
248
  const MockClass = function AssistantMessageComponent() {};
201
249
  MockClass.prototype.updateContent = function updateContent() {
@@ -224,4 +272,118 @@ describe("patchThinkingRenderer", () => {
224
272
  // The patched function must be a new closure (not the same reference)
225
273
  expect(MockClass.prototype.updateContent).not.toBe(first);
226
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
+ });
227
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;
@@ -30,7 +34,13 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
30
34
  }
31
35
 
32
36
  const src = proto.updateContent.toString();
33
- const hasThinkingCheck = src.includes('content.type === "thinking"');
37
+ // NOTE: pi ships the interactive TUI in a minified bundle chunk at runtime, so
38
+ // the source we see via .toString() can be `content.type==="thinking"` (no
39
+ // spaces) even where dist is readable. The probe below must therefore be
40
+ // minification-safe: a whitespace-tolerant regex rather than an exact
41
+ // substring. A bare `space === "thinking"` (or any other field) still does
42
+ // not match, as required.
43
+ const hasThinkingCheck = /content\.type\s*===\s*["']thinking["']/.test(src);
34
44
  const hasMarkdownTheme = src.includes("this.markdownTheme");
35
45
  if (!hasThinkingCheck || !hasMarkdownTheme) {
36
46
  console.warn(
@@ -55,13 +65,44 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
55
65
  }
56
66
  }
57
67
 
58
- // Re-patch every time — /resume needs a fresh getTheme closure
59
- (proto as any).updateContent = function (this: any, message: any): void {
68
+ // Re-patched every session_start — /resume needs a fresh getTheme closure.
69
+ //
70
+ // Shape: 0.84.3 pi native updateContent:
71
+ // updateContent(message, isStreaming = this.isStreaming) {
72
+ // this.lastMessage = message;
73
+ // this.isStreaming = isStreaming;
74
+ // this.contentContainer.clear();
75
+ // ...
76
+ // // batches consecutive "thinking" parts into thinkingBlocks
77
+ // // (skipping empties), i-- after the inner loop,
78
+ // // renders as ONE Markdown section of thinkingBlocks.join("\n\n")
79
+ // // or ONE static Text label when hidden.
80
+ // // stop-reason: const hasToolCalls = content.some(...);
81
+ // // this.hasToolCalls = hasToolCalls; (render() uses for OSC-133 zones)
82
+ // // stopReason === "length" → Spacer + "truncated" Text
83
+ // // else if (!hasToolCalls) { aborted / error branches }
84
+ (proto as any).updateContent = function (this: any, message: any, isStreaming?: boolean): void {
60
85
  this.lastMessage = message;
86
+ if (isStreaming !== undefined) this.isStreaming = isStreaming;
61
87
 
62
88
  this.markdownTheme.codeBlockIndent = "";
63
89
  this.contentContainer.clear();
64
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
+
65
106
  const hasVisibleContent = message.content.some(
66
107
  (c: any) =>
67
108
  (c.type === "text" && c.text.trim()) ||
@@ -105,11 +146,11 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
105
146
  // We must preserve it here, otherwise those transformers are silently
106
147
  // dropped when this patch replaces updateContent.
107
148
  //
108
- // NOTE: the `transform` option was added to @earendil-works/pi-tui in
109
- // 0.84.1. The repo's lockfile pins 0.78.0 whose types lack the field, so we
110
- // extend the options type locally; at runtime the field is ignored by very
111
- // old pi-tui and honored by 0.84.1+ (which is where Mermaid rendering and
112
- // the bug both exist).
149
+ // NOTE: `createMarkdownTransform` is not exported from pi-coding-agent, so
150
+ // we inline an equivalent pipeline over `this.markdownTransformers`.
151
+ //
152
+ // The `transform` option was added to @earendil-works/pi-tui in 0.84.1.
153
+ // At runtime older pi-tui ignores the field, 0.84.1+ honors it.
113
154
  type MarkdownOptionsWithTransform = MarkdownOptions & {
114
155
  transform?: (markdown: string, availableWidth: number) => string;
115
156
  };
@@ -139,14 +180,27 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
139
180
  this.contentContainer.addChild(
140
181
  new Markdown(
141
182
  content.text.trim(),
142
- 1,
183
+ this.outputPad ?? 1,
143
184
  0,
144
185
  this.markdownTheme,
145
186
  undefined,
146
187
  { transform: transformFor("assistant") } as MarkdownOptionsWithTransform,
147
188
  ),
148
189
  );
149
- } else if (content.type === "thinking" && content.thinking.trim()) {
190
+ } else if (content.type === "thinking") {
191
+ // Batch a consecutive run of thinking parts into one section
192
+ // (mirrors 0.84.3 pi native behaviour: thinkBlocks, i-- on the
193
+ // inner loop, early continue on zero-length runs).
194
+ const thinkBlocks: string[] = [];
195
+ for (; i < message.content.length; i++) {
196
+ const thinkingPart = message.content[i];
197
+ if (thinkingPart.type !== "thinking") break;
198
+ const trimmed = thinkingPart.thinking.trim();
199
+ if (trimmed) thinkBlocks.push(trimmed);
200
+ }
201
+ i--;
202
+ if (thinkBlocks.length === 0) continue;
203
+
150
204
  const hasVisibleContentAfter = message.content
151
205
  .slice(i + 1)
152
206
  .some(
@@ -156,18 +210,20 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
156
210
  );
157
211
 
158
212
  if (this.hideThinkingBlock) {
213
+ // One static label for the whole run when hidden.
159
214
  const t = ensureTheme();
160
215
  if (!t) continue;
161
216
  this.contentContainer.addChild(
162
- new Text(t.italic(t.fg("thinkingText", this.hiddenThinkingLabel)), 1, 0),
217
+ new Text(t.italic(t.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad ?? 1, 0),
163
218
  );
164
219
  if (hasVisibleContentAfter) {
165
220
  this.contentContainer.addChild(new Spacer(1));
166
221
  }
167
222
  } else {
168
- let thinkingContent = content.thinking.trim();
169
- if (!thinkingContent.startsWith(THINKING_LABEL)) {
170
- thinkingContent = `${THINKING_LABEL}\n\n${thinkingContent}`;
223
+ let thinkingContent = thinkBlocks.join("\n\n");
224
+ const label = buildThinkingLabel();
225
+ if (!thinkingContent.startsWith(label)) {
226
+ thinkingContent = `${label}\n\n${thinkingContent}`;
171
227
  }
172
228
  const t = ensureTheme();
173
229
  if (!t) continue;
@@ -175,7 +231,7 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
175
231
  this.contentContainer.addChild(
176
232
  new Markdown(
177
233
  thinkingContent,
178
- 1,
234
+ this.outputPad ?? 1,
179
235
  0,
180
236
  muted ?? this.markdownTheme,
181
237
  {
@@ -192,9 +248,19 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
192
248
  }
193
249
  }
194
250
 
195
- // Aborted/error rendering.
251
+ // Stop-reason handling — 0.84.3 pi shape. `hasToolCalls` is required by
252
+ // the component's render() for OSC-133 prompt zones, so it must be set.
196
253
  const hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
197
- if (!hasToolCalls) {
254
+ this.hasToolCalls = hasToolCalls;
255
+
256
+ if (message.stopReason === "length") {
257
+ this.contentContainer.addChild(new Spacer(1));
258
+ const t = ensureTheme();
259
+ if (t)
260
+ this.contentContainer.addChild(
261
+ new Text(t.fg("error", "Response was truncated before completion."), this.outputPad ?? 1, 0),
262
+ );
263
+ } else if (!hasToolCalls) {
198
264
  if (message.stopReason === "aborted") {
199
265
  const abortMessage =
200
266
  message.errorMessage && message.errorMessage !== "Request was aborted"
@@ -202,21 +268,18 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
202
268
  : "Operation aborted";
203
269
  this.contentContainer.addChild(new Spacer(1));
204
270
  const t = ensureTheme();
205
- if (t) this.contentContainer.addChild(new Text(t.fg("error", abortMessage), 1, 0));
271
+ if (t) this.contentContainer.addChild(new Text(t.fg("error", abortMessage), this.outputPad ?? 1, 0));
206
272
  } else if (message.stopReason === "error") {
207
273
  const errorMsg = message.errorMessage || "Unknown error";
208
274
  this.contentContainer.addChild(new Spacer(1));
209
275
  const t = ensureTheme();
210
276
  if (t) {
211
277
  this.contentContainer.addChild(
212
- new Text(t.fg("error", `Error: ${errorMsg}`), 1, 0),
278
+ new Text(t.fg("error", `Error: ${errorMsg}`), this.outputPad ?? 1, 0),
213
279
  );
214
280
  }
215
281
  }
216
282
  }
217
-
218
- // Bottom padding so next message has breathing room
219
- this.contentContainer.addChild(new Spacer(1));
220
283
  };
221
284
 
222
285
  // Mark as patched with version for incompatibility detection
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ renderToolHeader,
4
+ renderStatusLabel,
5
+ renderToolCallLine,
6
+ STATUS_GLYPH,
7
+ type ToolRenderTheme,
8
+ } from "./tool-render.js";
9
+
10
+ // Fake theme: wraps text in visible markers so assertions can verify tokens.
11
+ const theme: ToolRenderTheme = {
12
+ fg: (token: string, text: string) => `[${token}:${text}]`,
13
+ bold: (text: string) => `**${text}**`,
14
+ };
15
+
16
+ describe("renderToolHeader", () => {
17
+ it("renders blue bold tool name + orange action", () => {
18
+ expect(renderToolHeader("mcp", "atlassian", theme)).toBe(
19
+ "[toolTitle:**mcp**] [accent:atlassian]",
20
+ );
21
+ });
22
+
23
+ it("renders the name only when action is empty", () => {
24
+ expect(renderToolHeader("todo", "", theme)).toBe("[toolTitle:**todo**]");
25
+ expect(renderToolHeader("todo", undefined, theme)).toBe(
26
+ "[toolTitle:**todo**]",
27
+ );
28
+ });
29
+ });
30
+
31
+ describe("renderStatusLabel", () => {
32
+ it("running: muted glyph + muted label", () => {
33
+ expect(renderStatusLabel("running", "2/4 completed", theme)).toBe(
34
+ "[muted:▸ ][muted:2/4 completed]",
35
+ );
36
+ });
37
+
38
+ it("success: green glyph + muted label", () => {
39
+ expect(renderStatusLabel("success", "done", theme)).toBe(
40
+ "[success:✓ ][muted:done]",
41
+ );
42
+ });
43
+
44
+ it("error: red glyph + muted label", () => {
45
+ expect(renderStatusLabel("error", "boom", theme)).toBe(
46
+ "[error:✗ ][muted:boom]",
47
+ );
48
+ });
49
+
50
+ it("exposes the glyph map", () => {
51
+ expect(STATUS_GLYPH).toEqual({ running: "▸", success: "✓", error: "✗" });
52
+ });
53
+ });
54
+
55
+ describe("renderToolCallLine", () => {
56
+ it("success: green glyph + green name + dim suffix", () => {
57
+ expect(renderToolCallLine("success", "read", ": /path", theme)).toBe(
58
+ "[success:✓ ][success:read][dim:: /path]",
59
+ );
60
+ });
61
+
62
+ it("error: red glyph + red name + dim suffix", () => {
63
+ expect(renderToolCallLine("error", "read", ": /missing", theme)).toBe(
64
+ "[error:✗ ][error:read][dim:: /missing]",
65
+ );
66
+ });
67
+
68
+ it("running: muted glyph + muted name", () => {
69
+ expect(renderToolCallLine("running", "grep", ": pattern | 2s", theme)).toBe(
70
+ "[muted:▸ ][muted:grep][dim:: pattern | 2s]",
71
+ );
72
+ });
73
+
74
+ it("omits the dim fragment when suffix is empty", () => {
75
+ expect(renderToolCallLine("success", "bash", "", theme)).toBe(
76
+ "[success:✓ ][success:bash]",
77
+ );
78
+ });
79
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared tool-row rendering helpers.
3
+ *
4
+ * Archimedes tools (mcp, todo, …) render a consistent two-part row:
5
+ *
6
+ * line 1 (header): <toolName> (blue bold) + <action> (orange accent)
7
+ * result line: <glyph> <label> — glyph reflects run status:
8
+ * ▸ running (muted) · ✓ success (green) · ✗ error (red)
9
+ * the label is muted so the glyph carries the colour.
10
+ *
11
+ * These helpers are pure (no TUI component imports) so they stay directly
12
+ * unit-testable and can be reused across packages. Callers wrap the returned
13
+ * string in whatever component they use (typically a pi-tui Text).
14
+ */
15
+
16
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
17
+
18
+ /** Run status of a settled/in-flight tool row. */
19
+ export type ToolStatus = "running" | "success" | "error";
20
+
21
+ /** Glyph shown before the result label, keyed by status. */
22
+ export const STATUS_GLYPH: Record<ToolStatus, string> = {
23
+ running: "▸",
24
+ success: "✓",
25
+ error: "✗",
26
+ };
27
+
28
+ /** Theme color token used to colour each status glyph. */
29
+ const STATUS_TOKEN: Record<ToolStatus, ThemeColor> = {
30
+ running: "muted",
31
+ success: "success",
32
+ error: "error",
33
+ };
34
+
35
+ /**
36
+ * The subset of a pi Theme these helpers need. Typed with pi's ThemeColor so
37
+ * pi's real Theme is assignable (parameter contravariance: a fn requiring the
38
+ * wider string token would NOT accept a Theme whose fg only takes ThemeColor).
39
+ */
40
+ export type ToolRenderTheme = {
41
+ fg: (token: ThemeColor, text: string) => string;
42
+ bold: (text: string) => string;
43
+ };
44
+
45
+ /**
46
+ * Render the tool header line:
47
+ * <toolName> (toolTitle, bold) + " " + <action> (accent)
48
+ *
49
+ * When action is empty/undefined only the tool name is rendered.
50
+ */
51
+ export function renderToolHeader(
52
+ toolName: string,
53
+ action: string | undefined,
54
+ theme: ToolRenderTheme,
55
+ ): string {
56
+ const name = theme.fg("toolTitle", theme.bold(toolName));
57
+ if (!action) return name;
58
+ return name + " " + theme.fg("accent", action);
59
+ }
60
+
61
+ /**
62
+ * Render a status result line:
63
+ * <glyph> (status-coloured) + <label> (muted)
64
+ *
65
+ * e.g. "✓ atlassian_searchJiraIssuesUsingJql" or "▸ 2/4 completed".
66
+ */
67
+ export function renderStatusLabel(
68
+ status: ToolStatus,
69
+ label: string,
70
+ theme: ToolRenderTheme,
71
+ ): string {
72
+ return (
73
+ theme.fg(STATUS_TOKEN[status], STATUS_GLYPH[status] + " ") +
74
+ theme.fg("muted", label)
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Render a tool-call line with a status glyph, a status-coloured name, and an
80
+ * optional dim args/suffix fragment:
81
+ *
82
+ * <glyph> (status-coloured) + <name> (status-coloured) + <suffix> (dim)
83
+ *
84
+ * e.g. "✓ read: /path/to/file" (green glyph+name, dim ": /path...") or
85
+ * "▸ grep: pattern" (muted glyph+name while running).
86
+ *
87
+ * The name shares the glyph's colour (unlike renderStatusLabel, which mutes
88
+ * the label) so a completed call reads as a single green/red unit. The suffix
89
+ * is passed pre-formatted (e.g. ": args" or ": args | 2s") and rendered dim.
90
+ */
91
+ export function renderToolCallLine(
92
+ status: ToolStatus,
93
+ name: string,
94
+ suffix: string,
95
+ theme: ToolRenderTheme,
96
+ ): string {
97
+ const token = STATUS_TOKEN[status];
98
+ const head =
99
+ theme.fg(token, STATUS_GLYPH[status] + " ") + theme.fg(token, name);
100
+ return suffix ? head + theme.fg("dim", suffix) : head;
101
+ }