@monotykamary/pi-loop 0.1.12

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 (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +285 -0
  4. package/media/demo.mp4 +0 -0
  5. package/media/pi-loop.jpg +0 -0
  6. package/media/screenshot.png +0 -0
  7. package/package.json +89 -0
  8. package/src/core/analyzer.ts +51 -0
  9. package/src/core/content-extractor.ts +79 -0
  10. package/src/core/inference.ts +137 -0
  11. package/src/core/prompt-builder.ts +217 -0
  12. package/src/core/prompt-loader.ts +126 -0
  13. package/src/core/reframe.ts +30 -0
  14. package/src/core/snapshot-builder.ts +252 -0
  15. package/src/global-config.ts +38 -0
  16. package/src/index.ts +532 -0
  17. package/src/session/client.ts +47 -0
  18. package/src/session/loop-session.ts +102 -0
  19. package/src/session/response-parser.ts +37 -0
  20. package/src/state/manager.ts +164 -0
  21. package/src/state/patterns.ts +81 -0
  22. package/src/state/reframe.ts +33 -0
  23. package/src/subagent-detector.ts +94 -0
  24. package/src/types.ts +83 -0
  25. package/src/ui/animations.ts +70 -0
  26. package/src/ui/model-picker.ts +79 -0
  27. package/src/ui/renderer.ts +257 -0
  28. package/src/ui/status-widget.ts +30 -0
  29. package/src/ui/types.ts +48 -0
  30. package/tests/compaction.test.ts +754 -0
  31. package/tests/continue-action-regression.test.ts +456 -0
  32. package/tests/engine.test.ts +770 -0
  33. package/tests/ephemeral-supervision.test.ts +391 -0
  34. package/tests/full-fidelity-snapshot.test.ts +843 -0
  35. package/tests/parsing.test.ts +303 -0
  36. package/tests/state.test.ts +525 -0
  37. package/tests/status-widget.test.ts +703 -0
  38. package/tests/subagent-detector.test.ts +191 -0
  39. package/tests/supervise-command.test.ts +381 -0
  40. package/tsconfig.json +14 -0
  41. package/vitest.config.ts +15 -0
@@ -0,0 +1,703 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { updateUI, toggleWidget } from '../src/ui/status-widget.js';
3
+ import type { LoopState } from '../src/types.js';
4
+
5
+ // Mock the TUI module
6
+ vi.mock('@earendil-works/pi-tui', () => ({
7
+ truncateToWidth: (text: string, width: number) => {
8
+ if (text.length <= width) return text;
9
+ return text.slice(0, width);
10
+ },
11
+ }));
12
+
13
+ // Helper to create mock ExtensionContext
14
+ function createMockCtx() {
15
+ const setStatusMock = vi.fn();
16
+ const setWidgetMock = vi.fn();
17
+
18
+ return {
19
+ ui: {
20
+ setStatus: setStatusMock,
21
+ setWidget: setWidgetMock,
22
+ notify: vi.fn(),
23
+ select: vi.fn(),
24
+ },
25
+ sessionManager: {
26
+ getBranch: vi.fn(() => []),
27
+ },
28
+ model: { provider: 'test', id: 'test-model' },
29
+ } as any;
30
+ }
31
+
32
+ // Helper to create a mock supervisor state
33
+ function createMockState(overrides?: Partial<LoopState>): LoopState {
34
+ return {
35
+ active: true,
36
+ outcome: 'Test goal',
37
+ interventions: [],
38
+ turnCount: 1,
39
+ justSteered: false,
40
+ lastAnalyzedTurn: 0,
41
+ snapshotBuffer: [],
42
+ reframeTier: 0,
43
+ lastSteerTurn: -1,
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe('status-widget', () => {
49
+ beforeEach(() => {
50
+ // Reset widget visibility before each test
51
+ while (!toggleWidget()) {
52
+ // Toggle until visible
53
+ }
54
+ });
55
+
56
+ describe('animation flow', () => {
57
+ it('triggers animation timer when leaving analyzing for steering', () => {
58
+ const ctx = createMockCtx();
59
+ const state = createMockState();
60
+
61
+ // Set initial thinking
62
+ updateUI(ctx, state, {
63
+ type: 'analyzing',
64
+ turn: 1,
65
+ thinking: 'Analysis content for animation test',
66
+ });
67
+
68
+ // Verify initial render has thinking
69
+ let lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
70
+ let widget = lastCall[1](null, { fg: (_c: string, t: string) => t });
71
+ let lines = widget.render(100);
72
+ expect(lines.length).toBeGreaterThan(1);
73
+
74
+ // Transition to steering - should set up animation timer
75
+ updateUI(ctx, state, {
76
+ type: 'steering',
77
+ message: 'Please fix',
78
+ reframeTier: 0,
79
+ });
80
+
81
+ // Widget still shows thinking (animation hasn't started yet, timer is set)
82
+ lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
83
+ widget = lastCall[1](null, { fg: (_c: string, t: string) => t });
84
+ lines = widget.render(100);
85
+ expect(lines.length).toBeGreaterThan(1);
86
+ expect(lines.join(' ')).toContain('Analysis content');
87
+ expect(lines.join(' ')).toContain('steering');
88
+ });
89
+
90
+ it('hides lines from bottom to top during animation', () => {
91
+ const ctx = createMockCtx();
92
+ const state = createMockState();
93
+
94
+ // Create a long thinking content that will wrap to multiple lines
95
+ const longThinking = 'Word one word two word three word four word five word six word seven';
96
+
97
+ // Start analyzing with thinking
98
+ updateUI(ctx, state, {
99
+ type: 'analyzing',
100
+ turn: 1,
101
+ thinking: longThinking,
102
+ });
103
+
104
+ // Capture the widget and get initial lines
105
+ let lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
106
+ const mockTheme = { fg: (_c: string, t: string) => t };
107
+ let widget = lastCall[1](null, mockTheme);
108
+ const width = 30; // Narrow width to force wrapping
109
+ const initialLines = widget.render(width);
110
+ const initialThinkingLines = initialLines.length - 1; // Exclude header
111
+ expect(initialThinkingLines).toBeGreaterThanOrEqual(2);
112
+
113
+ // Transition to done - thinking lines should be preserved for animation
114
+ updateUI(ctx, state, { type: 'done' });
115
+
116
+ // Verify before animation starts, all lines should be visible
117
+ lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
118
+ widget = lastCall[1](null, mockTheme);
119
+ const linesBeforeAnimation = widget.render(width);
120
+ expect(linesBeforeAnimation.length).toBe(initialLines.length);
121
+ });
122
+
123
+ it('animation progressively removes lines from bottom using hideFromBottom', () => {
124
+ // This test verifies the core animation mechanism:
125
+ // when hideFromBottom increases, fewer lines are shown from the bottom
126
+
127
+ const ctx = createMockCtx();
128
+ const state = createMockState();
129
+
130
+ // Set multi-line thinking - use a longer text that will wrap even at width 80
131
+ // The thinking indent is 2 spaces, so we need enough content to wrap
132
+ const longThinking =
133
+ 'One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty';
134
+
135
+ updateUI(ctx, state, {
136
+ type: 'analyzing',
137
+ turn: 1,
138
+ thinking: longThinking,
139
+ });
140
+
141
+ const mockTheme = { fg: (_c: string, t: string) => t };
142
+
143
+ // Transition to steering to set up animation state
144
+ updateUI(ctx, state, {
145
+ type: 'steering',
146
+ message: 'Fix this',
147
+ reframeTier: 0,
148
+ });
149
+
150
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
151
+ const widget = lastCall[1](null, mockTheme);
152
+
153
+ // At this point the widget render function is set up with lastThinkingLines
154
+ // The key assertion: the render function is designed to handle hideFromBottom
155
+ // by slicing from 0 to (total - hideFromBottom)
156
+
157
+ const width = 78; // Width that allows some wrapping
158
+ const linesWithFullThinking = widget.render(width);
159
+
160
+ // Should have header + at least 1 thinking line
161
+ expect(linesWithFullThinking.length).toBeGreaterThanOrEqual(2);
162
+
163
+ // Verify the thinking lines contain expected words
164
+ const allText = linesWithFullThinking.join(' ');
165
+ expect(allText).toContain('One');
166
+ expect(allText).toContain('steering');
167
+ });
168
+
169
+ it('animation resets when new thinking arrives mid-animation', () => {
170
+ const ctx = createMockCtx();
171
+ const state = createMockState();
172
+
173
+ // Initial analysis
174
+ updateUI(ctx, state, {
175
+ type: 'analyzing',
176
+ turn: 1,
177
+ thinking: 'First analysis content',
178
+ });
179
+
180
+ // Start transition to steering (animation timer set)
181
+ updateUI(ctx, state, {
182
+ type: 'steering',
183
+ message: 'Fix this',
184
+ reframeTier: 0,
185
+ });
186
+
187
+ // Before animation completes, new analyzing content arrives
188
+ updateUI(ctx, state, {
189
+ type: 'analyzing',
190
+ turn: 2,
191
+ thinking: 'Fresh analysis after interrupt',
192
+ });
193
+
194
+ // Should show fresh content, animation state reset
195
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
196
+ const widget = lastCall[1](null, { fg: (_c: string, t: string) => t });
197
+ const lines = widget.render(100);
198
+
199
+ const allText = lines.join(' ');
200
+ expect(allText).toContain('Fresh analysis after interrupt');
201
+ expect(allText).not.toContain('First analysis content');
202
+ expect(allText).toContain('⟳ turn 2');
203
+ });
204
+
205
+ it('animation completes and clears thinking lines', () => {
206
+ const ctx = createMockCtx();
207
+ const state = createMockState();
208
+
209
+ // Start with analyzing
210
+ updateUI(ctx, state, {
211
+ type: 'analyzing',
212
+ turn: 1,
213
+ thinking: 'Short thinking',
214
+ });
215
+
216
+ // Transition to steering
217
+ updateUI(ctx, state, {
218
+ type: 'steering',
219
+ message: 'Please fix',
220
+ reframeTier: 0,
221
+ });
222
+
223
+ // Verify widget was rendered with thinking preserved
224
+ let lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
225
+ expect(lastCall[1]).not.toBeUndefined();
226
+
227
+ // Simulate supervisor becoming inactive (this happens after animation or when loop ends)
228
+ const inactiveState = { ...state, active: false };
229
+ updateUI(ctx, inactiveState as any);
230
+
231
+ // Widget should be cleared when inactive
232
+ const finalCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
233
+ expect(finalCall[1]).toBeUndefined();
234
+ });
235
+
236
+ it('cancels previous animation timer when new action arrives', () => {
237
+ const ctx = createMockCtx();
238
+ const state = createMockState();
239
+
240
+ // Start analyzing
241
+ updateUI(ctx, state, {
242
+ type: 'analyzing',
243
+ turn: 1,
244
+ thinking: 'Analysis one',
245
+ });
246
+
247
+ // Go to steering - sets timer
248
+ updateUI(ctx, state, {
249
+ type: 'steering',
250
+ message: 'First steer',
251
+ reframeTier: 0,
252
+ });
253
+
254
+ const callsAfterFirstSteer = ctx.ui.setWidget.mock.calls.length;
255
+
256
+ // Second steer before animation starts - should cancel first timer and set new one
257
+ updateUI(ctx, state, {
258
+ type: 'steering',
259
+ message: 'Second steer',
260
+ reframeTier: 0,
261
+ });
262
+
263
+ // Should have updated widget again
264
+ expect(ctx.ui.setWidget.mock.calls.length).toBeGreaterThan(callsAfterFirstSteer);
265
+ });
266
+
267
+ it('steering preserves thinking for animation to clear - regression test for empty lines bug', () => {
268
+ // This test documents the bug fix for the case where lastThinkingLines was empty
269
+ // when the animation timer fired, causing the animation to return early and leave
270
+ // thoughts on screen indefinitely.
271
+ //
272
+ // Bug scenario:
273
+ // 1. updateUI called with analyzing, sets lastThinking
274
+ // 2. renderWithState called, populates lastThinkingLines based on lastThinking
275
+ // 3. updateUI called with steering, sets up animation timer
276
+ // 4. renderWithState called with hideFromBottom > 0, returns early (doesn't update lastThinkingLines)
277
+ // 5. 15 seconds later, animation timer fires
278
+ // 6. startLineClearAnimation checks lastThinkingLines.length === 0, returns early
279
+ // 7. Thoughts stay on screen forever!
280
+ //
281
+ // Fix: Before setting the animation timer, populate lastThinkingLines
282
+ // from lastThinking so the animation can run properly.
283
+ //
284
+ // This test verifies that after steering, the thinking is preserved
285
+ // so the animation (which runs after 15 seconds) can properly clear it.
286
+
287
+ const ctx = createMockCtx();
288
+ const state = createMockState();
289
+
290
+ // Start with analyzing and multi-line thinking content
291
+ updateUI(ctx, state, {
292
+ type: 'analyzing',
293
+ turn: 1,
294
+ thinking: 'First line of thinking that needs to be cleared by animation',
295
+ });
296
+
297
+ // Transition to steering - thinking lines should be preserved for animation
298
+ updateUI(ctx, state, {
299
+ type: 'steering',
300
+ message: 'Fix this',
301
+ reframeTier: 0,
302
+ });
303
+
304
+ // Verify the widget shows the thinking content (will be cleared by animation after 15s)
305
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
306
+ const widgetFactory = lastCall[1];
307
+ const mockTheme = { fg: (_c: string, t: string) => t };
308
+ const widget = widgetFactory(null, mockTheme);
309
+ const lines = widget.render(100);
310
+
311
+ // Should have header + thinking lines
312
+ expect(lines.length).toBeGreaterThan(1);
313
+ const allText = lines.join(' ');
314
+
315
+ // Widget shows steering status
316
+ expect(allText).toContain('steering');
317
+
318
+ // Thinking content is preserved for animation to clear
319
+ expect(allText).toContain('First line of thinking');
320
+
321
+ // The thinking will be cleared by the animation after the 15s timer fires
322
+ // (verified by the animation completing and calling renderFn with empty thinking)
323
+ });
324
+ });
325
+
326
+ describe('thought clearing behavior', () => {
327
+ it('clears old thoughts immediately when new thinking arrives', () => {
328
+ const ctx = createMockCtx();
329
+ const state = createMockState();
330
+
331
+ // First call with initial thinking content
332
+ updateUI(ctx, state, {
333
+ type: 'analyzing',
334
+ turn: 1,
335
+ thinking: 'Initial thinking content that is long enough to wrap',
336
+ });
337
+
338
+ // Verify widget was set with initial thinking
339
+ expect(ctx.ui.setWidget).toHaveBeenCalled();
340
+ const firstWidgetCall = ctx.ui.setWidget.mock.calls[0];
341
+ expect(firstWidgetCall[0]).toBe('loop');
342
+
343
+ // Second call with new/different thinking content (simulating agent_end with new analysis)
344
+ updateUI(ctx, state, {
345
+ type: 'analyzing',
346
+ turn: 2,
347
+ thinking: 'Completely new thinking content for turn 2',
348
+ });
349
+
350
+ // Verify widget was updated again
351
+ expect(ctx.ui.setWidget).toHaveBeenCalledTimes(2);
352
+
353
+ // Get the render function from the second call
354
+ const secondWidgetCall = ctx.ui.setWidget.mock.calls[1];
355
+ const widgetFactory = secondWidgetCall[1];
356
+ const mockTheme = {
357
+ fg: (color: string, text: string) => text,
358
+ };
359
+ const widget = widgetFactory(null, mockTheme);
360
+
361
+ // Render with enough width to see the content
362
+ const lines = widget.render(100);
363
+
364
+ // Should contain the new thinking, not the old
365
+ const allText = lines.join(' ');
366
+ expect(allText).toContain('Completely new thinking');
367
+ expect(allText).not.toContain('Initial thinking content');
368
+ });
369
+
370
+ it('does not flash back old thoughts when clear animation is interrupted', () => {
371
+ const ctx = createMockCtx();
372
+ const state = createMockState();
373
+
374
+ // Step 1: Set initial thinking content
375
+ updateUI(ctx, state, {
376
+ type: 'analyzing',
377
+ turn: 1,
378
+ thinking: 'First analysis thinking content that will be cleared',
379
+ });
380
+
381
+ // Step 2: Trigger the clear animation by going to watching state (simulating 15s delay completion)
382
+ // First we need to trigger the clear timer - this happens when we go to a non-analyzing state
383
+ // or when state becomes inactive. Let's use a steering action which triggers the animation.
384
+ updateUI(ctx, state, {
385
+ type: 'steering',
386
+ message: 'Please focus',
387
+ });
388
+
389
+ // Step 3: Now simulate agent_end firing with new analyzing content
390
+ // This is the key test - if old thoughts flash back, the bug exists
391
+ updateUI(ctx, state, {
392
+ type: 'analyzing',
393
+ turn: 2,
394
+ thinking: 'Fresh analysis after steering',
395
+ });
396
+
397
+ // Get the final widget state
398
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
399
+ const widgetFactory = lastCall[1];
400
+ const mockTheme = {
401
+ fg: (color: string, text: string) => text,
402
+ };
403
+ const widget = widgetFactory(null, mockTheme);
404
+ const lines = widget.render(100);
405
+
406
+ // Verify fresh content is shown, old content is not flashed back
407
+ const allText = lines.join(' ');
408
+ expect(allText).toContain('Fresh analysis after steering');
409
+ expect(allText).not.toContain('First analysis thinking');
410
+ });
411
+
412
+ it('preserves thinking lines when leaving analyzing for steering (animation will clear)', () => {
413
+ const ctx = createMockCtx();
414
+ const state = createMockState();
415
+
416
+ // Step 1: Start with analyzing and thinking content
417
+ updateUI(ctx, state, {
418
+ type: 'analyzing',
419
+ turn: 1,
420
+ thinking: 'Analysis thinking that should animate out on steer',
421
+ });
422
+
423
+ // Step 2: Transition to steering - thinking lines are preserved for animation
424
+ updateUI(ctx, state, {
425
+ type: 'steering',
426
+ message: 'Please fix this issue',
427
+ reframeTier: 0,
428
+ });
429
+
430
+ // Get the widget state after steering
431
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
432
+ const widgetFactory = lastCall[1];
433
+ const mockTheme = {
434
+ fg: (color: string, text: string) => text,
435
+ };
436
+ const widget = widgetFactory(null, mockTheme);
437
+ const lines = widget.render(100);
438
+
439
+ // The thinking content should be preserved for animation (not immediately cleared)
440
+ expect(lines.length).toBeGreaterThan(1); // Header + thinking lines
441
+ const allText = lines.join(' ');
442
+ expect(allText).toContain('steering');
443
+ expect(allText).toContain('Analysis thinking');
444
+ });
445
+
446
+ it('preserves thinking lines through multiple rapid steers (animation handles clearing)', () => {
447
+ const ctx = createMockCtx();
448
+ const state = createMockState();
449
+
450
+ // Simulate rapid steers after analysis (the 5 steers bug scenario)
451
+ updateUI(ctx, state, {
452
+ type: 'analyzing',
453
+ turn: 1,
454
+ thinking: 'Initial analysis content',
455
+ });
456
+
457
+ // Multiple rapid steers (faster than 15s animation delay)
458
+ // Each steer preserves thinking lines - animation will clear them after 15s delay
459
+ for (let i = 0; i < 5; i++) {
460
+ updateUI(ctx, state, {
461
+ type: 'steering',
462
+ message: `Steer ${i + 1}`,
463
+ reframeTier: 0,
464
+ });
465
+ }
466
+
467
+ // Final state should preserve thinking lines (they animate out after delay)
468
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
469
+ const widgetFactory = lastCall[1];
470
+ const mockTheme = {
471
+ fg: (color: string, text: string) => text,
472
+ };
473
+ const widget = widgetFactory(null, mockTheme);
474
+ const lines = widget.render(100);
475
+
476
+ // Should have header + thinking lines (animation will clear them)
477
+ expect(lines.length).toBeGreaterThan(1);
478
+ const allText = lines.join(' ');
479
+ expect(allText).toContain('Initial analysis');
480
+ expect(allText).toContain('steering');
481
+ });
482
+
483
+ it('preserves thinking lines when leaving analyzing for done state (animation will clear)', () => {
484
+ const ctx = createMockCtx();
485
+ const state = createMockState();
486
+
487
+ // Start with analyzing
488
+ updateUI(ctx, state, {
489
+ type: 'analyzing',
490
+ turn: 1,
491
+ thinking: 'Analysis that will animate out on done',
492
+ });
493
+
494
+ // Transition to done - thinking lines are preserved for animation
495
+ updateUI(ctx, state, { type: 'done' });
496
+
497
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
498
+ const widgetFactory = lastCall[1];
499
+ const mockTheme = {
500
+ fg: (color: string, text: string) => text,
501
+ };
502
+ const widget = widgetFactory(null, mockTheme);
503
+ const lines = widget.render(100);
504
+
505
+ // Should have header + thinking lines (animation will clear them)
506
+ expect(lines.length).toBeGreaterThan(1);
507
+ const allText = lines.join(' ');
508
+ expect(allText).toContain('done');
509
+ expect(allText).toContain('Analysis that will animate out');
510
+ });
511
+
512
+ it('does not flash old thoughts when re-entering analyzing after steering', () => {
513
+ const ctx = createMockCtx();
514
+ const state = createMockState();
515
+
516
+ // Initial analysis
517
+ updateUI(ctx, state, {
518
+ type: 'analyzing',
519
+ turn: 1,
520
+ thinking: 'First round of analysis',
521
+ });
522
+
523
+ // Steer
524
+ updateUI(ctx, state, {
525
+ type: 'steering',
526
+ message: 'Fix this',
527
+ });
528
+
529
+ // New analysis - should NOT show old thinking even briefly
530
+ updateUI(ctx, state, {
531
+ type: 'analyzing',
532
+ turn: 2,
533
+ thinking: 'Fresh second round analysis',
534
+ });
535
+
536
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
537
+ const widgetFactory = lastCall[1];
538
+ const mockTheme = {
539
+ fg: (color: string, text: string) => text,
540
+ };
541
+ const widget = widgetFactory(null, mockTheme);
542
+ const lines = widget.render(100);
543
+
544
+ const allText = lines.join(' ');
545
+ expect(allText).toContain('Fresh second round');
546
+ expect(allText).not.toContain('First round');
547
+ });
548
+
549
+ it('preserves animation state when same thinking content is updated', () => {
550
+ const ctx = createMockCtx();
551
+ const state = createMockState();
552
+
553
+ // First call with thinking
554
+ updateUI(ctx, state, {
555
+ type: 'analyzing',
556
+ turn: 1,
557
+ thinking: 'Streaming thinking content',
558
+ });
559
+
560
+ // Second call with same thinking (simulating streaming update with same content)
561
+ updateUI(ctx, state, {
562
+ type: 'analyzing',
563
+ turn: 1,
564
+ thinking: 'Streaming thinking content',
565
+ });
566
+
567
+ // Should still have the content (not cleared since it's the same)
568
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
569
+ const widgetFactory = lastCall[1];
570
+ const mockTheme = {
571
+ fg: (color: string, text: string) => text,
572
+ };
573
+ const widget = widgetFactory(null, mockTheme);
574
+ const lines = widget.render(100);
575
+
576
+ const allText = lines.join(' ');
577
+ expect(allText).toContain('Streaming thinking content');
578
+ });
579
+
580
+ it('clears thoughts when going to done state', () => {
581
+ const ctx = createMockCtx();
582
+ const state = createMockState();
583
+
584
+ // First set some thinking
585
+ updateUI(ctx, state, {
586
+ type: 'analyzing',
587
+ turn: 1,
588
+ thinking: 'Analysis in progress',
589
+ });
590
+
591
+ // Then go to done state
592
+ updateUI(ctx, state, { type: 'done' });
593
+
594
+ // Should trigger clear timer setup (but not immediately clear)
595
+ // The clear animation should be scheduled
596
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
597
+ const widgetFactory = lastCall[1];
598
+ const mockTheme = {
599
+ fg: (color: string, text: string) => text,
600
+ };
601
+ const widget = widgetFactory(null, mockTheme);
602
+ const lines = widget.render(100);
603
+
604
+ // The done action should show the done indicator
605
+ const allText = lines.join(' ');
606
+ expect(allText).toContain('done');
607
+ });
608
+
609
+ it('handles transition from cleared state to new analysis', async () => {
610
+ const ctx = createMockCtx();
611
+ const state = createMockState();
612
+
613
+ // Initial analysis
614
+ updateUI(ctx, state, {
615
+ type: 'analyzing',
616
+ turn: 1,
617
+ thinking: 'Initial analysis',
618
+ });
619
+
620
+ // Go to done (triggers clear)
621
+ updateUI(ctx, state, { type: 'done' });
622
+
623
+ // Simulate another agent_end with new analysis
624
+ // This should show new thinking, not old
625
+ updateUI(ctx, state, {
626
+ type: 'analyzing',
627
+ turn: 2,
628
+ thinking: 'New analysis after completion',
629
+ });
630
+
631
+ const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
632
+ const widgetFactory = lastCall[1];
633
+ const mockTheme = {
634
+ fg: (color: string, text: string) => text,
635
+ };
636
+ const widget = widgetFactory(null, mockTheme);
637
+ const lines = widget.render(100);
638
+
639
+ const allText = lines.join(' ');
640
+ expect(allText).toContain('New analysis after completion');
641
+ expect(allText).not.toContain('Initial analysis');
642
+ });
643
+
644
+ it('preserves thinking lines when supervisor becomes inactive after done (animation will clear)', () => {
645
+ const ctx = createMockCtx();
646
+ const state = createMockState();
647
+
648
+ // Step 1: Start with analyzing and thinking content
649
+ updateUI(ctx, state, {
650
+ type: 'analyzing',
651
+ turn: 1,
652
+ thinking: 'Analysis thinking that should animate clear',
653
+ });
654
+
655
+ // Step 2: Transition to done while still active
656
+ updateUI(ctx, state, { type: 'done' });
657
+
658
+ // Verify the widget was rendered with 'done' status
659
+ const doneCalls = ctx.ui.setWidget.mock.calls.filter((call: any[]) => call[0] === 'loop');
660
+ expect(doneCalls.length).toBeGreaterThan(0);
661
+
662
+ const lastDoneCall = doneCalls[doneCalls.length - 1];
663
+ expect(lastDoneCall[1]).not.toBeUndefined();
664
+
665
+ // Verify thinking lines are preserved (shown until animation clears them)
666
+ const widgetFactory = lastDoneCall[1];
667
+ const mockTheme = { fg: (color: string, text: string) => text };
668
+ const widget = widgetFactory(null, mockTheme);
669
+ const lines = widget.render(100);
670
+ expect(lines.length).toBeGreaterThan(1);
671
+ expect(lines.join(' ')).toContain('done');
672
+ expect(lines.join(' ')).toContain('Analysis thinking');
673
+
674
+ // Step 3: Verify widget was updated after going inactive (animation path or clear)
675
+ const callsAfterDone = ctx.ui.setWidget.mock.calls.length;
676
+
677
+ const inactiveState = { ...state, active: false };
678
+ updateUI(ctx, inactiveState as any);
679
+
680
+ // Widget should be updated (either animation setup or clear)
681
+ expect(ctx.ui.setWidget.mock.calls.length).toBeGreaterThanOrEqual(callsAfterDone);
682
+ });
683
+ });
684
+
685
+ describe('widget visibility', () => {
686
+ it('hides widget when toggle is off', () => {
687
+ const ctx = createMockCtx();
688
+ const state = createMockState();
689
+
690
+ // Hide the widget
691
+ toggleWidget();
692
+
693
+ updateUI(ctx, state, {
694
+ type: 'analyzing',
695
+ turn: 1,
696
+ thinking: 'Should not be visible',
697
+ });
698
+
699
+ // Should set widget to undefined when hidden
700
+ expect(ctx.ui.setWidget).toHaveBeenCalledWith('loop', undefined);
701
+ });
702
+ });
703
+ });