@pi-archimedes/ui 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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/package.json +44 -0
  4. package/src/bash/index.ts +10 -0
  5. package/src/bash/renderer.test.ts +424 -0
  6. package/src/bash/renderer.ts +301 -0
  7. package/src/bash/tool.test.ts +26 -0
  8. package/src/bash/tool.ts +17 -0
  9. package/src/codemode/index.ts +20 -0
  10. package/src/codemode/loader.test.ts +139 -0
  11. package/src/codemode/loader.ts +115 -0
  12. package/src/codemode/renderer.test.ts +717 -0
  13. package/src/codemode/renderer.ts +399 -0
  14. package/src/codemode/tool.test.ts +149 -0
  15. package/src/codemode/tool.ts +96 -0
  16. package/src/config.test.ts +199 -0
  17. package/src/config.ts +129 -0
  18. package/src/editor/index.test.ts +820 -0
  19. package/src/editor/index.ts +380 -0
  20. package/src/editor/spin-quips.test.ts +106 -0
  21. package/src/editor/spin-quips.ts +44 -0
  22. package/src/editor/spin.test.ts +763 -0
  23. package/src/editor/spin.ts +493 -0
  24. package/src/index.test.ts +402 -0
  25. package/src/index.ts +235 -0
  26. package/src/migration.test.ts +256 -0
  27. package/src/migration.ts +94 -0
  28. package/src/settings.test.ts +122 -0
  29. package/src/settings.ts +135 -0
  30. package/src/startup/capture.ts +47 -0
  31. package/src/startup/index.test.ts +175 -0
  32. package/src/startup/index.ts +348 -0
  33. package/src/startup/logo.test.ts +260 -0
  34. package/src/startup/logo.ts +85 -0
  35. package/src/startup/sections.test.ts +333 -0
  36. package/src/startup/sections.ts +306 -0
  37. package/src/startup/version.test.ts +98 -0
  38. package/src/startup/version.ts +26 -0
  39. package/src/thinking/patch.test.ts +857 -0
  40. package/src/thinking/patch.ts +407 -0
  41. package/src/thinking/theme.test.ts +216 -0
  42. package/src/thinking/theme.ts +156 -0
  43. package/src/thinking/transform.test.ts +89 -0
  44. package/src/thinking/transform.ts +21 -0
  45. package/src/thinking/unindent.test.ts +74 -0
  46. package/src/thinking/unindent.ts +74 -0
