@pi-archimedes/core 2.7.3 → 2.9.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/README.md +6 -27
- package/package.json +8 -6
- package/src/bridge/channel.test.ts +52 -0
- package/src/bridge/channel.ts +322 -0
- package/src/bridge/events.ts +197 -0
- package/src/bridge/index.test.ts +722 -0
- package/src/bridge/index.ts +166 -0
- package/src/bridge/self-usage.test.ts +146 -0
- package/src/bridge/self-usage.ts +37 -0
- package/src/bus.ts +8 -1
- package/src/config.test.ts +8 -103
- package/src/config.ts +4 -66
- package/src/index.test.ts +19 -350
- package/src/index.ts +12 -269
- package/src/tool-render.ts +1 -1
- package/src/editor/index.test.ts +0 -820
- package/src/editor/index.ts +0 -379
- package/src/editor/spin-quips.test.ts +0 -106
- package/src/editor/spin-quips.ts +0 -44
- package/src/editor/spin.test.ts +0 -702
- package/src/editor/spin.ts +0 -471
- package/src/startup/capture.ts +0 -47
- package/src/startup/index.ts +0 -330
- package/src/startup/logo.test.ts +0 -260
- package/src/startup/logo.ts +0 -85
- package/src/startup/sections.test.ts +0 -314
- package/src/startup/sections.ts +0 -284
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
- package/src/thinking/patch.test.ts +0 -389
- package/src/thinking/patch.ts +0 -288
- package/src/thinking/theme.test.ts +0 -216
- package/src/thinking/theme.ts +0 -156
- package/src/thinking/transform.test.ts +0 -89
- package/src/thinking/transform.ts +0 -21
- package/src/thinking/unindent.test.ts +0 -74
- package/src/thinking/unindent.ts +0 -74
package/src/startup/version.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
const NPM_REGISTRY_URL = "https://registry.npmjs.org/@earendil-works/pi-coding-agent/latest";
|
|
2
|
-
const FETCH_TIMEOUT_MS = 4000;
|
|
3
|
-
|
|
4
|
-
export async function fetchLatestVersion(): Promise<string | undefined> {
|
|
5
|
-
try {
|
|
6
|
-
const controller = new AbortController();
|
|
7
|
-
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
8
|
-
const res = await fetch(NPM_REGISTRY_URL, { signal: controller.signal });
|
|
9
|
-
clearTimeout(timeout);
|
|
10
|
-
if (!res.ok) return undefined;
|
|
11
|
-
const data = (await res.json()) as { version?: string };
|
|
12
|
-
return data.version;
|
|
13
|
-
} catch {
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function compareVersions(a: string, b: string): number {
|
|
19
|
-
const pa = a.replace(/^v/, "").split(".").map(Number);
|
|
20
|
-
const pb = b.replace(/^v/, "").split(".").map(Number);
|
|
21
|
-
for (let i = 0; i < 3; i++) {
|
|
22
|
-
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
|
|
23
|
-
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
|
|
24
|
-
}
|
|
25
|
-
return 0;
|
|
26
|
-
}
|
|
@@ -1,389 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
// Symbols used by patch.ts to mark patched prototypes
|
|
4
|
-
const PATCHED_KEY = Symbol.for("archimedes:thinkingPatched");
|
|
5
|
-
const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
|
|
6
|
-
|
|
7
|
-
// Dynamic import after mocking the pi-coding-agent module
|
|
8
|
-
async function importPatch() {
|
|
9
|
-
vi.resetModules();
|
|
10
|
-
const mod = await import("./patch.js");
|
|
11
|
-
return mod.patchThinkingRenderer;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe("patchThinkingRenderer", () => {
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
vi.resetModules();
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("returns early when AssistantMessageComponent is undefined", async () => {
|
|
20
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
21
|
-
AssistantMessageComponent: undefined,
|
|
22
|
-
VERSION: "1.0.0",
|
|
23
|
-
highlightCode: vi.fn(),
|
|
24
|
-
}));
|
|
25
|
-
|
|
26
|
-
const patch = await importPatch();
|
|
27
|
-
// Should not throw
|
|
28
|
-
patch(() => ({} as any));
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("returns early when prototype is null", async () => {
|
|
32
|
-
const MockClass = function AssistantMessageComponent() {};
|
|
33
|
-
MockClass.prototype = null;
|
|
34
|
-
|
|
35
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
36
|
-
AssistantMessageComponent: MockClass,
|
|
37
|
-
VERSION: "1.0.0",
|
|
38
|
-
highlightCode: vi.fn(),
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
const patch = await importPatch();
|
|
42
|
-
patch(() => ({} as any));
|
|
43
|
-
// Prototype must still be null — no patching occurred
|
|
44
|
-
expect(MockClass.prototype).toBeNull();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("returns early when updateContent is not a function", async () => {
|
|
48
|
-
const MockClass = function AssistantMessageComponent() {};
|
|
49
|
-
MockClass.prototype.updateContent = "not a function";
|
|
50
|
-
|
|
51
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
52
|
-
AssistantMessageComponent: MockClass,
|
|
53
|
-
VERSION: "1.0.0",
|
|
54
|
-
highlightCode: vi.fn(),
|
|
55
|
-
}));
|
|
56
|
-
|
|
57
|
-
const patch = await importPatch();
|
|
58
|
-
patch(() => ({} as any));
|
|
59
|
-
// PATCHED_KEY must NOT be set — early return before patching
|
|
60
|
-
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("returns early when class name does not match", async () => {
|
|
64
|
-
const WrongName = function () {};
|
|
65
|
-
WrongName.prototype.updateContent = function () {};
|
|
66
|
-
|
|
67
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
68
|
-
AssistantMessageComponent: WrongName,
|
|
69
|
-
VERSION: "1.0.0",
|
|
70
|
-
highlightCode: vi.fn(),
|
|
71
|
-
}));
|
|
72
|
-
|
|
73
|
-
const patch = await importPatch();
|
|
74
|
-
patch(() => ({} as any));
|
|
75
|
-
// PATCHED_KEY must NOT be set — name mismatch causes early return
|
|
76
|
-
expect(WrongName.prototype[PATCHED_KEY]).toBeUndefined();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("returns early when source lacks thinking check", async () => {
|
|
80
|
-
const MockClass = function AssistantMessageComponent() {};
|
|
81
|
-
MockClass.prototype.updateContent = function updateContent() {
|
|
82
|
-
// Does NOT contain: content.type === "thinking"
|
|
83
|
-
this.doSomething();
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
87
|
-
AssistantMessageComponent: MockClass,
|
|
88
|
-
VERSION: "1.0.0",
|
|
89
|
-
highlightCode: vi.fn(),
|
|
90
|
-
}));
|
|
91
|
-
|
|
92
|
-
const patch = await importPatch();
|
|
93
|
-
patch(() => ({} as any));
|
|
94
|
-
// PATCHED_KEY must NOT be set — signature mismatch causes early return
|
|
95
|
-
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("returns early when source lacks markdownTheme reference", async () => {
|
|
99
|
-
const MockClass = function AssistantMessageComponent() {};
|
|
100
|
-
MockClass.prototype.updateContent = function updateContent() {
|
|
101
|
-
// Has thinking check but no markdownTheme
|
|
102
|
-
if (this.content.type === "thinking") {
|
|
103
|
-
this.render();
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
108
|
-
AssistantMessageComponent: MockClass,
|
|
109
|
-
VERSION: "1.0.0",
|
|
110
|
-
highlightCode: vi.fn(),
|
|
111
|
-
}));
|
|
112
|
-
|
|
113
|
-
const patch = await importPatch();
|
|
114
|
-
patch(() => ({} as any));
|
|
115
|
-
// PATCHED_KEY must NOT be set — signature mismatch causes early return
|
|
116
|
-
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("patches successfully when signature matches", async () => {
|
|
120
|
-
const MockClass = function AssistantMessageComponent() {};
|
|
121
|
-
MockClass.prototype.updateContent = function updateContent() {
|
|
122
|
-
if (this.content.type === "thinking") {
|
|
123
|
-
this.markdownTheme.codeBlockIndent = "";
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
128
|
-
AssistantMessageComponent: MockClass,
|
|
129
|
-
VERSION: "1.0.0",
|
|
130
|
-
highlightCode: vi.fn(),
|
|
131
|
-
}));
|
|
132
|
-
|
|
133
|
-
const patch = await importPatch();
|
|
134
|
-
patch(() => ({} as any));
|
|
135
|
-
|
|
136
|
-
// The prototype should be marked as patched
|
|
137
|
-
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
138
|
-
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("marks prototype with correct version", async () => {
|
|
142
|
-
const MockClass = function AssistantMessageComponent() {};
|
|
143
|
-
MockClass.prototype.updateContent = function updateContent() {
|
|
144
|
-
if (this.content.type === "thinking") {
|
|
145
|
-
this.markdownTheme.codeBlockIndent = "";
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
150
|
-
AssistantMessageComponent: MockClass,
|
|
151
|
-
VERSION: "2.5.0",
|
|
152
|
-
highlightCode: vi.fn(),
|
|
153
|
-
}));
|
|
154
|
-
|
|
155
|
-
const patch = await importPatch();
|
|
156
|
-
patch(() => ({} as any));
|
|
157
|
-
|
|
158
|
-
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("2.5.0");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("re-patches when version changes", async () => {
|
|
162
|
-
// First patch with version 1.0.0
|
|
163
|
-
const MockClassV1 = function AssistantMessageComponent() {};
|
|
164
|
-
MockClassV1.prototype.updateContent = function updateContent() {
|
|
165
|
-
if (this.content.type === "thinking") {
|
|
166
|
-
this.markdownTheme.codeBlockIndent = "";
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
171
|
-
AssistantMessageComponent: MockClassV1,
|
|
172
|
-
VERSION: "1.0.0",
|
|
173
|
-
highlightCode: vi.fn(),
|
|
174
|
-
}));
|
|
175
|
-
|
|
176
|
-
const patchV1 = await importPatch();
|
|
177
|
-
patchV1(() => ({} as any));
|
|
178
|
-
expect(MockClassV1.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
179
|
-
|
|
180
|
-
// Now simulate a version change with a fresh class
|
|
181
|
-
const MockClassV2 = function AssistantMessageComponent() {};
|
|
182
|
-
MockClassV2.prototype.updateContent = function updateContent() {
|
|
183
|
-
if (this.content.type === "thinking") {
|
|
184
|
-
this.markdownTheme.codeBlockIndent = "";
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
189
|
-
AssistantMessageComponent: MockClassV2,
|
|
190
|
-
VERSION: "2.0.0",
|
|
191
|
-
highlightCode: vi.fn(),
|
|
192
|
-
}));
|
|
193
|
-
|
|
194
|
-
const patchV2 = await importPatch();
|
|
195
|
-
patchV2(() => ({} as any));
|
|
196
|
-
expect(MockClassV2.prototype[PATCH_VERSION_KEY]).toBe("2.0.0");
|
|
197
|
-
});
|
|
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
|
-
|
|
247
|
-
it("re-patches on same version to update getTheme closure", async () => {
|
|
248
|
-
const MockClass = function AssistantMessageComponent() {};
|
|
249
|
-
MockClass.prototype.updateContent = function updateContent() {
|
|
250
|
-
if (this.content.type === "thinking") {
|
|
251
|
-
this.markdownTheme.codeBlockIndent = "";
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
256
|
-
AssistantMessageComponent: MockClass,
|
|
257
|
-
VERSION: "1.0.0",
|
|
258
|
-
highlightCode: vi.fn(),
|
|
259
|
-
}));
|
|
260
|
-
|
|
261
|
-
const patch = await importPatch();
|
|
262
|
-
|
|
263
|
-
// First patch
|
|
264
|
-
patch(() => ({} as any));
|
|
265
|
-
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
266
|
-
const first = MockClass.prototype.updateContent;
|
|
267
|
-
|
|
268
|
-
// Second patch (same version) — must produce a new function
|
|
269
|
-
patch(() => ({} as any));
|
|
270
|
-
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
271
|
-
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
272
|
-
// The patched function must be a new closure (not the same reference)
|
|
273
|
-
expect(MockClass.prototype.updateContent).not.toBe(first);
|
|
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
|
-
});
|
|
389
|
-
});
|
package/src/thinking/patch.ts
DELETED
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { AssistantMessageComponent, VERSION } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Markdown, type MarkdownOptions, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
|
|
4
|
-
import { buildMutedMarkdownTheme } from "./theme.js";
|
|
5
|
-
|
|
6
|
-
// Track which pi version we patched against to detect incompatibility
|
|
7
|
-
const PATCHED_KEY = Symbol.for("archimedes:thinkingPatched");
|
|
8
|
-
const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Patches `AssistantMessageComponent.prototype.updateContent` so thinking
|
|
12
|
-
* blocks render with a muted `MarkdownTheme`. Called on every session_start
|
|
13
|
-
* to capture a fresh `getTheme` closure (required for /resume).
|
|
14
|
-
*
|
|
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.
|
|
20
|
-
*/
|
|
21
|
-
export function patchThinkingRenderer(
|
|
22
|
-
getTheme: () => Theme,
|
|
23
|
-
config?: { labelText?: string; labelColor?: string },
|
|
24
|
-
): void {
|
|
25
|
-
if (!AssistantMessageComponent) return;
|
|
26
|
-
|
|
27
|
-
const proto = AssistantMessageComponent.prototype;
|
|
28
|
-
if (
|
|
29
|
-
!proto ||
|
|
30
|
-
typeof proto.updateContent !== "function" ||
|
|
31
|
-
AssistantMessageComponent.name !== "AssistantMessageComponent"
|
|
32
|
-
) {
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const src = proto.updateContent.toString();
|
|
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);
|
|
44
|
-
const hasMarkdownTheme = src.includes("this.markdownTheme");
|
|
45
|
-
if (!hasThinkingCheck || !hasMarkdownTheme) {
|
|
46
|
-
console.warn(
|
|
47
|
-
`[archimedes] Skipping thinking renderer patch — signature mismatch
|
|
48
|
-
hasThinkingCheck: ${hasThinkingCheck}, hasMarkdownTheme: ${hasMarkdownTheme}
|
|
49
|
-
This likely means pi's AssistantMessageComponent changed. The muted theme
|
|
50
|
-
for thinking blocks will not be applied.`,
|
|
51
|
-
);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Check if already patched against the current pi version
|
|
56
|
-
const currentVersion = VERSION ?? "unknown";
|
|
57
|
-
const patchVersion = (proto as any)[PATCH_VERSION_KEY] as string | undefined;
|
|
58
|
-
if ((proto as any)[PATCHED_KEY] && patchVersion === currentVersion) {
|
|
59
|
-
// Already patched for this version — just update the closure by re-patching
|
|
60
|
-
// (needed for /resume to get fresh getTheme)
|
|
61
|
-
} else {
|
|
62
|
-
// First patch or version changed — warn if version mismatch
|
|
63
|
-
if ((proto as any)[PATCHED_KEY] && patchVersion && patchVersion !== currentVersion) {
|
|
64
|
-
console.warn(`[archimedes] Re-patching thinking renderer: pi version changed ${patchVersion} → ${currentVersion}`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
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 {
|
|
85
|
-
this.lastMessage = message;
|
|
86
|
-
if (isStreaming !== undefined) this.isStreaming = isStreaming;
|
|
87
|
-
|
|
88
|
-
this.markdownTheme.codeBlockIndent = "";
|
|
89
|
-
this.contentContainer.clear();
|
|
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
|
-
|
|
106
|
-
const hasVisibleContent = message.content.some(
|
|
107
|
-
(c: any) =>
|
|
108
|
-
(c.type === "text" && c.text.trim()) ||
|
|
109
|
-
(c.type === "thinking" && c.thinking.trim()),
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
if (hasVisibleContent) {
|
|
113
|
-
this.contentContainer.addChild(new Spacer(1));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Lazy muted theme: built once per updateContent call.
|
|
117
|
-
let mutedTheme: ReturnType<typeof buildMutedMarkdownTheme> | undefined;
|
|
118
|
-
let theme: Theme | undefined;
|
|
119
|
-
let themeFailed = false;
|
|
120
|
-
|
|
121
|
-
const ensureTheme = (): Theme | undefined => {
|
|
122
|
-
if (themeFailed) return undefined;
|
|
123
|
-
if (!theme) {
|
|
124
|
-
try {
|
|
125
|
-
theme = getTheme();
|
|
126
|
-
} catch {
|
|
127
|
-
themeFailed = true;
|
|
128
|
-
return undefined;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return theme;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const ensureMuted = (): MarkdownTheme | undefined => {
|
|
135
|
-
if (!mutedTheme) {
|
|
136
|
-
const t = ensureTheme();
|
|
137
|
-
if (!t) return undefined;
|
|
138
|
-
mutedTheme = buildMutedMarkdownTheme(t);
|
|
139
|
-
}
|
|
140
|
-
return mutedTheme;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
// Pi's native updateContent passes a `transform` (createMarkdownTransform)
|
|
144
|
-
// to Markdown so the markdown-transformer pipeline runs — that is what
|
|
145
|
-
// renders Mermaid blocks to ASCII and applies any extension transformers.
|
|
146
|
-
// We must preserve it here, otherwise those transformers are silently
|
|
147
|
-
// dropped when this patch replaces updateContent.
|
|
148
|
-
//
|
|
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.
|
|
154
|
-
type MarkdownOptionsWithTransform = MarkdownOptions & {
|
|
155
|
-
transform?: (markdown: string, availableWidth: number) => string;
|
|
156
|
-
};
|
|
157
|
-
const transformFor =
|
|
158
|
-
(messageType: "assistant" | "assistant-thinking") =>
|
|
159
|
-
(markdown: string, availableWidth: number): string => {
|
|
160
|
-
let out = markdown;
|
|
161
|
-
for (const transformer of (this as any).markdownTransformers ?? []) {
|
|
162
|
-
try {
|
|
163
|
-
const transformed = transformer(out, {
|
|
164
|
-
messageType,
|
|
165
|
-
isStreaming: this.isStreaming,
|
|
166
|
-
availableWidth,
|
|
167
|
-
});
|
|
168
|
-
if (typeof transformed === "string") out = transformed;
|
|
169
|
-
} catch {
|
|
170
|
-
// Keep the current markdown and continue with the next transformer.
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return out;
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
// Render content in order.
|
|
177
|
-
for (let i = 0; i < message.content.length; i++) {
|
|
178
|
-
const content = message.content[i];
|
|
179
|
-
if (content.type === "text" && content.text.trim()) {
|
|
180
|
-
this.contentContainer.addChild(
|
|
181
|
-
new Markdown(
|
|
182
|
-
content.text.trim(),
|
|
183
|
-
this.outputPad ?? 1,
|
|
184
|
-
0,
|
|
185
|
-
this.markdownTheme,
|
|
186
|
-
undefined,
|
|
187
|
-
{ transform: transformFor("assistant") } as MarkdownOptionsWithTransform,
|
|
188
|
-
),
|
|
189
|
-
);
|
|
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
|
-
|
|
204
|
-
const hasVisibleContentAfter = message.content
|
|
205
|
-
.slice(i + 1)
|
|
206
|
-
.some(
|
|
207
|
-
(c: any) =>
|
|
208
|
-
(c.type === "text" && c.text.trim()) ||
|
|
209
|
-
(c.type === "thinking" && c.thinking.trim()),
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
if (this.hideThinkingBlock) {
|
|
213
|
-
// One static label for the whole run when hidden.
|
|
214
|
-
const t = ensureTheme();
|
|
215
|
-
if (!t) continue;
|
|
216
|
-
this.contentContainer.addChild(
|
|
217
|
-
new Text(t.italic(t.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad ?? 1, 0),
|
|
218
|
-
);
|
|
219
|
-
if (hasVisibleContentAfter) {
|
|
220
|
-
this.contentContainer.addChild(new Spacer(1));
|
|
221
|
-
}
|
|
222
|
-
} else {
|
|
223
|
-
let thinkingContent = thinkBlocks.join("\n\n");
|
|
224
|
-
const label = buildThinkingLabel();
|
|
225
|
-
if (!thinkingContent.startsWith(label)) {
|
|
226
|
-
thinkingContent = `${label}\n\n${thinkingContent}`;
|
|
227
|
-
}
|
|
228
|
-
const t = ensureTheme();
|
|
229
|
-
if (!t) continue;
|
|
230
|
-
const muted = ensureMuted();
|
|
231
|
-
this.contentContainer.addChild(
|
|
232
|
-
new Markdown(
|
|
233
|
-
thinkingContent,
|
|
234
|
-
this.outputPad ?? 1,
|
|
235
|
-
0,
|
|
236
|
-
muted ?? this.markdownTheme,
|
|
237
|
-
{
|
|
238
|
-
color: (text: string) => t.fg("thinkingText", text),
|
|
239
|
-
italic: true,
|
|
240
|
-
},
|
|
241
|
-
{ transform: transformFor("assistant-thinking") } as MarkdownOptionsWithTransform,
|
|
242
|
-
),
|
|
243
|
-
);
|
|
244
|
-
if (hasVisibleContentAfter) {
|
|
245
|
-
this.contentContainer.addChild(new Spacer(1));
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
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.
|
|
253
|
-
const hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
|
|
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) {
|
|
264
|
-
if (message.stopReason === "aborted") {
|
|
265
|
-
const abortMessage =
|
|
266
|
-
message.errorMessage && message.errorMessage !== "Request was aborted"
|
|
267
|
-
? message.errorMessage
|
|
268
|
-
: "Operation aborted";
|
|
269
|
-
this.contentContainer.addChild(new Spacer(1));
|
|
270
|
-
const t = ensureTheme();
|
|
271
|
-
if (t) this.contentContainer.addChild(new Text(t.fg("error", abortMessage), this.outputPad ?? 1, 0));
|
|
272
|
-
} else if (message.stopReason === "error") {
|
|
273
|
-
const errorMsg = message.errorMessage || "Unknown error";
|
|
274
|
-
this.contentContainer.addChild(new Spacer(1));
|
|
275
|
-
const t = ensureTheme();
|
|
276
|
-
if (t) {
|
|
277
|
-
this.contentContainer.addChild(
|
|
278
|
-
new Text(t.fg("error", `Error: ${errorMsg}`), this.outputPad ?? 1, 0),
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
// Mark as patched with version for incompatibility detection
|
|
286
|
-
(proto as any)[PATCHED_KEY] = true;
|
|
287
|
-
(proto as any)[PATCH_VERSION_KEY] = currentVersion;
|
|
288
|
-
}
|