@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 +6 -2
- package/src/index.ts +1 -1
- package/src/overlay.test.ts +181 -0
- package/src/overlay.ts +115 -0
- package/src/startup/index.ts +1 -1
- package/src/text.ts +1 -1
- package/src/thinking/patch.test.ts +48 -0
- package/src/thinking/patch.ts +63 -20
- package/src/tool-render.test.ts +79 -0
- package/src/tool-render.ts +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/core",
|
|
3
|
-
"version": "2.
|
|
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
|
+
}
|
package/src/startup/index.ts
CHANGED
|
@@ -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
|
-
|
|
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() {
|
package/src/thinking/patch.ts
CHANGED
|
@@ -30,7 +30,13 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const src = proto.updateContent.toString();
|
|
33
|
-
|
|
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-
|
|
59
|
-
|
|
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:
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// the
|
|
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"
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|