@@ -0,0 +1,857 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import type { ThinkingStyle } from "../config.js";
3
+
4
+ // Symbols used by patch.ts to mark patched prototypes
5
+ const PATCHED_KEY = Symbol.for("archimedes:thinkingPatched");
6
+ const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
7
+ const THINKING_STATES_KEY = Symbol.for("archimedes:thinkingStateOverrides");
8
+
9
+ // Dynamic import after mocking the pi-coding-agent module
10
+ async function importPatch() {
11
+ vi.resetModules();
12
+ const mod = await import("./patch.js");
13
+ return mod.patchThinkingRenderer;
14
+ }
15
+
16
+ describe("patchThinkingRenderer", () => {
17
+ afterEach(() => {
18
+ vi.resetModules();
19
+ });
20
+
21
+ it("returns early when AssistantMessageComponent is undefined", async () => {
22
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
23
+ AssistantMessageComponent: undefined,
24
+ VERSION: "1.0.0",
25
+ highlightCode: vi.fn(),
26
+ }));
27
+
28
+ const patch = await importPatch();
29
+ // Should not throw
30
+ patch(() => ({} as any));
31
+ });
32
+
33
+ it("returns early when prototype is null", async () => {
34
+ const MockClass = function AssistantMessageComponent() {};
35
+ MockClass.prototype = null;
36
+
37
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
38
+ AssistantMessageComponent: MockClass,
39
+ VERSION: "1.0.0",
40
+ highlightCode: vi.fn(),
41
+ }));
42
+
43
+ const patch = await importPatch();
44
+ patch(() => ({} as any));
45
+ // Prototype must still be null — no patching occurred
46
+ expect(MockClass.prototype).toBeNull();
47
+ });
48
+
49
+ it("returns early when updateContent is not a function", async () => {
50
+ const MockClass = function AssistantMessageComponent() {};
51
+ MockClass.prototype.updateContent = "not a function";
52
+
53
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
54
+ AssistantMessageComponent: MockClass,
55
+ VERSION: "1.0.0",
56
+ highlightCode: vi.fn(),
57
+ }));
58
+
59
+ const patch = await importPatch();
60
+ patch(() => ({} as any));
61
+ // PATCHED_KEY must NOT be set — early return before patching
62
+ expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
63
+ });
64
+
65
+ it("returns early when class name does not match", async () => {
66
+ const WrongName = function () {};
67
+ WrongName.prototype.updateContent = function () {};
68
+
69
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
70
+ AssistantMessageComponent: WrongName,
71
+ VERSION: "1.0.0",
72
+ highlightCode: vi.fn(),
73
+ }));
74
+
75
+ const patch = await importPatch();
76
+ patch(() => ({} as any));
77
+ // PATCHED_KEY must NOT be set — name mismatch causes early return
78
+ expect(WrongName.prototype[PATCHED_KEY]).toBeUndefined();
79
+ });
80
+
81
+ it("returns early when source lacks thinking check", async () => {
82
+ const MockClass = function AssistantMessageComponent() {};
83
+ MockClass.prototype.updateContent = function updateContent() {
84
+ // Does NOT contain: content.type === "thinking"
85
+ this.doSomething();
86
+ };
87
+
88
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
89
+ AssistantMessageComponent: MockClass,
90
+ VERSION: "1.0.0",
91
+ highlightCode: vi.fn(),
92
+ }));
93
+
94
+ const patch = await importPatch();
95
+ patch(() => ({} as any));
96
+ // PATCHED_KEY must NOT be set — signature mismatch causes early return
97
+ expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
98
+ });
99
+
100
+ it("returns early when source lacks markdownTheme reference", async () => {
101
+ const MockClass = function AssistantMessageComponent() {};
102
+ MockClass.prototype.updateContent = function updateContent() {
103
+ // Has thinking check but no markdownTheme
104
+ if (this.content.type === "thinking") {
105
+ this.render();
106
+ }
107
+ };
108
+
109
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
110
+ AssistantMessageComponent: MockClass,
111
+ VERSION: "1.0.0",
112
+ highlightCode: vi.fn(),
113
+ }));
114
+
115
+ const patch = await importPatch();
116
+ patch(() => ({} as any));
117
+ // PATCHED_KEY must NOT be set — signature mismatch causes early return
118
+ expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
119
+ });
120
+
121
+ it("patches successfully when signature matches", async () => {
122
+ const MockClass = function AssistantMessageComponent() {};
123
+ MockClass.prototype.updateContent = function updateContent() {
124
+ if (this.content.type === "thinking") {
125
+ this.markdownTheme.codeBlockIndent = "";
126
+ }
127
+ };
128
+
129
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
130
+ AssistantMessageComponent: MockClass,
131
+ VERSION: "1.0.0",
132
+ highlightCode: vi.fn(),
133
+ }));
134
+
135
+ const patch = await importPatch();
136
+ patch(() => ({} as any));
137
+
138
+ // The prototype should be marked as patched
139
+ expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
140
+ expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
141
+ });
142
+
143
+ it("marks prototype with correct version", async () => {
144
+ const MockClass = function AssistantMessageComponent() {};
145
+ MockClass.prototype.updateContent = function updateContent() {
146
+ if (this.content.type === "thinking") {
147
+ this.markdownTheme.codeBlockIndent = "";
148
+ }
149
+ };
150
+
151
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
152
+ AssistantMessageComponent: MockClass,
153
+ VERSION: "2.5.0",
154
+ highlightCode: vi.fn(),
155
+ }));
156
+
157
+ const patch = await importPatch();
158
+ patch(() => ({} as any));
159
+
160
+ expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("2.5.0");
161
+ });
162
+
163
+ it("re-patches when version changes", async () => {
164
+ // First patch with version 1.0.0
165
+ const MockClassV1 = function AssistantMessageComponent() {};
166
+ MockClassV1.prototype.updateContent = function updateContent() {
167
+ if (this.content.type === "thinking") {
168
+ this.markdownTheme.codeBlockIndent = "";
169
+ }
170
+ };
171
+
172
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
173
+ AssistantMessageComponent: MockClassV1,
174
+ VERSION: "1.0.0",
175
+ highlightCode: vi.fn(),
176
+ }));
177
+
178
+ const patchV1 = await importPatch();
179
+ patchV1(() => ({} as any));
180
+ expect(MockClassV1.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
181
+
182
+ // Now simulate a version change with a fresh class
183
+ const MockClassV2 = function AssistantMessageComponent() {};
184
+ MockClassV2.prototype.updateContent = function updateContent() {
185
+ if (this.content.type === "thinking") {
186
+ this.markdownTheme.codeBlockIndent = "";
187
+ }
188
+ };
189
+
190
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
191
+ AssistantMessageComponent: MockClassV2,
192
+ VERSION: "2.0.0",
193
+ highlightCode: vi.fn(),
194
+ }));
195
+
196
+ const patchV2 = await importPatch();
197
+ patchV2(() => ({} as any));
198
+ expect(MockClassV2.prototype[PATCH_VERSION_KEY]).toBe("2.0.0");
199
+ });
200
+
201
+ it("accepts a minified thinking-check variant (no whitespace around ===)", async () => {
202
+ const MockClass = function AssistantMessageComponent() {};
203
+ MockClass.prototype.updateContent = function updateContent() {
204
+ // Minified dist-chunk shape: no whitespace around ===, single quotes
205
+ const content = { type: "thinking" };
206
+ if (content.type==="thinking") {
207
+ this.markdownTheme.codeBlockIndent="";
208
+ }
209
+ };
210
+
211
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
212
+ AssistantMessageComponent: MockClass,
213
+ VERSION: "1.0.0",
214
+ highlightCode: vi.fn(),
215
+ }));
216
+
217
+ const patch = await importPatch();
218
+ patch(() => ({} as any));
219
+
220
+ // The minification-safe regex probe must accept the minified variant
221
+ expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
222
+ });
223
+
224
+ it("rejects a negated thinking check", async () => {
225
+ const MockClass = function AssistantMessageComponent() {};
226
+ MockClass.prototype.updateContent = function updateContent() {
227
+ // pi 0.84.3's own minified chunk contains
228
+ // `thinkingContent.type!=="thinking"` (inner batch-loop break). A source whose
229
+ // ONLY thinking-relations are negations must NOT pass the probe.
230
+ const content = { type: "thinking" };
231
+ if (content.type!=="thinking") {
232
+ this.markdownTheme.codeBlockIndent="";
233
+ }
234
+ };
235
+
236
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
237
+ AssistantMessageComponent: MockClass,
238
+ VERSION: "1.0.0",
239
+ highlightCode: vi.fn(),
240
+ }));
241
+
242
+ const patch = await importPatch();
243
+ patch(() => ({} as any));
244
+
245
+ // PATCHED_KEY must NOT be set — the probe must not match `!==`
246
+ expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
247
+ });
248
+
249
+ it("re-patches on same version to update getTheme closure", async () => {
250
+ const MockClass = function AssistantMessageComponent() {};
251
+ MockClass.prototype.updateContent = function updateContent() {
252
+ if (this.content.type === "thinking") {
253
+ this.markdownTheme.codeBlockIndent = "";
254
+ }
255
+ };
256
+
257
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
258
+ AssistantMessageComponent: MockClass,
259
+ VERSION: "1.0.0",
260
+ highlightCode: vi.fn(),
261
+ }));
262
+
263
+ const patch = await importPatch();
264
+
265
+ // First patch
266
+ patch(() => ({} as any));
267
+ expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
268
+ const first = MockClass.prototype.updateContent;
269
+
270
+ // Second patch (same version) — must produce a new function
271
+ patch(() => ({} as any));
272
+ expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
273
+ expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
274
+ // The patched function must be a new closure (not the same reference)
275
+ expect(MockClass.prototype.updateContent).not.toBe(first);
276
+ });
277
+
278
+ // ── Configurable thinking label (issue #36) ────────────────────────────
279
+
280
+ // Patches with a valid AssistantMessageComponent + mocked pi-tui so the
281
+ // Markdown content rendered for a thinking block can be inspected.
282
+ async function patchAndRender(
283
+ config?: { labelText?: string; labelColor?: string },
284
+ thinkingText = "Let me consider this carefully.",
285
+ ) {
286
+ const MockClass = function AssistantMessageComponent() {};
287
+ MockClass.prototype.updateContent = function updateContent() {
288
+ if (this.content.type === "thinking") {
289
+ this.markdownTheme.codeBlockIndent = "";
290
+ }
291
+ };
292
+
293
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
294
+ AssistantMessageComponent: MockClass,
295
+ VERSION: "1.0.0",
296
+ highlightCode: vi.fn(),
297
+ }));
298
+
299
+ const capturedMarkdown: any[] = [];
300
+ class MockMarkdown {
301
+ content: string;
302
+ constructor(content: string, ..._rest: any[]) {
303
+ this.content = content;
304
+ capturedMarkdown.push(this);
305
+ }
306
+ }
307
+ class MockSpacer {}
308
+ class MockText {}
309
+ class MockTruncatedText {}
310
+ class MockMouseRegion {
311
+ child: any;
312
+ onMouse: any;
313
+ constructor(child: any, onMouse: any) {
314
+ this.child = child;
315
+ this.onMouse = onMouse;
316
+ }
317
+ }
318
+ vi.doMock("@earendil-works/pi-tui", () => ({
319
+ Markdown: MockMarkdown,
320
+ Spacer: MockSpacer,
321
+ Text: MockText,
322
+ TruncatedText: MockTruncatedText,
323
+ MouseRegion: MockMouseRegion,
324
+ }));
325
+
326
+ vi.resetModules();
327
+ const mod = await import("./patch.js");
328
+ mod.patchThinkingRenderer(
329
+ () => ({ getFgAnsi: () => "", fg: (_t: string, text: string) => text }) as any,
330
+ config,
331
+ );
332
+
333
+ const instance = {
334
+ contentContainer: { clear: vi.fn(), addChild: vi.fn() },
335
+ isStreaming: false,
336
+ markdownTheme: { codeBlockIndent: "" },
337
+ markdownTransformers: [],
338
+ hideThinkingBlock: false,
339
+ outputPad: 1,
340
+ };
341
+ const message = {
342
+ content: [{ type: "thinking", thinking: thinkingText }],
343
+ stopReason: undefined,
344
+ };
345
+ MockClass.prototype.updateContent.call(instance, message, false);
346
+ return capturedMarkdown;
347
+ }
348
+
349
+ it("uses configured labelText/labelColor for the thinking label", async () => {
350
+ const captured = await patchAndRender({
351
+ labelText: "Yapping...",
352
+ labelColor: "255,215,0",
353
+ });
354
+
355
+ expect(captured).toHaveLength(1);
356
+ expect(
357
+ captured[0]!.content.startsWith("\x1b[3m\x1b[38;2;255;215;0mYapping...\x1b[39m\x1b[23m\n\n"),
358
+ ).toBe(true);
359
+ });
360
+
361
+ it("defaults to the original Thinking... label when no config is given", async () => {
362
+ const captured = await patchAndRender();
363
+
364
+ expect(captured).toHaveLength(1);
365
+ // Italic truecolor label
366
+ expect(
367
+ captured[0]!.content.startsWith("\x1b[3m\x1b[38;2;255;215;0mThinking...\x1b[39m\x1b[23m\n\n"),
368
+ ).toBe(true);
369
+ });
370
+
371
+ it("trims whitespace from configured labelText and labelColor", async () => {
372
+ const captured = await patchAndRender({
373
+ labelText: " Yapping... ",
374
+ labelColor: " 255, 215, 0 ",
375
+ });
376
+
377
+ expect(
378
+ captured[0]!.content.startsWith("\x1b[3m\x1b[38;2;255;215;0mYapping...\x1b[39m\x1b[23m\n\n"),
379
+ ).toBe(true);
380
+ });
381
+
382
+ it("falls back to 255,215,0 when labelColor is not a valid RGB triple", async () => {
383
+ const captured = await patchAndRender({
384
+ labelText: "Hmm",
385
+ labelColor: "not-a-color",
386
+ });
387
+
388
+ expect(
389
+ captured[0]!.content.startsWith("\x1b[3m\x1b[38;2;255;215;0mHmm\x1b[39m\x1b[23m\n\n"),
390
+ ).toBe(true);
391
+ });
392
+
393
+ it("does not double-prepend the label when content already starts with it", async () => {
394
+ const label = "\x1b[3m\x1b[38;2;255;215;0mYapping...\x1b[39m\x1b[23m";
395
+ const captured = await patchAndRender(
396
+ { labelText: "Yapping...", labelColor: "255,215,0" },
397
+ `${label}\n\nAlready labelled body.`,
398
+ );
399
+
400
+ expect(captured[0]!.content).toBe(`${label}\n\nAlready labelled body.`);
401
+ });
402
+
403
+ // ── Mouse click-to-toggle (MouseRegion) ─────────────────────────────
404
+
405
+ async function renderThinkingComponent(options?: {
406
+ hideThinkingBlock?: boolean;
407
+ message?: any;
408
+ isStreaming?: boolean;
409
+ config?: {
410
+ labelText?: string;
411
+ labelColor?: string;
412
+ autoCollapseThinking?: boolean;
413
+ thinkingStyle?: ThinkingStyle;
414
+ };
415
+ }) {
416
+ const MockClass = function AssistantMessageComponent() {};
417
+ MockClass.prototype.updateContent = function updateContent() {
418
+ if (this.content.type === "thinking") {
419
+ this.markdownTheme.codeBlockIndent = "";
420
+ }
421
+ };
422
+
423
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
424
+ AssistantMessageComponent: MockClass,
425
+ VERSION: "1.0.0",
426
+ highlightCode: vi.fn(),
427
+ }));
428
+
429
+ class MockMarkdown {
430
+ content: string;
431
+ constructor(content: string, ..._rest: any[]) {
432
+ this.content = content;
433
+ }
434
+ }
435
+ class MockSpacer {}
436
+ class MockText {
437
+ text: string;
438
+ constructor(text: string, ..._rest: any[]) {
439
+ this.text = text;
440
+ }
441
+ }
442
+ class MockTruncatedText {
443
+ text: string;
444
+ constructor(text: string, ..._rest: any[]) {
445
+ this.text = text;
446
+ }
447
+ }
448
+ class MockMouseRegion {
449
+ child: any;
450
+ onMouse: (event: any) => any;
451
+ constructor(child: any, onMouse: any) {
452
+ this.child = child;
453
+ this.onMouse = onMouse;
454
+ }
455
+ }
456
+ vi.doMock("@earendil-works/pi-tui", () => ({
457
+ Markdown: MockMarkdown,
458
+ Spacer: MockSpacer,
459
+ Text: MockText,
460
+ TruncatedText: MockTruncatedText,
461
+ MouseRegion: MockMouseRegion,
462
+ }));
463
+
464
+ vi.resetModules();
465
+ const mod = await import("./patch.js");
466
+ mod.patchThinkingRenderer(
467
+ () => ({ getFgAnsi: () => "", fg: (_t: string, text: string) => text, italic: (text: string) => text }) as any,
468
+ options?.config,
469
+ );
470
+
471
+ const addedChildren: any[] = [];
472
+ const instance: any = {
473
+ contentContainer: {
474
+ clear: vi.fn(),
475
+ addChild: vi.fn((child: any) => {
476
+ addedChildren.push(child);
477
+ }),
478
+ },
479
+ isStreaming: false,
480
+ markdownTheme: { codeBlockIndent: "" },
481
+ markdownTransformers: [],
482
+ hideThinkingBlock: options?.hideThinkingBlock ?? false,
483
+ hiddenThinkingLabel: "Thinking...",
484
+ outputPad: 1,
485
+ updateContent: vi.fn(function (this: any, message: any, isStreaming?: boolean) {
486
+ return MockClass.prototype.updateContent.call(this, message, isStreaming);
487
+ }),
488
+ };
489
+ const message = options?.message ?? {
490
+ content: [{ type: "thinking", thinking: "Testing thinking toggle" }],
491
+ stopReason: undefined,
492
+ };
493
+ const isStreaming = options?.isStreaming ?? false;
494
+ instance.updateContent(message, isStreaming);
495
+ return { instance, addedChildren, MockMouseRegion, MockMarkdown, MockText, MockTruncatedText };
496
+ }
497
+
498
+ it("wraps expanded thinking block in MouseRegion with Markdown child", async () => {
499
+ const { addedChildren, MockMouseRegion, MockMarkdown } = await renderThinkingComponent({
500
+ hideThinkingBlock: false,
501
+ });
502
+ const mouseRegions = addedChildren.filter((c) => c instanceof MockMouseRegion);
503
+ expect(mouseRegions).toHaveLength(1);
504
+ expect(mouseRegions[0]!.child).toBeInstanceOf(MockMarkdown);
505
+ });
506
+
507
+ it("wraps hidden thinking block in MouseRegion with Text child", async () => {
508
+ const { addedChildren, MockMouseRegion, MockText } = await renderThinkingComponent({
509
+ hideThinkingBlock: true,
510
+ });
511
+ const mouseRegions = addedChildren.filter((c) => c instanceof MockMouseRegion);
512
+ expect(mouseRegions).toHaveLength(1);
513
+ expect(mouseRegions[0]!.child).toBeInstanceOf(MockText);
514
+ });
515
+
516
+ it("left-click toggles thinkingVisibilityOverrides and re-invokes updateContent", async () => {
517
+ const { instance, addedChildren, MockMouseRegion } = await renderThinkingComponent({
518
+ hideThinkingBlock: false,
519
+ });
520
+ const mouseRegion = addedChildren.find((c) => c instanceof MockMouseRegion);
521
+ expect(mouseRegion).toBeDefined();
522
+
523
+ expect(instance.thinkingVisibilityOverrides).toBeInstanceOf(Map);
524
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBeUndefined();
525
+
526
+ const initialUpdateCalls = instance.updateContent.mock.calls.length;
527
+
528
+ const result = mouseRegion!.onMouse({ type: "click", button: "left" });
529
+ expect(result).toEqual({ handled: true });
530
+
531
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(true);
532
+ expect(instance.updateContent).toHaveBeenCalledTimes(initialUpdateCalls + 1);
533
+ expect(instance.updateContent).toHaveBeenLastCalledWith(instance.lastMessage);
534
+ });
535
+
536
+ it("left-click on hidden thinking block toggles override to false", async () => {
537
+ const { instance, addedChildren, MockMouseRegion } = await renderThinkingComponent({
538
+ hideThinkingBlock: true,
539
+ });
540
+ const mouseRegion = addedChildren.find((c) => c instanceof MockMouseRegion);
541
+ expect(mouseRegion).toBeDefined();
542
+
543
+ const result = mouseRegion!.onMouse({ type: "click", button: "left" });
544
+ expect(result).toEqual({ handled: true });
545
+
546
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(false);
547
+ });
548
+
549
+ it("ignores non-left-click or non-click mouse events", async () => {
550
+ const { instance, addedChildren, MockMouseRegion } = await renderThinkingComponent({
551
+ hideThinkingBlock: false,
552
+ });
553
+ const mouseRegion = addedChildren.find((c) => c instanceof MockMouseRegion);
554
+ expect(mouseRegion).toBeDefined();
555
+
556
+ const callsBefore = instance.updateContent.mock.calls.length;
557
+
558
+ expect(mouseRegion!.onMouse({ type: "click", button: "right" })).toBeUndefined();
559
+ expect(mouseRegion!.onMouse({ type: "move" })).toBeUndefined();
560
+
561
+ expect(instance.thinkingVisibilityOverrides.has(0)).toBe(false);
562
+ expect(instance.updateContent).toHaveBeenCalledTimes(callsBefore);
563
+ });
564
+
565
+ it("tracks separate run indices for multiple thinking blocks", async () => {
566
+ const message = {
567
+ content: [
568
+ { type: "thinking", thinking: "First thought" },
569
+ { type: "text", text: "Answer 1" },
570
+ { type: "thinking", thinking: "Second thought" },
571
+ ],
572
+ };
573
+ const { instance, addedChildren, MockMouseRegion } = await renderThinkingComponent({
574
+ hideThinkingBlock: false,
575
+ message,
576
+ });
577
+ const mouseRegions = addedChildren.filter((c) => c instanceof MockMouseRegion);
578
+ expect(mouseRegions).toHaveLength(2);
579
+
580
+ mouseRegions[1]!.onMouse({ type: "click", button: "left" });
581
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBeUndefined();
582
+ expect(instance.thinkingVisibilityOverrides.get(1)).toBe(true);
583
+ });
584
+
585
+ describe("autoCollapseThinking", () => {
586
+ it("renders expanded Markdown while actively streaming thinking", async () => {
587
+ const { addedChildren, MockMouseRegion, MockMarkdown } = await renderThinkingComponent({
588
+ config: { autoCollapseThinking: true },
589
+ isStreaming: true,
590
+ message: {
591
+ content: [{ type: "thinking", thinking: "Streamed thought" }],
592
+ },
593
+ });
594
+ const mouseRegions = addedChildren.filter((c) => c instanceof MockMouseRegion);
595
+ expect(mouseRegions).toHaveLength(1);
596
+ expect(mouseRegions[0]!.child).toBeInstanceOf(MockMarkdown);
597
+ });
598
+
599
+ it("collapses to Text once streaming ends", async () => {
600
+ const { addedChildren, MockMouseRegion, MockText } = await renderThinkingComponent({
601
+ config: { autoCollapseThinking: true },
602
+ isStreaming: false,
603
+ message: {
604
+ content: [{ type: "thinking", thinking: "Finished thought" }],
605
+ },
606
+ });
607
+ const mouseRegions = addedChildren.filter((c) => c instanceof MockMouseRegion);
608
+ expect(mouseRegions).toHaveLength(1);
609
+ expect(mouseRegions[0]!.child).toBeInstanceOf(MockText);
610
+ });
611
+
612
+ it("collapses to Text during streaming when followed by text content", async () => {
613
+ const { addedChildren, MockMouseRegion, MockText } = await renderThinkingComponent({
614
+ config: { autoCollapseThinking: true },
615
+ isStreaming: true,
616
+ message: {
617
+ content: [
618
+ { type: "thinking", thinking: "Completed thought" },
619
+ { type: "text", text: "Answer in progress..." },
620
+ ],
621
+ },
622
+ });
623
+ const mouseRegions = addedChildren.filter((c) => c instanceof MockMouseRegion);
624
+ expect(mouseRegions).toHaveLength(1);
625
+ expect(mouseRegions[0]!.child).toBeInstanceOf(MockText);
626
+ });
627
+
628
+ it("collapses to Text during streaming when followed by toolCall", async () => {
629
+ const { addedChildren, MockMouseRegion, MockText } = await renderThinkingComponent({
630
+ config: { autoCollapseThinking: true },
631
+ isStreaming: true,
632
+ message: {
633
+ content: [
634
+ { type: "thinking", thinking: "Completed thought" },
635
+ { type: "toolCall", id: "call_1", name: "bash", args: {} },
636
+ ],
637
+ },
638
+ });
639
+ const mouseRegions = addedChildren.filter((c) => c instanceof MockMouseRegion);
640
+ expect(mouseRegions).toHaveLength(1);
641
+ expect(mouseRegions[0]!.child).toBeInstanceOf(MockText);
642
+ });
643
+
644
+ it("respects user click override when autoCollapseThinking is enabled", async () => {
645
+ const { instance, addedChildren, MockMouseRegion, MockMarkdown } = await renderThinkingComponent({
646
+ config: { autoCollapseThinking: true },
647
+ isStreaming: false,
648
+ message: {
649
+ content: [{ type: "thinking", thinking: "Finished thought" }],
650
+ },
651
+ });
652
+ const mouseRegion = addedChildren.find((c) => c instanceof MockMouseRegion);
653
+ expect(mouseRegion).toBeDefined();
654
+
655
+ // User clicks collapsed block to expand it
656
+ mouseRegion!.onMouse({ type: "click", button: "left" });
657
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(false);
658
+
659
+ // Re-render with override applied
660
+ const childrenAfter: any[] = [];
661
+ instance.contentContainer.addChild = vi.fn((c: any) => childrenAfter.push(c));
662
+ instance.updateContent(instance.lastMessage, false);
663
+
664
+ const regionAfter = childrenAfter.find((c) => c instanceof MockMouseRegion);
665
+ expect(regionAfter!.child).toBeInstanceOf(MockMarkdown);
666
+ });
667
+ });
668
+
669
+ describe("thinkingStyle", () => {
670
+ const THINKING_LABEL = "\x1b[3m\x1b[38;2;255;215;0mThinking...\x1b[39m\x1b[23m";
671
+
672
+ it("renders single inline TruncatedText containing label and last line for thinkingStyle 'Compact' (streaming and finished)", async () => {
673
+ const multiline = "Line 1\nLine 2\nLine 3";
674
+
675
+ // Streaming
676
+ const streamingResult = await renderThinkingComponent({
677
+ config: { thinkingStyle: "Compact" },
678
+ isStreaming: true,
679
+ message: { content: [{ type: "thinking", thinking: multiline }] },
680
+ });
681
+ const streamRegion = streamingResult.addedChildren.find((c) => c instanceof streamingResult.MockMouseRegion);
682
+ expect(streamRegion).toBeDefined();
683
+ expect(streamRegion!.child).toBeInstanceOf(streamingResult.MockTruncatedText);
684
+ expect((streamRegion!.child as any).text).toBe(`${THINKING_LABEL} Line 3`);
685
+
686
+ // Finished
687
+ const finishedResult = await renderThinkingComponent({
688
+ config: { thinkingStyle: "Compact" },
689
+ isStreaming: false,
690
+ message: { content: [{ type: "thinking", thinking: multiline }] },
691
+ });
692
+ const finishedRegion = finishedResult.addedChildren.find((c) => c instanceof finishedResult.MockMouseRegion);
693
+ expect(finishedRegion).toBeDefined();
694
+ expect(finishedRegion!.child).toBeInstanceOf(finishedResult.MockTruncatedText);
695
+ expect((finishedRegion!.child as any).text).toBe(`${THINKING_LABEL} Line 3`);
696
+ });
697
+
698
+ it("renders the full Markdown path for thinkingStyle 'Full'", async () => {
699
+ const { addedChildren, MockMouseRegion, MockMarkdown } = await renderThinkingComponent({
700
+ config: { thinkingStyle: "Full" },
701
+ isStreaming: false,
702
+ message: { content: [{ type: "thinking", thinking: "Full thought\nLine 2\nLine 3" }] },
703
+ });
704
+ const region = addedChildren.find((c) => c instanceof MockMouseRegion);
705
+ expect(region).toBeDefined();
706
+ expect(region!.child).toBeInstanceOf(MockMarkdown);
707
+ });
708
+
709
+ it("collapses to hidden Text once streaming concludes when both thinkingStyle 'Compact' and autoCollapseThinking are active", async () => {
710
+ // While streaming: renders compact (one line)
711
+ const streamResult = await renderThinkingComponent({
712
+ config: { thinkingStyle: "Compact", autoCollapseThinking: true },
713
+ isStreaming: true,
714
+ message: { content: [{ type: "thinking", thinking: "A\nB\nC\nD" }] },
715
+ });
716
+ const streamRegion = streamResult.addedChildren.find((c) => c instanceof streamResult.MockMouseRegion);
717
+ expect(streamRegion!.child).toBeInstanceOf(streamResult.MockTruncatedText);
718
+ expect((streamRegion!.child as any).text).toBe(`${THINKING_LABEL} D`);
719
+
720
+ // Finished streaming: collapses to hidden label
721
+ const finishedResult = await renderThinkingComponent({
722
+ config: { thinkingStyle: "Compact", autoCollapseThinking: true },
723
+ isStreaming: false,
724
+ message: { content: [{ type: "thinking", thinking: "A\nB\nC\nD" }] },
725
+ });
726
+ const finishedRegion = finishedResult.addedChildren.find((c) => c instanceof finishedResult.MockMouseRegion);
727
+ expect(finishedRegion!.child).toBeInstanceOf(finishedResult.MockText);
728
+ expect((finishedRegion!.child as any).text).toBe("Thinking..."); // this.hiddenThinkingLabel
729
+ });
730
+
731
+ it("transitions state properly on click with compact active: compact -> full -> compact", async () => {
732
+ const { instance, addedChildren, MockMouseRegion, MockMarkdown, MockTruncatedText } =
733
+ await renderThinkingComponent({
734
+ config: { thinkingStyle: "Compact" },
735
+ message: { content: [{ type: "thinking", thinking: "A\nB\nC" }] },
736
+ });
737
+
738
+ const mouseRegion = addedChildren.find((c) => c instanceof MockMouseRegion);
739
+ expect(mouseRegion!.child).toBeInstanceOf(MockTruncatedText);
740
+ expect((mouseRegion!.child as any).text).toBe(`${THINKING_LABEL} C`);
741
+
742
+ // First click: compact -> full
743
+ mouseRegion!.onMouse({ type: "click", button: "left" });
744
+ expect(instance[THINKING_STATES_KEY].get(0)).toBe("full");
745
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(false);
746
+
747
+ const childrenAfterClick1: any[] = [];
748
+ instance.contentContainer.addChild = vi.fn((c: any) => childrenAfterClick1.push(c));
749
+ instance.updateContent(instance.lastMessage, false);
750
+
751
+ const region1 = childrenAfterClick1.find((c) => c instanceof MockMouseRegion);
752
+ expect(region1!.child).toBeInstanceOf(MockMarkdown);
753
+
754
+ // Second click: full -> compact
755
+ region1!.onMouse({ type: "click", button: "left" });
756
+ expect(instance[THINKING_STATES_KEY].get(0)).toBe("compact");
757
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(false);
758
+
759
+ const childrenAfterClick2: any[] = [];
760
+ instance.contentContainer.addChild = vi.fn((c: any) => childrenAfterClick2.push(c));
761
+ instance.updateContent(instance.lastMessage, false);
762
+
763
+ const region2 = childrenAfterClick2.find((c) => c instanceof MockMouseRegion);
764
+ expect(region2!.child).toBeInstanceOf(MockTruncatedText);
765
+ expect((region2!.child as any).text).toBe(`${THINKING_LABEL} C`);
766
+ });
767
+
768
+ it("transitions from hidden to full, and subsequent click returns to compact", async () => {
769
+ const { instance, addedChildren, MockMouseRegion, MockMarkdown, MockText, MockTruncatedText } =
770
+ await renderThinkingComponent({
771
+ config: { thinkingStyle: "Compact", autoCollapseThinking: true },
772
+ isStreaming: false,
773
+ message: { content: [{ type: "thinking", thinking: "A\nB\nC" }] },
774
+ });
775
+
776
+ const mouseRegion = addedChildren.find((c) => c instanceof MockMouseRegion);
777
+ expect(mouseRegion!.child).toBeInstanceOf(MockText);
778
+ expect((mouseRegion!.child as any).text).toBe("Thinking..."); // Hidden state
779
+
780
+ // First click on hidden: hidden -> full
781
+ mouseRegion!.onMouse({ type: "click", button: "left" });
782
+ expect(instance[THINKING_STATES_KEY].get(0)).toBe("full");
783
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(false);
784
+
785
+ const childrenAfterClick1: any[] = [];
786
+ instance.contentContainer.addChild = vi.fn((c: any) => childrenAfterClick1.push(c));
787
+ instance.updateContent(instance.lastMessage, false);
788
+
789
+ const region1 = childrenAfterClick1.find((c) => c instanceof MockMouseRegion);
790
+ expect(region1!.child).toBeInstanceOf(MockMarkdown);
791
+
792
+ // Second click: full -> compact
793
+ region1!.onMouse({ type: "click", button: "left" });
794
+ expect(instance[THINKING_STATES_KEY].get(0)).toBe("compact");
795
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(false);
796
+
797
+ const childrenAfterClick2: any[] = [];
798
+ instance.contentContainer.addChild = vi.fn((c: any) => childrenAfterClick2.push(c));
799
+ instance.updateContent(instance.lastMessage, false);
800
+
801
+ const region2 = childrenAfterClick2.find((c) => c instanceof MockMouseRegion);
802
+ expect(region2!.child).toBeInstanceOf(MockTruncatedText);
803
+ expect((region2!.child as any).text).toBe(`${THINKING_LABEL} C`);
804
+ });
805
+
806
+ it("synchronizes thinkingVisibilityOverrides (false for compact/full, true for hidden)", async () => {
807
+ const { instance, addedChildren, MockMouseRegion } = await renderThinkingComponent({
808
+ config: { thinkingStyle: "Compact", autoCollapseThinking: true },
809
+ isStreaming: false, // will resolve to hidden initially
810
+ message: { content: [{ type: "thinking", thinking: "Thought" }] },
811
+ });
812
+
813
+ const mouseRegion = addedChildren.find((c) => c instanceof MockMouseRegion);
814
+ // Initially hidden because autoCollapseThinking is true and not streaming
815
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(true);
816
+
817
+ // Click to full
818
+ mouseRegion!.onMouse({ type: "click", button: "left" });
819
+ expect(instance[THINKING_STATES_KEY].get(0)).toBe("full");
820
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(false);
821
+
822
+ // Re-render to get updated MouseRegion with state === "full"
823
+ const childrenAfterClick1: any[] = [];
824
+ instance.contentContainer.addChild = vi.fn((c: any) => childrenAfterClick1.push(c));
825
+ instance.updateContent(instance.lastMessage, false);
826
+
827
+ const region1 = childrenAfterClick1.find((c) => c instanceof MockMouseRegion);
828
+
829
+ // Click to compact
830
+ region1!.onMouse({ type: "click", button: "left" });
831
+ expect(instance[THINKING_STATES_KEY].get(0)).toBe("compact");
832
+ expect(instance.thinkingVisibilityOverrides.get(0)).toBe(false);
833
+ });
834
+
835
+ it("renders no MouseRegion child for whitespace-only thinking content", async () => {
836
+ const { addedChildren, MockMouseRegion } = await renderThinkingComponent({
837
+ config: { thinkingStyle: "Compact" },
838
+ isStreaming: false,
839
+ message: { content: [{ type: "thinking", thinking: " " }] },
840
+ });
841
+ const mouseRegions = addedChildren.filter((c) => c instanceof MockMouseRegion);
842
+ expect(mouseRegions).toHaveLength(0);
843
+ });
844
+
845
+ it("normalizes CRLF line endings in compact mode", async () => {
846
+ const { addedChildren, MockMouseRegion, MockTruncatedText } = await renderThinkingComponent({
847
+ config: { thinkingStyle: "Compact" },
848
+ message: {
849
+ content: [{ type: "thinking", thinking: "Line 1\r\nLine 2\r\nLine 3\r\nLine 4" }],
850
+ },
851
+ });
852
+ const region = addedChildren.find((c) => c instanceof MockMouseRegion);
853
+ expect(region!.child).toBeInstanceOf(MockTruncatedText);
854
+ expect((region!.child as any).text).toBe(`${THINKING_LABEL} Line 4`);
855
+ });
856
+ });
857
+ });