@monotykamary/pi-supervisor 0.5.9
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/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/media/demo.mp4 +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +87 -0
- package/src/compaction/brief.ts +841 -0
- package/src/compaction/build-sections.ts +340 -0
- package/src/compaction/causal-keys.ts +138 -0
- package/src/compaction/content.ts +68 -0
- package/src/compaction/extract/commits.ts +78 -0
- package/src/compaction/extract/goals.ts +79 -0
- package/src/compaction/extract/preferences.ts +52 -0
- package/src/compaction/extract/shared-symbols.ts +376 -0
- package/src/compaction/filter-noise.ts +47 -0
- package/src/compaction/format.ts +89 -0
- package/src/compaction/index.ts +38 -0
- package/src/compaction/normalize.ts +73 -0
- package/src/compaction/sanitize.ts +5 -0
- package/src/compaction/sections.ts +19 -0
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/tool-args.ts +14 -0
- package/src/compaction/types.ts +26 -0
- package/src/core/analyzer.ts +58 -0
- package/src/core/index.ts +8 -0
- package/src/core/inference.ts +77 -0
- package/src/core/prompt-builder.ts +137 -0
- package/src/core/prompt-loader.ts +125 -0
- package/src/core/reframe.ts +27 -0
- package/src/fabric-provider.ts +115 -0
- package/src/global-config.ts +65 -0
- package/src/index.ts +514 -0
- package/src/session/client.ts +46 -0
- package/src/session/response-parser.ts +37 -0
- package/src/session/supervisor-session.ts +102 -0
- package/src/state/manager.ts +133 -0
- package/src/state/mid-run-signals.ts +103 -0
- package/src/state/patterns.ts +82 -0
- package/src/state/reframe.ts +27 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +42 -0
- package/src/ui/animations.ts +95 -0
- package/src/ui/model-picker.ts +72 -0
- package/src/ui/model-settings-selector.ts +440 -0
- package/src/ui/model-sort.ts +101 -0
- package/src/ui/renderer.ts +314 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +507 -0
- package/tests/engine.test.ts +622 -0
- package/tests/ephemeral-supervision.test.ts +347 -0
- package/tests/fabric-provider.test.ts +55 -0
- package/tests/full-fidelity-snapshot.test.ts +250 -0
- package/tests/global-config.test.ts +74 -0
- package/tests/model-sort.test.ts +157 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +474 -0
- package/tests/status-widget.test.ts +539 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +363 -0
- package/tests/supervise-model-command.test.ts +184 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { updateUI, toggleWidget } from '../src/ui/renderer.js';
|
|
3
|
+
import { createInitialState, type WidgetState } from '../src/ui/types.js';
|
|
4
|
+
import type { SupervisorState } from '../src/types.js';
|
|
5
|
+
|
|
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
|
+
function createMockCtx() {
|
|
14
|
+
const setWidgetMock = vi.fn();
|
|
15
|
+
return {
|
|
16
|
+
ui: {
|
|
17
|
+
setWidget: setWidgetMock,
|
|
18
|
+
notify: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
sessionManager: {
|
|
21
|
+
getBranch: vi.fn(() => []),
|
|
22
|
+
},
|
|
23
|
+
model: { provider: 'test', id: 'test-model' },
|
|
24
|
+
} as any;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createMockState(overrides?: Partial<SupervisorState>): SupervisorState {
|
|
28
|
+
return {
|
|
29
|
+
active: true,
|
|
30
|
+
outcome: 'Test goal',
|
|
31
|
+
provider: 'anthropic',
|
|
32
|
+
modelId: 'claude-haiku',
|
|
33
|
+
interventions: [],
|
|
34
|
+
startedAt: Date.now(),
|
|
35
|
+
reframeTier: 0,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createWidgetState(): WidgetState {
|
|
41
|
+
return createInitialState();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mockTheme = {
|
|
45
|
+
fg: (_color: string, text: string) => text,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe('status-widget', () => {
|
|
49
|
+
let widgetState: WidgetState;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
widgetState = createWidgetState();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('thought clearing behavior', () => {
|
|
56
|
+
it('clears old thoughts immediately when new thinking arrives', () => {
|
|
57
|
+
const ctx = createMockCtx();
|
|
58
|
+
const state = createMockState();
|
|
59
|
+
|
|
60
|
+
updateUI(ctx, widgetState, state, {
|
|
61
|
+
type: 'analyzing',
|
|
62
|
+
thinking: 'Initial thinking content that is long enough to wrap',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(ctx.ui.setWidget).toHaveBeenCalled();
|
|
66
|
+
|
|
67
|
+
updateUI(ctx, widgetState, state, {
|
|
68
|
+
type: 'analyzing',
|
|
69
|
+
thinking: 'Completely new thinking content for turn 2',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
73
|
+
const widgetFactory = lastCall[1];
|
|
74
|
+
const widget = widgetFactory(null, mockTheme);
|
|
75
|
+
const lines = widget.render(100);
|
|
76
|
+
|
|
77
|
+
const allText = lines.join(' ');
|
|
78
|
+
expect(allText).toContain('Completely new thinking');
|
|
79
|
+
expect(allText).not.toContain('Initial thinking content');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('immediately clears thinking when leaving analyzing for steering', () => {
|
|
83
|
+
const ctx = createMockCtx();
|
|
84
|
+
const state = createMockState();
|
|
85
|
+
|
|
86
|
+
updateUI(ctx, widgetState, state, {
|
|
87
|
+
type: 'analyzing',
|
|
88
|
+
thinking: 'Analysis thinking that should disappear immediately on steer',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
updateUI(ctx, widgetState, state, {
|
|
92
|
+
type: 'steering',
|
|
93
|
+
message: 'Please fix this issue',
|
|
94
|
+
reframeTier: 0,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
98
|
+
const widgetFactory = lastCall[1];
|
|
99
|
+
const widget = widgetFactory(null, mockTheme);
|
|
100
|
+
const lines = widget.render(100);
|
|
101
|
+
|
|
102
|
+
expect(lines.length).toBe(1);
|
|
103
|
+
const allText = lines.join(' ');
|
|
104
|
+
expect(allText).toContain('steering');
|
|
105
|
+
expect(allText).not.toContain('Analysis thinking');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('animates thinking away when leaving analyzing for steering (with render cycle)', () => {
|
|
109
|
+
const ctx = createMockCtx();
|
|
110
|
+
const state = createMockState();
|
|
111
|
+
|
|
112
|
+
// First update: analyzing with thinking
|
|
113
|
+
updateUI(ctx, widgetState, state, {
|
|
114
|
+
type: 'analyzing',
|
|
115
|
+
thinking: 'Analysis thinking that should animate away on steer',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Simulate the TUI render cycle: render populates lastThinkingLines
|
|
119
|
+
const analyzingCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
120
|
+
const analyzingWidget = analyzingCall[1](null, mockTheme);
|
|
121
|
+
analyzingWidget.render(100);
|
|
122
|
+
|
|
123
|
+
// Now transition to steering
|
|
124
|
+
updateUI(ctx, widgetState, state, {
|
|
125
|
+
type: 'steering',
|
|
126
|
+
message: 'Please fix this issue',
|
|
127
|
+
reframeTier: 0,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// After the first animation step, lines should be reduced
|
|
131
|
+
// (animation starts immediately with no delay for steering)
|
|
132
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
133
|
+
const widgetFactory = lastCall[1];
|
|
134
|
+
const widget = widgetFactory(null, mockTheme);
|
|
135
|
+
const lines = widget.render(100);
|
|
136
|
+
|
|
137
|
+
const allText = lines.join(' ');
|
|
138
|
+
expect(allText).toContain('steering');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('completes steering animation and clears thinking lines', () => {
|
|
142
|
+
const ctx = createMockCtx();
|
|
143
|
+
const state = createMockState();
|
|
144
|
+
vi.useFakeTimers();
|
|
145
|
+
|
|
146
|
+
// First update: analyzing with thinking
|
|
147
|
+
updateUI(ctx, widgetState, state, {
|
|
148
|
+
type: 'analyzing',
|
|
149
|
+
thinking: 'Short thinking',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Simulate the TUI render cycle
|
|
153
|
+
const analyzingCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
154
|
+
const analyzingWidget = analyzingCall[1](null, mockTheme);
|
|
155
|
+
analyzingWidget.render(100);
|
|
156
|
+
|
|
157
|
+
// Transition to steering
|
|
158
|
+
updateUI(ctx, widgetState, state, {
|
|
159
|
+
type: 'steering',
|
|
160
|
+
message: 'Fix this',
|
|
161
|
+
reframeTier: 0,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Run all animation timers
|
|
165
|
+
vi.advanceTimersByTime(5000);
|
|
166
|
+
|
|
167
|
+
// After animation completes, thinking lines should be cleared
|
|
168
|
+
expect(widgetState.lastThinkingLines).toEqual([]);
|
|
169
|
+
expect(widgetState.hiddenFromBottomCount).toBe(0);
|
|
170
|
+
|
|
171
|
+
vi.useRealTimers();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('animates thinking away when leaving analyzing for watching (with render cycle)', () => {
|
|
175
|
+
const ctx = createMockCtx();
|
|
176
|
+
const state = createMockState();
|
|
177
|
+
|
|
178
|
+
// First update: analyzing with thinking
|
|
179
|
+
updateUI(ctx, widgetState, state, {
|
|
180
|
+
type: 'analyzing',
|
|
181
|
+
thinking: 'Analysis thinking that should animate away on watching',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Simulate the TUI render cycle
|
|
185
|
+
const analyzingCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
186
|
+
const analyzingWidget = analyzingCall[1](null, mockTheme);
|
|
187
|
+
analyzingWidget.render(100);
|
|
188
|
+
|
|
189
|
+
// Now transition to watching
|
|
190
|
+
updateUI(ctx, widgetState, state, { type: 'watching' });
|
|
191
|
+
|
|
192
|
+
// After the first animation step, the widget should show watching state
|
|
193
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
194
|
+
const widgetFactory = lastCall[1];
|
|
195
|
+
const widget = widgetFactory(null, mockTheme);
|
|
196
|
+
const lines = widget.render(100);
|
|
197
|
+
|
|
198
|
+
const allText = lines.join(' ');
|
|
199
|
+
expect(allText).toContain('watching');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('animates thinking away when leaving analyzing for inferring (with render cycle)', () => {
|
|
203
|
+
const ctx = createMockCtx();
|
|
204
|
+
const state = createMockState();
|
|
205
|
+
|
|
206
|
+
// First update: analyzing with thinking
|
|
207
|
+
updateUI(ctx, widgetState, state, {
|
|
208
|
+
type: 'analyzing',
|
|
209
|
+
thinking: 'Analysis that should animate away before inferring',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Simulate the TUI render cycle
|
|
213
|
+
const analyzingCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
214
|
+
const analyzingWidget = analyzingCall[1](null, mockTheme);
|
|
215
|
+
analyzingWidget.render(100);
|
|
216
|
+
|
|
217
|
+
// Now transition to inferring
|
|
218
|
+
updateUI(ctx, widgetState, state, { type: 'inferring' });
|
|
219
|
+
|
|
220
|
+
// The animation should start; after it completes, the inferring state is shown
|
|
221
|
+
const allCalls = ctx.ui.setWidget.mock.calls;
|
|
222
|
+
const lastCall = allCalls[allCalls.length - 1];
|
|
223
|
+
const widgetFactory = lastCall[1];
|
|
224
|
+
const widget = widgetFactory(null, mockTheme);
|
|
225
|
+
const lines = widget.render(100);
|
|
226
|
+
|
|
227
|
+
const allText = lines.join(' ');
|
|
228
|
+
expect(allText).toContain('Inferring');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('completes inferring animation and clears thinking lines', () => {
|
|
232
|
+
const ctx = createMockCtx();
|
|
233
|
+
const state = createMockState();
|
|
234
|
+
vi.useFakeTimers();
|
|
235
|
+
|
|
236
|
+
// First update: analyzing with thinking
|
|
237
|
+
updateUI(ctx, widgetState, state, {
|
|
238
|
+
type: 'analyzing',
|
|
239
|
+
thinking: 'Short thinking for inferring test',
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Simulate the TUI render cycle
|
|
243
|
+
const analyzingCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
244
|
+
const analyzingWidget = analyzingCall[1](null, mockTheme);
|
|
245
|
+
analyzingWidget.render(100);
|
|
246
|
+
|
|
247
|
+
// Transition to inferring
|
|
248
|
+
updateUI(ctx, widgetState, state, { type: 'inferring' });
|
|
249
|
+
|
|
250
|
+
// Run all animation timers
|
|
251
|
+
vi.advanceTimersByTime(5000);
|
|
252
|
+
|
|
253
|
+
// After animation completes, thinking lines should be cleared
|
|
254
|
+
expect(widgetState.lastThinkingLines).toEqual([]);
|
|
255
|
+
expect(widgetState.hiddenFromBottomCount).toBe(0);
|
|
256
|
+
|
|
257
|
+
vi.useRealTimers();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('animates thinking away when leaving analyzing for waiting (with render cycle)', () => {
|
|
261
|
+
const ctx = createMockCtx();
|
|
262
|
+
const state = createMockState();
|
|
263
|
+
|
|
264
|
+
// First update: analyzing with thinking
|
|
265
|
+
updateUI(ctx, widgetState, state, {
|
|
266
|
+
type: 'analyzing',
|
|
267
|
+
thinking: 'Analysis that should animate away before waiting',
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Simulate the TUI render cycle
|
|
271
|
+
const analyzingCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
272
|
+
const analyzingWidget = analyzingCall[1](null, mockTheme);
|
|
273
|
+
analyzingWidget.render(100);
|
|
274
|
+
|
|
275
|
+
// Now transition to waiting
|
|
276
|
+
updateUI(ctx, widgetState, state, {
|
|
277
|
+
type: 'waiting',
|
|
278
|
+
message: 'Waiting for 2 subagent(s)...',
|
|
279
|
+
reframeTier: 0,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// The animation should start; after first step, waiting state is shown
|
|
283
|
+
const allCalls = ctx.ui.setWidget.mock.calls;
|
|
284
|
+
const lastCall = allCalls[allCalls.length - 1];
|
|
285
|
+
const widgetFactory = lastCall[1];
|
|
286
|
+
const widget = widgetFactory(null, mockTheme);
|
|
287
|
+
const lines = widget.render(100);
|
|
288
|
+
|
|
289
|
+
const allText = lines.join(' ');
|
|
290
|
+
expect(allText).toContain('Waiting');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('does not accumulate stale thinking through multiple rapid steers', () => {
|
|
294
|
+
const ctx = createMockCtx();
|
|
295
|
+
const state = createMockState();
|
|
296
|
+
|
|
297
|
+
updateUI(ctx, widgetState, state, {
|
|
298
|
+
type: 'analyzing',
|
|
299
|
+
thinking: 'Initial analysis content',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
for (let i = 0; i < 5; i++) {
|
|
303
|
+
updateUI(ctx, widgetState, state, {
|
|
304
|
+
type: 'steering',
|
|
305
|
+
message: `Steer ${i + 1}`,
|
|
306
|
+
reframeTier: 0,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
311
|
+
const widgetFactory = lastCall[1];
|
|
312
|
+
const widget = widgetFactory(null, mockTheme);
|
|
313
|
+
const lines = widget.render(100);
|
|
314
|
+
|
|
315
|
+
expect(lines.length).toBe(1);
|
|
316
|
+
const allText = lines.join(' ');
|
|
317
|
+
expect(allText).not.toContain('Initial analysis');
|
|
318
|
+
expect(allText).toContain('steering');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('keeps thinking visible when leaving analyzing for done state', () => {
|
|
322
|
+
const ctx = createMockCtx();
|
|
323
|
+
const state = createMockState();
|
|
324
|
+
|
|
325
|
+
updateUI(ctx, widgetState, state, {
|
|
326
|
+
type: 'analyzing',
|
|
327
|
+
thinking: 'Analysis that should remain visible on done',
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
updateUI(ctx, widgetState, state, { type: 'done' });
|
|
331
|
+
|
|
332
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
333
|
+
const widgetFactory = lastCall[1];
|
|
334
|
+
const widget = widgetFactory(null, mockTheme);
|
|
335
|
+
const lines = widget.render(100);
|
|
336
|
+
|
|
337
|
+
// Done state should show thinking lines (they animate away later)
|
|
338
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
339
|
+
const allText = lines.join(' ');
|
|
340
|
+
expect(allText).toContain('done');
|
|
341
|
+
expect(allText).toContain('Analysis that should remain visible on done');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('does not flash old thoughts when re-entering analyzing after steering', () => {
|
|
345
|
+
const ctx = createMockCtx();
|
|
346
|
+
const state = createMockState();
|
|
347
|
+
|
|
348
|
+
updateUI(ctx, widgetState, state, {
|
|
349
|
+
type: 'analyzing',
|
|
350
|
+
thinking: 'First round of analysis',
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
updateUI(ctx, widgetState, state, {
|
|
354
|
+
type: 'steering',
|
|
355
|
+
message: 'Fix this',
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
updateUI(ctx, widgetState, state, {
|
|
359
|
+
type: 'analyzing',
|
|
360
|
+
thinking: 'Fresh second round analysis',
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
364
|
+
const widgetFactory = lastCall[1];
|
|
365
|
+
const widget = widgetFactory(null, mockTheme);
|
|
366
|
+
const lines = widget.render(100);
|
|
367
|
+
|
|
368
|
+
const allText = lines.join(' ');
|
|
369
|
+
expect(allText).toContain('Fresh second round');
|
|
370
|
+
expect(allText).not.toContain('First round');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('handles transition from cleared state to new analysis', () => {
|
|
374
|
+
const ctx = createMockCtx();
|
|
375
|
+
const state = createMockState();
|
|
376
|
+
|
|
377
|
+
updateUI(ctx, widgetState, state, {
|
|
378
|
+
type: 'analyzing',
|
|
379
|
+
thinking: 'Initial analysis',
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
updateUI(ctx, widgetState, state, { type: 'done' });
|
|
383
|
+
|
|
384
|
+
updateUI(ctx, widgetState, state, {
|
|
385
|
+
type: 'analyzing',
|
|
386
|
+
thinking: 'New analysis after completion',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
390
|
+
const widgetFactory = lastCall[1];
|
|
391
|
+
const widget = widgetFactory(null, mockTheme);
|
|
392
|
+
const lines = widget.render(100);
|
|
393
|
+
|
|
394
|
+
const allText = lines.join(' ');
|
|
395
|
+
expect(allText).toContain('New analysis after completion');
|
|
396
|
+
expect(allText).not.toContain('Initial analysis');
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe('widget visibility', () => {
|
|
401
|
+
it('hides widget when toggle is off', () => {
|
|
402
|
+
const ctx = createMockCtx();
|
|
403
|
+
const state = createMockState();
|
|
404
|
+
|
|
405
|
+
toggleWidget(widgetState);
|
|
406
|
+
|
|
407
|
+
updateUI(ctx, widgetState, state, {
|
|
408
|
+
type: 'analyzing',
|
|
409
|
+
thinking: 'Should not be visible',
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(ctx.ui.setWidget).toHaveBeenCalledWith('supervisor', undefined);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('goal rendering', () => {
|
|
417
|
+
it('sanitizes newlines in the goal to keep the header on a single line', () => {
|
|
418
|
+
const ctx = createMockCtx();
|
|
419
|
+
const state = createMockState({
|
|
420
|
+
outcome: 'Fix two bugs\n1. Bug 2 (PRIMARY): PtyTreeRow',
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
updateUI(ctx, widgetState, state, { type: 'watching' });
|
|
424
|
+
|
|
425
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
426
|
+
const widgetFactory = lastCall[1];
|
|
427
|
+
const widget = widgetFactory(null, mockTheme);
|
|
428
|
+
const lines = widget.render(100);
|
|
429
|
+
|
|
430
|
+
expect(lines.length).toBe(1);
|
|
431
|
+
expect(lines[0]).toContain('Fix two bugs 1. Bug 2 (PRIMARY): PtyTreeRow');
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe('thinking multiline handling', () => {
|
|
436
|
+
it('collapses newlines in thinking text to spaces', () => {
|
|
437
|
+
const ctx = createMockCtx();
|
|
438
|
+
const state = createMockState({
|
|
439
|
+
outcome: 'Test goal',
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
updateUI(ctx, widgetState, state, {
|
|
443
|
+
type: 'analyzing',
|
|
444
|
+
thinking: 'First line\nSecond line of thinking',
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
448
|
+
const widgetFactory = lastCall[1];
|
|
449
|
+
const widget = widgetFactory(null, mockTheme);
|
|
450
|
+
const lines = widget.render(100);
|
|
451
|
+
|
|
452
|
+
// No line should contain a literal newline
|
|
453
|
+
for (const line of lines) {
|
|
454
|
+
expect(line).not.toContain('\n');
|
|
455
|
+
}
|
|
456
|
+
// The thinking content should be present as a single wrapped flow
|
|
457
|
+
const allText = lines.join(' ');
|
|
458
|
+
expect(allText).toContain('First line');
|
|
459
|
+
expect(allText).toContain('Second line of thinking');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('collapses CRLF in thinking text', () => {
|
|
463
|
+
const ctx = createMockCtx();
|
|
464
|
+
const state = createMockState({
|
|
465
|
+
outcome: 'Test goal',
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
updateUI(ctx, widgetState, state, {
|
|
469
|
+
type: 'analyzing',
|
|
470
|
+
thinking: 'First line\r\nSecond line',
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
474
|
+
const widgetFactory = lastCall[1];
|
|
475
|
+
const widget = widgetFactory(null, mockTheme);
|
|
476
|
+
const lines = widget.render(100);
|
|
477
|
+
|
|
478
|
+
for (const line of lines) {
|
|
479
|
+
expect(line).not.toContain('\r');
|
|
480
|
+
expect(line).not.toContain('\n');
|
|
481
|
+
}
|
|
482
|
+
const allText = lines.join(' ');
|
|
483
|
+
expect(allText).toContain('First line');
|
|
484
|
+
expect(allText).toContain('Second line');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('truncates thinking lines to terminal width', () => {
|
|
488
|
+
const ctx = createMockCtx();
|
|
489
|
+
const state = createMockState({
|
|
490
|
+
outcome: 'Fix the bug',
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const longThinking = 'A '.repeat(80) + 'and this word will overflow the terminal width';
|
|
494
|
+
|
|
495
|
+
updateUI(ctx, widgetState, state, {
|
|
496
|
+
type: 'analyzing',
|
|
497
|
+
thinking: longThinking,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
501
|
+
const widgetFactory = lastCall[1];
|
|
502
|
+
const widget = widgetFactory(null, mockTheme);
|
|
503
|
+
const lines = widget.render(40);
|
|
504
|
+
|
|
505
|
+
for (const line of lines) {
|
|
506
|
+
// Each line must fit within paddedWidth (width - 1)
|
|
507
|
+
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
508
|
+
expect(visibleLen).toBeLessThanOrEqual(39);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('truncates preserved thinking lines to terminal width', () => {
|
|
513
|
+
const ctx = createMockCtx();
|
|
514
|
+
const state = createMockState({
|
|
515
|
+
outcome: 'Fix the bug',
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const longThinking = 'A '.repeat(80) + 'overflow word';
|
|
519
|
+
|
|
520
|
+
updateUI(ctx, widgetState, state, {
|
|
521
|
+
type: 'analyzing',
|
|
522
|
+
thinking: longThinking,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Transition to done — preserved lines are re-rendered
|
|
526
|
+
updateUI(ctx, widgetState, state, { type: 'done' });
|
|
527
|
+
|
|
528
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
529
|
+
const widgetFactory = lastCall[1];
|
|
530
|
+
const widget = widgetFactory(null, mockTheme);
|
|
531
|
+
const lines = widget.render(40);
|
|
532
|
+
|
|
533
|
+
for (const line of lines) {
|
|
534
|
+
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
535
|
+
expect(visibleLen).toBeLessThanOrEqual(39);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
});
|