@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 +5 -2
- package/src/bus.ts +1 -1
- package/src/index.ts +8 -5
- package/src/settings-io.test.ts +142 -1
- package/src/settings-io.ts +57 -0
- package/src/startup/index.ts +1 -1
- package/src/text.ts +1 -1
- package/src/thinking/patch.test.ts +162 -0
- package/src/thinking/patch.ts +89 -26
- 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.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<{
|
|
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
|
-
//
|
|
173
|
-
patchThinkingRenderer(() => ctx.ui.theme);
|
|
174
|
-
|
|
175
|
-
// Load config for thinking transformation
|
|
172
|
+
// Load config for thinking transformation + label overrides
|
|
176
173
|
const config = loadCoreConfig();
|
|
177
174
|
|
|
175
|
+
// Patch thinking renderer
|
|
176
|
+
patchThinkingRenderer(() => ctx.ui.theme, {
|
|
177
|
+
labelText: config.labelText,
|
|
178
|
+
labelColor: config.labelColor,
|
|
179
|
+
});
|
|
180
|
+
|
|
178
181
|
// Register events
|
|
179
182
|
pi.on("message_end", (event, _ctx) => {
|
|
180
183
|
// Transform thinking content (unindent code blocks if enabled)
|
package/src/settings-io.test.ts
CHANGED
|
@@ -17,7 +17,148 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
19
|
// Import after mocks are set up
|
|
20
|
-
const { loadConfig, saveConfig } = await import("./settings-io.js");
|
|
20
|
+
const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled } = await import("./settings-io.js");
|
|
21
|
+
|
|
22
|
+
describe("removeConfig", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
25
|
+
if (fs.existsSync(settingsPath)) {
|
|
26
|
+
fs.unlinkSync(settingsPath);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
32
|
+
const tmpPath = settingsPath + ".tmp";
|
|
33
|
+
try { fs.unlinkSync(settingsPath); } catch { /* ignore */ }
|
|
34
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("deletes an existing namespace key and leaves sibling keys intact", () => {
|
|
38
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
39
|
+
fs.writeFileSync(
|
|
40
|
+
settingsPath,
|
|
41
|
+
JSON.stringify({ "test.ns": { foo: "bar" }, "other.ns": { baz: 1 }, "third.ns": { qux: "y" } }),
|
|
42
|
+
"utf-8",
|
|
43
|
+
);
|
|
44
|
+
removeConfig("test.ns");
|
|
45
|
+
const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
46
|
+
expect(data["test.ns"]).toBeUndefined();
|
|
47
|
+
expect(data["other.ns"]).toEqual({ baz: 1 });
|
|
48
|
+
expect(data["third.ns"]).toEqual({ qux: "y" });
|
|
49
|
+
expect(fs.existsSync(settingsPath + ".tmp")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("is a no-op when the key is absent (no rewrite)", () => {
|
|
53
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
54
|
+
fs.writeFileSync(
|
|
55
|
+
settingsPath,
|
|
56
|
+
JSON.stringify({ "other.ns": { baz: 1 } }),
|
|
57
|
+
"utf-8",
|
|
58
|
+
);
|
|
59
|
+
const beforeMtimeMs = fs.statSync(settingsPath).mtimeMs;
|
|
60
|
+
removeConfig("test.ns");
|
|
61
|
+
const afterMtimeMs = fs.statSync(settingsPath).mtimeMs;
|
|
62
|
+
expect(afterMtimeMs).toBe(beforeMtimeMs);
|
|
63
|
+
const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
64
|
+
expect(data).toEqual({ "other.ns": { baz: 1 } });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not create the file when settings.json does not exist", () => {
|
|
68
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
69
|
+
expect(fs.existsSync(settingsPath)).toBe(false);
|
|
70
|
+
removeConfig("test.ns");
|
|
71
|
+
expect(fs.existsSync(settingsPath)).toBe(false);
|
|
72
|
+
expect(fs.existsSync(settingsPath + ".tmp")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("isConfigEnabled", () => {
|
|
77
|
+
const settingsPath = () => join(tempDir, "settings.json");
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
if (fs.existsSync(settingsPath())) {
|
|
81
|
+
fs.unlinkSync(settingsPath());
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
const path = settingsPath();
|
|
87
|
+
const tmpPath = path + ".tmp";
|
|
88
|
+
try { fs.unlinkSync(path); } catch { /* ignore */ }
|
|
89
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("missing file/namespace → true", () => {
|
|
93
|
+
expect(isConfigEnabled("test.ns")).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("empty namespace object → true", () => {
|
|
97
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": {} }), "utf-8");
|
|
98
|
+
expect(isConfigEnabled("test.ns")).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("{ enabled: true } → true", () => {
|
|
102
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: true } }), "utf-8");
|
|
103
|
+
expect(isConfigEnabled("test.ns")).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("{ enabled: false } → false", () => {
|
|
107
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: false } }), "utf-8");
|
|
108
|
+
expect(isConfigEnabled("test.ns")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("{ enabled: \"false\" } → true (strict === false check)", () => {
|
|
112
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: "false" } }), "utf-8");
|
|
113
|
+
expect(isConfigEnabled("test.ns")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("setConfigEnabled", () => {
|
|
118
|
+
const settingsPath = () => join(tempDir, "settings.json");
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
if (fs.existsSync(settingsPath())) {
|
|
122
|
+
fs.unlinkSync(settingsPath());
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
const path = settingsPath();
|
|
128
|
+
const tmpPath = path + ".tmp";
|
|
129
|
+
try { fs.unlinkSync(path); } catch { /* ignore */ }
|
|
130
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("set to false adds enabled:false, keeping other keys and sibling namespaces", () => {
|
|
134
|
+
fs.writeFileSync(
|
|
135
|
+
settingsPath(),
|
|
136
|
+
JSON.stringify({ "test.ns": { foo: "bar", count: 42 }, "other.ns": { baz: 1 } }),
|
|
137
|
+
"utf-8",
|
|
138
|
+
);
|
|
139
|
+
setConfigEnabled("test.ns", false);
|
|
140
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
141
|
+
expect(data["test.ns"]).toEqual({ foo: "bar", count: 42, enabled: false });
|
|
142
|
+
expect(data["other.ns"]).toEqual({ baz: 1 });
|
|
143
|
+
expect(isConfigEnabled("test.ns")).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("set to true on { enabled: false } removes the namespace entirely (zero keys)", () => {
|
|
147
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: false } }), "utf-8");
|
|
148
|
+
setConfigEnabled("test.ns", true);
|
|
149
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
150
|
+
expect(data["test.ns"]).toBeUndefined();
|
|
151
|
+
expect(isConfigEnabled("test.ns")).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("set to true on { enabled: false, other: 1 } leaves the namespace as { other: 1 }", () => {
|
|
155
|
+
fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { enabled: false, other: 1 } }), "utf-8");
|
|
156
|
+
setConfigEnabled("test.ns", true);
|
|
157
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
158
|
+
expect(data["test.ns"]).toEqual({ other: 1 });
|
|
159
|
+
expect(isConfigEnabled("test.ns")).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
21
162
|
|
|
22
163
|
describe("loadConfig", () => {
|
|
23
164
|
beforeEach(() => {
|
package/src/settings-io.ts
CHANGED
|
@@ -40,3 +40,60 @@ export function saveConfig(namespace: string, config: object): void {
|
|
|
40
40
|
writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Delete a namespace section from settings.json entirely.
|
|
46
|
+
* No-op if the key is absent (including when the file doesn't exist — the file
|
|
47
|
+
* is never created). Atomic write, same pattern as saveConfig.
|
|
48
|
+
*/
|
|
49
|
+
export function removeConfig(namespace: string): void {
|
|
50
|
+
const full = readSettings();
|
|
51
|
+
// No file at all (readSettings returns {} for missing AND for corrupt,
|
|
52
|
+
// but only for missing is existsSync false) and no key → never create the file.
|
|
53
|
+
if (!existsSync(SETTINGS_PATH)) return;
|
|
54
|
+
if (!(namespace in full)) return;
|
|
55
|
+
delete full[namespace];
|
|
56
|
+
const tmpPath = SETTINGS_PATH + ".tmp";
|
|
57
|
+
writeFileSync(tmpPath, JSON.stringify(full, null, 2), "utf-8");
|
|
58
|
+
try {
|
|
59
|
+
renameSync(tmpPath, SETTINGS_PATH);
|
|
60
|
+
} catch {
|
|
61
|
+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
62
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True unless settings[namespace].enabled === false (strict comparison — a
|
|
68
|
+
* string "false" or 0 does NOT disable). Missing file/namespace/key ⇒ true.
|
|
69
|
+
*/
|
|
70
|
+
export function isConfigEnabled(namespace: string): boolean {
|
|
71
|
+
const cfg = loadConfig(namespace, {}) as { enabled?: boolean };
|
|
72
|
+
return cfg.enabled !== false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Turn a namespace's `enabled` gate on or off without touching the
|
|
77
|
+
* namespace's other keys or sibling namespaces.
|
|
78
|
+
*
|
|
79
|
+
* off: write settings[ns].enabled = false (merged — other keys survive).
|
|
80
|
+
* on: delete the `enabled` key; if that leaves the namespace with zero keys,
|
|
81
|
+
* remove the whole namespace key from the file.
|
|
82
|
+
*
|
|
83
|
+
* A namespace omitted from settings.json counts as enabled.
|
|
84
|
+
*/
|
|
85
|
+
export function setConfigEnabled(namespace: string, enabled: boolean): void {
|
|
86
|
+
if (enabled) {
|
|
87
|
+
const cfg = loadConfig(namespace, {}) as Record<string, unknown>;
|
|
88
|
+
delete cfg.enabled;
|
|
89
|
+
if (Object.keys(cfg).length === 0) {
|
|
90
|
+
removeConfig(namespace);
|
|
91
|
+
} else {
|
|
92
|
+
saveConfig(namespace, cfg);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
const cfg = loadConfig(namespace, {}) as Record<string, unknown>;
|
|
96
|
+
cfg.enabled = false;
|
|
97
|
+
saveConfig(namespace, cfg);
|
|
98
|
+
}
|
|
99
|
+
}
|
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() {
|
|
@@ -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
|
});
|
package/src/thinking/patch.ts
CHANGED
|
@@ -3,9 +3,6 @@ import { AssistantMessageComponent, VERSION } from "@earendil-works/pi-coding-ag
|
|
|
3
3
|
import { Markdown, type MarkdownOptions, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { buildMutedMarkdownTheme } from "./theme.js";
|
|
5
5
|
|
|
6
|
-
// The label we prepend to visible thinking content.
|
|
7
|
-
const THINKING_LABEL = "\x1b[1m\x1b[38;2;255;215;0mThinking...\x1b[39m\x1b[22m";
|
|
8
|
-
|
|
9
6
|
// Track which pi version we patched against to detect incompatibility
|
|
10
7
|
const PATCHED_KEY = Symbol.for("archimedes:thinkingPatched");
|
|
11
8
|
const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
|
|
@@ -16,8 +13,15 @@ const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
|
|
|
16
13
|
* to capture a fresh `getTheme` closure (required for /resume).
|
|
17
14
|
*
|
|
18
15
|
* Re-patches when pi version changes to catch breaking upstream changes.
|
|
16
|
+
*
|
|
17
|
+
* @param config Optional labelText/labelColor overrides for the thinking
|
|
18
|
+
* block header. When omitted (or empty/invalid), the original defaults
|
|
19
|
+
* ("Thinking..." / "255,215,0") are used, producing byte-identical output.
|
|
19
20
|
*/
|
|
20
|
-
export function patchThinkingRenderer(
|
|
21
|
+
export function patchThinkingRenderer(
|
|
22
|
+
getTheme: () => Theme,
|
|
23
|
+
config?: { labelText?: string; labelColor?: string },
|
|
24
|
+
): void {
|
|
21
25
|
if (!AssistantMessageComponent) return;
|
|
22
26
|
|
|
23
27
|
const proto = AssistantMessageComponent.prototype;
|
|
@@ -30,7 +34,13 @@ export function patchThinkingRenderer(getTheme: () => Theme): void {
|
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
const src = proto.updateContent.toString();
|
|
33
|
-
|
|
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-
|
|
59
|
-
|
|
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:
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// the
|
|
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"
|
|
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 =
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|