@pi-archimedes/core 2.1.0 → 2.3.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.1.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -16,15 +16,19 @@
16
16
  "./chrome": "./src/chrome.ts",
17
17
  "./text": "./src/text.ts",
18
18
  "./color": "./src/color.ts",
19
+ "./overlay": "./src/overlay.ts",
19
20
  "./config": "./src/config.ts",
20
21
  "./settings-io": "./src/settings-io.ts",
21
- "./profiler": "./src/profiler.ts"
22
+ "./profiler": "./src/profiler.ts",
23
+ "./tool-render": "./src/tool-render.ts"
22
24
  },
23
25
  "peerDependencies": {
24
26
  "@earendil-works/pi-coding-agent": ">=0.1.0",
25
27
  "@earendil-works/pi-tui": ">=0.1.0"
26
28
  },
27
29
  "devDependencies": {
30
+ "@earendil-works/pi-coding-agent": "^0.84.2",
31
+ "@earendil-works/pi-tui": "^0.84.2",
28
32
  "typescript": "^6.0.0"
29
33
  },
30
34
  "pi": {
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
 
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ visibleWidth,
4
+ padEnd,
5
+ wrapText,
6
+ hardTruncate,
7
+ renderHeader,
8
+ renderFooter,
9
+ wrapWithBorder,
10
+ borderContentWidth,
11
+ OVERLAY_CHROME,
12
+ type OverlayTheme,
13
+ } from "./overlay.js";
14
+
15
+ // Mock theme: fg returns its text argument untouched.
16
+ const mockTheme: OverlayTheme = {
17
+ fg: (_color: string, text: string) => text,
18
+ };
19
+
20
+ // Mock theme that records the color token passed to fg.
21
+ function recordingTheme() {
22
+ let lastColor: string | null = null;
23
+ const theme: OverlayTheme = {
24
+ fg: (color: string, text: string) => {
25
+ lastColor = color;
26
+ return text;
27
+ },
28
+ };
29
+ return { theme, lastColor: () => lastColor };
30
+ }
31
+
32
+ // ── visibleWidth ─────────────────────────────────────────────────────────────
33
+
34
+ describe("visibleWidth", () => {
35
+ it("counts a plain string", () => {
36
+ expect(visibleWidth("hello")).toBe(5);
37
+ });
38
+
39
+ it("ignores ANSI SGR codes", () => {
40
+ expect(visibleWidth("\x1b[31mred\x1b[0m")).toBe(3);
41
+ });
42
+
43
+ it("returns 0 for empty string", () => {
44
+ expect(visibleWidth("")).toBe(0);
45
+ });
46
+ });
47
+
48
+ // ── padEnd ───────────────────────────────────────────────────────────────────
49
+
50
+ describe("padEnd", () => {
51
+ it("pads to width", () => {
52
+ expect(padEnd("hi", 5)).toBe("hi ");
53
+ });
54
+
55
+ it("returns input unchanged when visible width >= target", () => {
56
+ expect(padEnd("hello", 5)).toBe("hello");
57
+ expect(padEnd("toolong", 5)).toBe("toolong");
58
+ });
59
+
60
+ it("returns '' for width <= 0", () => {
61
+ expect(padEnd("text", 0)).toBe("");
62
+ expect(padEnd("text", -3)).toBe("");
63
+ });
64
+ });
65
+
66
+ // ── wrapText ─────────────────────────────────────────────────────────────────
67
+
68
+ describe("wrapText", () => {
69
+ it("wraps a long line to width", () => {
70
+ expect(wrapText("aaa bbb ccc ddd", 8)).toEqual(["aaa bbb ", "ccc ddd"]);
71
+ });
72
+
73
+ it("wraps long words to width", () => {
74
+ expect(wrapText("hello world", 5)).toEqual(["hello", " ", "world"]);
75
+ });
76
+
77
+ it("preserves empty paragraphs as blank lines", () => {
78
+ expect(wrapText("one\n\ntwo", 10)).toEqual(["one", "", "two"]);
79
+ });
80
+
81
+ it("returns [] for width <= 0", () => {
82
+ expect(wrapText("anything", 0)).toEqual([]);
83
+ });
84
+ });
85
+
86
+ // ── hardTruncate ─────────────────────────────────────────────────────────────
87
+
88
+ describe("hardTruncate", () => {
89
+ it("leaves short strings alone", () => {
90
+ expect(hardTruncate("short", 10)).toBe("short");
91
+ });
92
+
93
+ it("truncates at the visible-width boundary", () => {
94
+ expect(hardTruncate("hello world", 5)).toBe("hello");
95
+ });
96
+
97
+ it("truncates an ANSI-colored string and appends a reset", () => {
98
+ const result = hardTruncate("\x1b[31mhello world\x1b[0m", 5);
99
+ expect(result).toBe("\x1b[31mhello\x1b[0m");
100
+ expect(result).toContain("\x1b[0m");
101
+ });
102
+ });
103
+
104
+ // ── renderHeader / renderFooter ──────────────────────────────────────────────
105
+
106
+ describe("renderHeader", () => {
107
+ it("pads to width and renders with the accent token", () => {
108
+ const { theme, lastColor } = recordingTheme();
109
+ const out = renderHeader(" Title ", 10, theme);
110
+ expect(out).toBe(" Title ");
111
+ expect(lastColor()).toBe("accent");
112
+ });
113
+ });
114
+
115
+ describe("renderFooter", () => {
116
+ it("pads to width and renders with the dim token", () => {
117
+ const { theme, lastColor } = recordingTheme();
118
+ const out = renderFooter(" hint ", 10, theme);
119
+ expect(out).toBe(" hint ");
120
+ expect(lastColor()).toBe("dim");
121
+ });
122
+ });
123
+
124
+ // ── wrapWithBorder / borderContentWidth ──────────────────────────────────────
125
+
126
+ describe("borderContentWidth", () => {
127
+ it("derives content width from outer width", () => {
128
+ expect(borderContentWidth(84)).toBe(80);
129
+ });
130
+
131
+ it("floors at 1 for tiny widths", () => {
132
+ expect(borderContentWidth(0)).toBe(1);
133
+ expect(borderContentWidth(2)).toBe(1);
134
+ });
135
+ });
136
+
137
+ describe("wrapWithBorder", () => {
138
+ const W = 84;
139
+
140
+ it("emits border rows with the correct shape", () => {
141
+ const out = wrapWithBorder(["a", "b"], W, mockTheme);
142
+ expect(out[0]).toBe("┌" + "─".repeat(W - 2) + "┐");
143
+ expect(out[out.length - 1]).toBe("└" + "─".repeat(W - 2) + "┘");
144
+ expect(out[1]).toBe("│ a" + " ".repeat(W - 4) + "│");
145
+ expect(out[2]).toBe("│ b" + " ".repeat(W - 4) + "│");
146
+ });
147
+
148
+ it("emits exactly lines.length + 2 rows", () => {
149
+ const out = wrapWithBorder(["a", "b", "c"], W, mockTheme);
150
+ expect(out.length).toBe(5);
151
+ });
152
+
153
+ it("pads every row to exactly the outer width", () => {
154
+ const out = wrapWithBorder(["x", "shorter", "y"], W, mockTheme);
155
+ for (const line of out) {
156
+ expect(visibleWidth(line)).toBe(W);
157
+ }
158
+ });
159
+
160
+ it("hard-truncates content wider than the content width, without overflowing", () => {
161
+ const long = "z".repeat(W + 10); // wider than the 80-col content area
162
+ const out = wrapWithBorder([long], W, mockTheme);
163
+ expect(out.length).toBe(3);
164
+ for (const line of out) {
165
+ expect(visibleWidth(line)).toBe(W);
166
+ }
167
+ expect(out[1]).toBe("│ " + "z".repeat(W - 4) + " │");
168
+ });
169
+ });
170
+
171
+ // ── OVERLAY_CHROME ───────────────────────────────────────────────────────────
172
+
173
+ describe("OVERLAY_CHROME", () => {
174
+ it("matches the /agents overlay options", () => {
175
+ expect(OVERLAY_CHROME).toEqual({
176
+ anchor: "center",
177
+ width: 84,
178
+ maxHeight: "80%",
179
+ });
180
+ });
181
+ });
package/src/overlay.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shared overlay chrome — border wrapping, text width math, header/footer
3
+ * rendering. Used by the /agents manager (subagent) and the /archimedes
4
+ * settings overlay (meta) so both screens look identical.
5
+ */
6
+
7
+ /** Minimal structural theme — anything with `fg` satisfies it (the real pi-coding-agent Theme, agent-manager's local interface, or a test mock). */
8
+ export interface OverlayTheme {
9
+ fg(token: string, text: string): string;
10
+ }
11
+
12
+ export function wrapText(text: string, width: number): string[] {
13
+ if (width <= 0) return [];
14
+ const lines: string[] = [];
15
+ const paragraphs = text.split("\n");
16
+ for (const para of paragraphs) {
17
+ if (para.length === 0) {
18
+ lines.push("");
19
+ continue;
20
+ }
21
+ const words = para.split(/(\s+)/).filter(Boolean);
22
+ let current = "";
23
+ for (const word of words) {
24
+ const test = current === "" ? word : current + word;
25
+ if (test.length > width && current.length > 0) {
26
+ lines.push(current);
27
+ current = word;
28
+ } else {
29
+ current = test;
30
+ }
31
+ }
32
+ if (current) lines.push(current);
33
+ }
34
+ return lines;
35
+ }
36
+
37
+ export function padEnd(text: string, width: number): string {
38
+ if (width <= 0) return "";
39
+ const vw = visibleWidth(text);
40
+ if (vw >= width) return text;
41
+ return text + " ".repeat(width - vw);
42
+ }
43
+
44
+ export function visibleWidth(text: string): number {
45
+ // Strip ANSI escape sequences for width calculation
46
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
47
+ }
48
+
49
+ export function renderHeader(text: string, width: number, theme: OverlayTheme): string {
50
+ return theme.fg("accent", padEnd(text, width));
51
+ }
52
+
53
+ export function renderFooter(text: string, width: number, theme: OverlayTheme): string {
54
+ return theme.fg("dim", padEnd(text, width));
55
+ }
56
+
57
+ /** Content width available inside wrapWithBorder at a given outer width.
58
+ * inner = max(1, width - 2); content = max(1, inner - 2). */
59
+ export function borderContentWidth(width: number): number {
60
+ const innerWidth = Math.max(1, width - 2);
61
+ return Math.max(1, innerWidth - 2);
62
+ }
63
+
64
+ /** Shared overlay options so /agents and /archimedes never drift. */
65
+ export const OVERLAY_CHROME = { anchor: "center", width: 84, maxHeight: "80%" } as const;
66
+
67
+ // ── Border wrapper ────────────────────────────────────────────────────────────
68
+
69
+ /** Hard-truncate by visible width — no "..." suffix. Strips ANSI, truncates, rebuilds. */
70
+ export function hardTruncate(text: string, maxVisible: number): string {
71
+ if (visibleWidth(text) <= maxVisible) return text;
72
+ // Walk the string, skipping ANSI escape sequences, and stop after
73
+ // maxVisible visible characters; copy SGR codes through so styling survives,
74
+ // and append a reset at the end so styling doesn't bleed.
75
+ let result = "";
76
+ let plainPos = 0;
77
+ let i = 0;
78
+ let copiedSgr = false;
79
+ while (i < text.length && plainPos < maxVisible) {
80
+ if (text[i] === "\x1b" && text[i + 1] === "[") {
81
+ // Copy the escape sequence
82
+ let j = i;
83
+ while (j < text.length && text[j] !== "m") j++;
84
+ result += text.slice(i, j + 1);
85
+ copiedSgr = true;
86
+ i = j + 1;
87
+ } else {
88
+ result += text[i];
89
+ plainPos++;
90
+ i++;
91
+ }
92
+ }
93
+ // Ensure styling doesn't bleed: append reset if we copied SGR and result doesn't end with one
94
+ if (copiedSgr && !/\x1b\[0?m$/.test(result)) {
95
+ result += "\x1b[0m";
96
+ }
97
+ return result;
98
+ }
99
+
100
+ export function wrapWithBorder(lines: string[], width: number, theme: OverlayTheme): string[] {
101
+ const innerWidth = Math.max(1, width - 2);
102
+ const contentWidth = Math.max(1, innerWidth - 2); // minus 1 space padding each side
103
+ const left = theme.fg("dim", "│");
104
+ const right = theme.fg("dim", "│");
105
+ const top = theme.fg("dim", `┌${"─".repeat(innerWidth)}┐`);
106
+ const bottom = theme.fg("dim", `└${"─".repeat(innerWidth)}┘`);
107
+ const result: string[] = [top];
108
+ for (const line of lines) {
109
+ const clamped = hardTruncate(line, contentWidth);
110
+ const padded = " " + padEnd(clamped, contentWidth) + " ";
111
+ result.push(left + padded + right);
112
+ }
113
+ result.push(bottom);
114
+ return result;
115
+ }
@@ -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() {
@@ -30,7 +30,13 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
30
30
  }
31
31
 
32
32
  const src = proto.updateContent.toString();
33
- const hasThinkingCheck = src.includes('content.type === "thinking"');
33
+ // NOTE: pi ships the interactive TUI in a minified bundle chunk at runtime, so
34
+ // the source we see via .toString() can be `content.type==="thinking"` (no
35
+ // spaces) even where dist is readable. The probe below must therefore be
36
+ // minification-safe: a whitespace-tolerant regex rather than an exact
37
+ // substring. A bare `space === "thinking"` (or any other field) still does
38
+ // not match, as required.
39
+ const hasThinkingCheck = /content\.type\s*===\s*["']thinking["']/.test(src);
34
40
  const hasMarkdownTheme = src.includes("this.markdownTheme");
35
41
  if (!hasThinkingCheck || !hasMarkdownTheme) {
36
42
  console.warn(
@@ -55,9 +61,25 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
55
61
  }
56
62
  }
57
63
 
58
- // Re-patch every time — /resume needs a fresh getTheme closure
59
- (proto as any).updateContent = function (this: any, message: any): void {
64
+ // Re-patched every session_start — /resume needs a fresh getTheme closure.
65
+ //
66
+ // Shape: 0.84.3 pi native updateContent:
67
+ // updateContent(message, isStreaming = this.isStreaming) {
68
+ // this.lastMessage = message;
69
+ // this.isStreaming = isStreaming;
70
+ // this.contentContainer.clear();
71
+ // ...
72
+ // // batches consecutive "thinking" parts into thinkingBlocks
73
+ // // (skipping empties), i-- after the inner loop,
74
+ // // renders as ONE Markdown section of thinkingBlocks.join("\n\n")
75
+ // // or ONE static Text label when hidden.
76
+ // // stop-reason: const hasToolCalls = content.some(...);
77
+ // // this.hasToolCalls = hasToolCalls; (render() uses for OSC-133 zones)
78
+ // // stopReason === "length" → Spacer + "truncated" Text
79
+ // // else if (!hasToolCalls) { aborted / error branches }
80
+ (proto as any).updateContent = function (this: any, message: any, isStreaming?: boolean): void {
60
81
  this.lastMessage = message;
82
+ if (isStreaming !== undefined) this.isStreaming = isStreaming;
61
83
 
62
84
  this.markdownTheme.codeBlockIndent = "";
63
85
  this.contentContainer.clear();
@@ -105,11 +127,11 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
105
127
  // We must preserve it here, otherwise those transformers are silently
106
128
  // dropped when this patch replaces updateContent.
107
129
  //
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).
130
+ // NOTE: `createMarkdownTransform` is not exported from pi-coding-agent, so
131
+ // we inline an equivalent pipeline over `this.markdownTransformers`.
132
+ //
133
+ // The `transform` option was added to @earendil-works/pi-tui in 0.84.1.
134
+ // At runtime older pi-tui ignores the field, 0.84.1+ honors it.
113
135
  type MarkdownOptionsWithTransform = MarkdownOptions & {
114
136
  transform?: (markdown: string, availableWidth: number) => string;
115
137
  };
@@ -139,14 +161,27 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
139
161
  this.contentContainer.addChild(
140
162
  new Markdown(
141
163
  content.text.trim(),
142
- 1,
164
+ this.outputPad ?? 1,
143
165
  0,
144
166
  this.markdownTheme,
145
167
  undefined,
146
168
  { transform: transformFor("assistant") } as MarkdownOptionsWithTransform,
147
169
  ),
148
170
  );
149
- } else if (content.type === "thinking" && content.thinking.trim()) {
171
+ } else if (content.type === "thinking") {
172
+ // Batch a consecutive run of thinking parts into one section
173
+ // (mirrors 0.84.3 pi native behaviour: thinkBlocks, i-- on the
174
+ // inner loop, early continue on zero-length runs).
175
+ const thinkBlocks: string[] = [];
176
+ for (; i < message.content.length; i++) {
177
+ const thinkingPart = message.content[i];
178
+ if (thinkingPart.type !== "thinking") break;
179
+ const trimmed = thinkingPart.thinking.trim();
180
+ if (trimmed) thinkBlocks.push(trimmed);
181
+ }
182
+ i--;
183
+ if (thinkBlocks.length === 0) continue;
184
+
150
185
  const hasVisibleContentAfter = message.content
151
186
  .slice(i + 1)
152
187
  .some(
@@ -156,16 +191,17 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
156
191
  );
157
192
 
158
193
  if (this.hideThinkingBlock) {
194
+ // One static label for the whole run when hidden.
159
195
  const t = ensureTheme();
160
196
  if (!t) continue;
161
197
  this.contentContainer.addChild(
162
- new Text(t.italic(t.fg("thinkingText", this.hiddenThinkingLabel)), 1, 0),
198
+ new Text(t.italic(t.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad ?? 1, 0),
163
199
  );
164
200
  if (hasVisibleContentAfter) {
165
201
  this.contentContainer.addChild(new Spacer(1));
166
202
  }
167
203
  } else {
168
- let thinkingContent = content.thinking.trim();
204
+ let thinkingContent = thinkBlocks.join("\n\n");
169
205
  if (!thinkingContent.startsWith(THINKING_LABEL)) {
170
206
  thinkingContent = `${THINKING_LABEL}\n\n${thinkingContent}`;
171
207
  }
@@ -175,7 +211,7 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
175
211
  this.contentContainer.addChild(
176
212
  new Markdown(
177
213
  thinkingContent,
178
- 1,
214
+ this.outputPad ?? 1,
179
215
  0,
180
216
  muted ?? this.markdownTheme,
181
217
  {
@@ -192,9 +228,19 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
192
228
  }
193
229
  }
194
230
 
195
- // Aborted/error rendering.
231
+ // Stop-reason handling — 0.84.3 pi shape. `hasToolCalls` is required by
232
+ // the component's render() for OSC-133 prompt zones, so it must be set.
196
233
  const hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
197
- if (!hasToolCalls) {
234
+ this.hasToolCalls = hasToolCalls;
235
+
236
+ if (message.stopReason === "length") {
237
+ this.contentContainer.addChild(new Spacer(1));
238
+ const t = ensureTheme();
239
+ if (t)
240
+ this.contentContainer.addChild(
241
+ new Text(t.fg("error", "Response was truncated before completion."), this.outputPad ?? 1, 0),
242
+ );
243
+ } else if (!hasToolCalls) {
198
244
  if (message.stopReason === "aborted") {
199
245
  const abortMessage =
200
246
  message.errorMessage && message.errorMessage !== "Request was aborted"
@@ -202,21 +248,18 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
202
248
  : "Operation aborted";
203
249
  this.contentContainer.addChild(new Spacer(1));
204
250
  const t = ensureTheme();
205
- if (t) this.contentContainer.addChild(new Text(t.fg("error", abortMessage), 1, 0));
251
+ if (t) this.contentContainer.addChild(new Text(t.fg("error", abortMessage), this.outputPad ?? 1, 0));
206
252
  } else if (message.stopReason === "error") {
207
253
  const errorMsg = message.errorMessage || "Unknown error";
208
254
  this.contentContainer.addChild(new Spacer(1));
209
255
  const t = ensureTheme();
210
256
  if (t) {
211
257
  this.contentContainer.addChild(
212
- new Text(t.fg("error", `Error: ${errorMsg}`), 1, 0),
258
+ new Text(t.fg("error", `Error: ${errorMsg}`), this.outputPad ?? 1, 0),
213
259
  );
214
260
  }
215
261
  }
216
262
  }
217
-
218
- // Bottom padding so next message has breathing room
219
- this.contentContainer.addChild(new Spacer(1));
220
263
  };
221
264
 
222
265
  // 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
+ }