@raidou/pi-pm-subagents 0.1.1

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 (69) hide show
  1. package/.prettierrc +7 -0
  2. package/AGENTS.md +1 -0
  3. package/README.md +85 -0
  4. package/README.zh-CN.md +85 -0
  5. package/agents/explorer.md +10 -0
  6. package/agents/planner.md +16 -0
  7. package/agents/researcher.md +22 -0
  8. package/agents/reviewer.md +10 -0
  9. package/eslint.config.mjs +14 -0
  10. package/example-prompts/coordinator.md +21 -0
  11. package/package.json +48 -0
  12. package/pm-subagents-prompts/coordinator.md +16 -0
  13. package/pnpm-workspace.yaml +5 -0
  14. package/src/bash-readonly.test.ts +331 -0
  15. package/src/bash-readonly.ts +205 -0
  16. package/src/coordinator/coordinator.test.ts +28 -0
  17. package/src/coordinator/coordinator.ts +275 -0
  18. package/src/custom-select.test.ts +91 -0
  19. package/src/custom-select.ts +209 -0
  20. package/src/index.ts +87 -0
  21. package/src/models-config/models-config.test.ts +88 -0
  22. package/src/models-config/models-config.ts +205 -0
  23. package/src/models-config/scoped-models-editor.test.ts +189 -0
  24. package/src/models-config/scoped-models-editor.ts +412 -0
  25. package/src/models-config/subagent-model-constants.ts +2 -0
  26. package/src/models-config/subagent-model-cycle.ts +53 -0
  27. package/src/models-config/subagent-model-utils.test.ts +250 -0
  28. package/src/models-config/subagent-model-utils.ts +52 -0
  29. package/src/pm-mode.test.ts +324 -0
  30. package/src/pm-mode.ts +142 -0
  31. package/src/prompts/mode.test.ts +289 -0
  32. package/src/prompts/mode.ts +31 -0
  33. package/src/prompts/roles.test.ts +724 -0
  34. package/src/prompts/roles.ts +119 -0
  35. package/src/subagent/activity.test.ts +230 -0
  36. package/src/subagent/activity.ts +60 -0
  37. package/src/subagent/batcher.test.ts +198 -0
  38. package/src/subagent/batcher.ts +51 -0
  39. package/src/subagent/consts.ts +1 -0
  40. package/src/subagent/demo.ts +773 -0
  41. package/src/subagent/fleet.test.ts +1758 -0
  42. package/src/subagent/fleet.ts +376 -0
  43. package/src/subagent/identity.test.ts +31 -0
  44. package/src/subagent/identity.ts +16 -0
  45. package/src/subagent/manager.test.ts +392 -0
  46. package/src/subagent/manager.ts +277 -0
  47. package/src/subagent/tools.ts +314 -0
  48. package/src/subagent/viewer.ts +305 -0
  49. package/src/types.ts +15 -0
  50. package/src/ui/border-view.ts +50 -0
  51. package/src/ui/review-pager.ts +146 -0
  52. package/src/ui/scroll-view.test.ts +190 -0
  53. package/src/ui/scroll-view.ts +155 -0
  54. package/src/utils/format.test.ts +76 -0
  55. package/src/utils/format.ts +67 -0
  56. package/src/utils/fs.ts +9 -0
  57. package/src/utils/markdown.test.ts +442 -0
  58. package/src/utils/markdown.ts +79 -0
  59. package/src/utils/messages.test.ts +436 -0
  60. package/src/utils/messages.ts +131 -0
  61. package/src/utils/model-ref.test.ts +42 -0
  62. package/src/utils/model-ref.ts +44 -0
  63. package/src/utils/state.test.ts +98 -0
  64. package/src/utils/state.ts +45 -0
  65. package/src/utils/tools.ts +48 -0
  66. package/src/utils/truncate.test.ts +41 -0
  67. package/src/utils/truncate.ts +59 -0
  68. package/tsconfig.json +24 -0
  69. package/vitest.config.ts +8 -0
@@ -0,0 +1,1758 @@
1
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
2
+ import type { TUI } from '@earendil-works/pi-tui'
3
+ import { Editor } from '@earendil-works/pi-tui'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { formatElapsed } from '../utils/format.js'
7
+ import { type FleetEntry, FleetList, type FleetListOptions } from './fleet.js'
8
+
9
+ function createFakeTheme() {
10
+ return {
11
+ fg: (variant: string, text: string) => `[FG:${variant}]${text}[/-FG]`,
12
+ bg: (variant: string, text: string) => `[BG:${variant}]${text}[/-BG]`,
13
+ bold: (text: string) => `[BOLD]${text}[/BOLD]`,
14
+ borderColor: (str: string) => str,
15
+ selectList: {
16
+ selectedPrefix: (text: string) => text,
17
+ selectedText: (text: string) => text,
18
+ description: (text: string) => text,
19
+ scrollInfo: (text: string) => text,
20
+ noMatch: (text: string) => text,
21
+ },
22
+ }
23
+ }
24
+
25
+ describe('FleetList roster sorting', () => {
26
+ const now = Date.now()
27
+
28
+ function createFleetList(entries: FleetEntry[]) {
29
+ return new FleetList({
30
+ list: () => entries,
31
+ onOpen: () => {},
32
+ })
33
+ }
34
+
35
+ it('sorts running items first by id descending, then done items by id descending', () => {
36
+ const entries: FleetEntry[] = [
37
+ {
38
+ id: 1,
39
+ title: 'task 1',
40
+ previousEntries: [],
41
+ status: 'done',
42
+ startedAt: now - 10000,
43
+ completedAt: now - 5000,
44
+ followUpCount: 0,
45
+ role: 'worker',
46
+ },
47
+ {
48
+ id: 5,
49
+ title: 'task 5',
50
+ previousEntries: [],
51
+ status: 'done',
52
+ startedAt: now - 20000,
53
+ completedAt: now - 15000,
54
+ followUpCount: 1,
55
+ role: 'worker',
56
+ },
57
+ {
58
+ id: 3,
59
+ title: 'task 3',
60
+ previousEntries: [],
61
+ status: 'running',
62
+ startedAt: now - 3000,
63
+ followUpCount: 0,
64
+ role: 'worker',
65
+ },
66
+ {
67
+ id: 4,
68
+ title: 'task 4',
69
+ previousEntries: [],
70
+ status: 'running',
71
+ startedAt: now - 4000,
72
+ followUpCount: 2,
73
+ role: 'supervisor',
74
+ },
75
+ {
76
+ id: 2,
77
+ title: 'task 2',
78
+ previousEntries: [],
79
+ status: 'done',
80
+ startedAt: now - 8000,
81
+ completedAt: now - 6000,
82
+ followUpCount: 0,
83
+ role: 'worker',
84
+ },
85
+ ]
86
+
87
+ const fleetList = createFleetList(entries)
88
+
89
+ const roster = fleetList['roster']()
90
+
91
+ const itemEntries = roster.filter((entry) => entry.kind === 'item')
92
+
93
+ expect(itemEntries.map((e) => e.item.id)).toEqual([4, 3, 5, 2, 1])
94
+
95
+ expect(itemEntries.map((e) => e.item.status)).toEqual([
96
+ 'running',
97
+ 'running',
98
+ 'done',
99
+ 'done',
100
+ 'done',
101
+ ])
102
+ })
103
+
104
+ it('handles all running items', () => {
105
+ const entries: FleetEntry[] = [
106
+ {
107
+ id: 1,
108
+ title: 'task 1',
109
+ previousEntries: [],
110
+ status: 'running',
111
+ startedAt: now - 1000,
112
+ followUpCount: 0,
113
+ role: 'worker',
114
+ },
115
+ {
116
+ id: 3,
117
+ title: 'task 3',
118
+ previousEntries: [],
119
+ status: 'running',
120
+ startedAt: now - 3000,
121
+ followUpCount: 1,
122
+ role: 'supervisor',
123
+ },
124
+ {
125
+ id: 2,
126
+ title: 'task 2',
127
+ previousEntries: [],
128
+ status: 'running',
129
+ startedAt: now - 2000,
130
+ followUpCount: 0,
131
+ role: 'worker',
132
+ },
133
+ ]
134
+
135
+ const fleetList = createFleetList(entries)
136
+ const roster = fleetList['roster']()
137
+ const itemEntries = roster.filter((entry) => entry.kind === 'item')
138
+
139
+ expect(itemEntries.map((e) => e.item.id)).toEqual([3, 2, 1])
140
+ })
141
+
142
+ it('handles all done items', () => {
143
+ const entries: FleetEntry[] = [
144
+ {
145
+ id: 2,
146
+ title: 'task 2',
147
+ previousEntries: [],
148
+ status: 'done',
149
+ startedAt: now - 2000,
150
+ completedAt: now - 1000,
151
+ followUpCount: 0,
152
+ role: 'worker',
153
+ },
154
+ {
155
+ id: 5,
156
+ title: 'task 5',
157
+ previousEntries: [],
158
+ status: 'done',
159
+ startedAt: now - 5000,
160
+ completedAt: now - 4000,
161
+ followUpCount: 1,
162
+ role: 'worker',
163
+ },
164
+ {
165
+ id: 1,
166
+ title: 'task 1',
167
+ previousEntries: [],
168
+ status: 'done',
169
+ startedAt: now - 1000,
170
+ completedAt: now - 500,
171
+ followUpCount: 0,
172
+ role: 'supervisor',
173
+ },
174
+ ]
175
+
176
+ const fleetList = createFleetList(entries)
177
+ const roster = fleetList['roster']()
178
+ const itemEntries = roster.filter((entry) => entry.kind === 'item')
179
+
180
+ expect(itemEntries.map((e) => e.item.id)).toEqual([5, 2, 1])
181
+ })
182
+
183
+ it('handles mixed statuses including failed and killed', () => {
184
+ const entries: FleetEntry[] = [
185
+ {
186
+ id: 1,
187
+ title: 'task 1',
188
+ previousEntries: [],
189
+ status: 'failed',
190
+ startedAt: now - 10000,
191
+ completedAt: now - 5000,
192
+ followUpCount: 0,
193
+ role: 'worker',
194
+ },
195
+ {
196
+ id: 4,
197
+ title: 'task 4',
198
+ previousEntries: [],
199
+ status: 'running',
200
+ startedAt: now - 2000,
201
+ followUpCount: 0,
202
+ role: 'worker',
203
+ },
204
+ {
205
+ id: 2,
206
+ title: 'task 2',
207
+ previousEntries: [],
208
+ status: 'killed',
209
+ startedAt: now - 8000,
210
+ completedAt: now - 7000,
211
+ followUpCount: 1,
212
+ role: 'worker',
213
+ },
214
+ {
215
+ id: 3,
216
+ title: 'task 3',
217
+ previousEntries: [],
218
+ status: 'running',
219
+ startedAt: now - 3000,
220
+ followUpCount: 0,
221
+ role: 'supervisor',
222
+ },
223
+ ]
224
+
225
+ const fleetList = createFleetList(entries)
226
+ const roster = fleetList['roster']()
227
+ const itemEntries = roster.filter((entry) => entry.kind === 'item')
228
+
229
+ expect(itemEntries.map((e) => e.item.id)).toEqual([4, 3, 2, 1])
230
+
231
+ expect(itemEntries.map((e) => e.item.status)).toEqual([
232
+ 'running',
233
+ 'running',
234
+ 'killed',
235
+ 'failed',
236
+ ])
237
+ })
238
+ })
239
+
240
+ describe('formatElapsed', () => {
241
+ const base = (
242
+ overrides: Partial<FleetEntry> & { title?: never },
243
+ ): FleetEntry => ({
244
+ id: 1,
245
+ title: '',
246
+ previousEntries: [],
247
+ startedAt: 1000,
248
+ status: 'running',
249
+ followUpCount: 0,
250
+ role: 'worker',
251
+ ...overrides,
252
+ })
253
+
254
+ it('rounds down to whole seconds', () => {
255
+ expect(formatElapsed(base({ startedAt: 0, completedAt: 12_400 }))).toBe(
256
+ '12s',
257
+ )
258
+ })
259
+
260
+ it('uses now when the subagent is still running', () => {
261
+ const now = Date.now()
262
+ const subagent = base({ startedAt: now - 4_000 })
263
+ expect(formatElapsed(subagent)).toBe('4s')
264
+ })
265
+
266
+ it('never goes negative', () => {
267
+ expect(formatElapsed(base({ startedAt: 5_000, completedAt: 1_000 }))).toBe(
268
+ '0s',
269
+ )
270
+ })
271
+
272
+ it('shows minutes for >= 60s', () => {
273
+ expect(formatElapsed(base({ startedAt: 0, completedAt: 5 * 60_000 }))).toBe(
274
+ '5m0s',
275
+ )
276
+ expect(
277
+ formatElapsed(base({ startedAt: 0, completedAt: 10 * 60_000 })),
278
+ ).toBe('10m0s')
279
+ })
280
+
281
+ it('shows hours for >= 60min', () => {
282
+ expect(
283
+ formatElapsed(base({ startedAt: 0, completedAt: 2 * 60 * 60_000 })),
284
+ ).toBe('2h0m0s')
285
+ expect(
286
+ formatElapsed(base({ startedAt: 0, completedAt: 24 * 60 * 60_000 })),
287
+ ).toBe('24h0m0s')
288
+ })
289
+
290
+ it('handles boundary values', () => {
291
+ expect(formatElapsed(base({ startedAt: 0, completedAt: 59_999 }))).toBe(
292
+ '59s',
293
+ )
294
+ expect(formatElapsed(base({ startedAt: 0, completedAt: 60_000 }))).toBe(
295
+ '1m0s',
296
+ )
297
+ expect(
298
+ formatElapsed(base({ startedAt: 0, completedAt: 59 * 60_000 })),
299
+ ).toBe('59m0s')
300
+ expect(
301
+ formatElapsed(base({ startedAt: 0, completedAt: 60 * 60_000 })),
302
+ ).toBe('1h0m0s')
303
+ })
304
+
305
+ it('rounds down minutes', () => {
306
+ expect(formatElapsed(base({ startedAt: 0, completedAt: 89_999 }))).toBe(
307
+ '1m29s',
308
+ )
309
+ expect(formatElapsed(base({ startedAt: 0, completedAt: 119_999 }))).toBe(
310
+ '1m59s',
311
+ )
312
+ expect(formatElapsed(base({ startedAt: 0, completedAt: 120_000 }))).toBe(
313
+ '2m0s',
314
+ )
315
+ })
316
+
317
+ it('shows remainder seconds when >= 1 minute', () => {
318
+ expect(
319
+ formatElapsed(base({ startedAt: 0, completedAt: 5 * 60_000 + 30_000 })),
320
+ ).toBe('5m30s')
321
+ expect(
322
+ formatElapsed(base({ startedAt: 0, completedAt: 1 * 60_000 + 59_999 })),
323
+ ).toBe('1m59s')
324
+ })
325
+
326
+ it('shows remainder minutes and seconds when >= 1 hour', () => {
327
+ expect(
328
+ formatElapsed(
329
+ base({
330
+ startedAt: 0,
331
+ completedAt: 2 * 60 * 60_000 + 5 * 60_000 + 30_000,
332
+ }),
333
+ ),
334
+ ).toBe('2h5m30s')
335
+ expect(
336
+ formatElapsed(
337
+ base({
338
+ startedAt: 0,
339
+ completedAt: 1 * 60 * 60_000 + 30 * 60_000 + 45_000,
340
+ }),
341
+ ),
342
+ ).toBe('1h30m45s')
343
+ })
344
+ })
345
+
346
+ describe('FleetList focus gate (#123)', () => {
347
+ type FakeTui = {
348
+ requestRender: () => void
349
+ hasOverlay: () => boolean
350
+ focusedComponent?: unknown
351
+ }
352
+
353
+ let fakeTui: FakeTui
354
+ let capturedOnTerminalInput:
355
+ ((data: string) => { consume?: boolean } | undefined) | undefined
356
+ let capturedSetWidget: (
357
+ key: string,
358
+ factory: ((tui: unknown) => unknown) | undefined,
359
+ options?: unknown,
360
+ ) => void
361
+
362
+ function createFakeContext(): Record<string, unknown> {
363
+ const fakeContext = {
364
+ ui: {
365
+ getEditorText: () => '',
366
+ setWidget: (
367
+ key: string,
368
+ factory: ((tui: unknown) => unknown) | undefined,
369
+ options?: unknown,
370
+ ) => {
371
+ capturedSetWidget(key, factory, options)
372
+ },
373
+ theme: createFakeTheme(),
374
+ onTerminalInput: (
375
+ handler: (data: string) => { consume?: boolean } | undefined,
376
+ ) => {
377
+ capturedOnTerminalInput = handler
378
+ return () => {
379
+ capturedOnTerminalInput = undefined
380
+ }
381
+ },
382
+ notify: () => {},
383
+ },
384
+ }
385
+ return fakeContext
386
+ }
387
+
388
+ function createFakeTui(): FakeTui {
389
+ return {
390
+ requestRender: () => {},
391
+ hasOverlay: () => false,
392
+ focusedComponent: null,
393
+ }
394
+ }
395
+
396
+ let entries: FleetEntry[]
397
+ let fleetList: FleetList
398
+
399
+ function createHarness() {
400
+ fakeTui = createFakeTui()
401
+ capturedOnTerminalInput = undefined
402
+ capturedSetWidget = () => {}
403
+
404
+ const now = Date.now()
405
+ entries = [
406
+ {
407
+ id: 1,
408
+ title: 'test task',
409
+ previousEntries: [],
410
+ status: 'running',
411
+ startedAt: now,
412
+ followUpCount: 0,
413
+ role: 'worker',
414
+ },
415
+ ]
416
+
417
+ fleetList = new FleetList({
418
+ list: () => entries,
419
+ onOpen: () => {},
420
+ })
421
+
422
+ capturedSetWidget = (
423
+ key: string,
424
+ factory: ((tui: unknown) => unknown) | undefined,
425
+ ) => {
426
+ if (factory) {
427
+ const result = factory(fakeTui)
428
+ Object.assign(fakeTui, result)
429
+ }
430
+ }
431
+
432
+ const fakeContext = createFakeContext()
433
+ fleetList.setContext(fakeContext as unknown as ExtensionContext)
434
+ }
435
+
436
+ function press(key: string): { consume?: boolean } | undefined {
437
+ if (!capturedOnTerminalInput) {
438
+ throw new Error('onTerminalInput handler not captured')
439
+ }
440
+ return capturedOnTerminalInput(key)
441
+ }
442
+
443
+ const DOWN = '\x1b[B'
444
+
445
+ function setFocus(comp: unknown) {
446
+ fakeTui.focusedComponent = comp
447
+ }
448
+
449
+ it('does not consume key when focused component is non-editor', () => {
450
+ createHarness()
451
+ setFocus({ kind: 'selector' })
452
+ const result = press(DOWN)
453
+ expect(result).toBeUndefined()
454
+ })
455
+
456
+ it('consumes key when focused component is editor', () => {
457
+ createHarness()
458
+ const editor = new Editor(fakeTui as unknown as TUI, createFakeTheme())
459
+ setFocus(editor)
460
+ const result = press(DOWN)
461
+ expect(result).toEqual({ consume: true })
462
+ })
463
+
464
+ it('deactivates when non-editor component receives focus while active', () => {
465
+ createHarness()
466
+ const editor = new Editor(fakeTui as unknown as TUI, createFakeTheme())
467
+ setFocus(editor)
468
+ const firstResult = press(DOWN)
469
+ expect(firstResult).toEqual({ consume: true })
470
+
471
+ setFocus({ kind: 'selector' })
472
+ const secondResult = press(DOWN)
473
+ expect(secondResult).toBeUndefined()
474
+ })
475
+
476
+ it('consumes key when tui has not yet rendered (focusedComponent is null)', () => {
477
+ createHarness()
478
+ setFocus(null)
479
+ const result = press(DOWN)
480
+ expect(result).toEqual({ consume: true })
481
+ })
482
+ })
483
+
484
+ describe('FleetList renderBar when inactive', () => {
485
+ function createFakeContext(): Record<string, unknown> {
486
+ return {
487
+ ui: {
488
+ getEditorText: () => '',
489
+ setWidget: () => {},
490
+ theme: createFakeTheme(),
491
+ onTerminalInput: () => () => {},
492
+ notify: () => {},
493
+ },
494
+ }
495
+ }
496
+
497
+ it('renders main line without any bullet', () => {
498
+ const now = Date.now()
499
+ const entries: FleetEntry[] = [
500
+ {
501
+ id: 1,
502
+ title: 'test task',
503
+ previousEntries: [],
504
+ status: 'running',
505
+ startedAt: now,
506
+ followUpCount: 0,
507
+ role: 'worker',
508
+ },
509
+ ]
510
+
511
+ const fleetList = new FleetList({
512
+ list: () => entries,
513
+ onOpen: () => {},
514
+ })
515
+
516
+ const fakeContext = createFakeContext()
517
+ fleetList.setContext(fakeContext as unknown as ExtensionContext)
518
+
519
+ // Simulate inactive state (activeSelect = false)
520
+ fleetList['activeSelect'] = false
521
+
522
+ // Get the render output
523
+ const width = 100
524
+ const render = (
525
+ fleetList as unknown as { renderBar: (w: number) => string[] }
526
+ ).renderBar(width)
527
+
528
+ // Check that main line does not contain any bullet
529
+ const mainLine = render.find((line: string) => line.includes('subagents'))
530
+ expect(mainLine).toBeDefined()
531
+ expect(mainLine).not.toContain('●')
532
+ expect(mainLine).not.toContain('◯')
533
+ })
534
+
535
+ it('renders all subagents without selected bullet (●) when inactive', () => {
536
+ const now = Date.now()
537
+ const entries: FleetEntry[] = [
538
+ {
539
+ id: 1,
540
+ title: 'task 1',
541
+ previousEntries: [],
542
+ status: 'running',
543
+ startedAt: now,
544
+ followUpCount: 0,
545
+ role: 'worker',
546
+ },
547
+ {
548
+ id: 2,
549
+ title: 'task 2',
550
+ previousEntries: [],
551
+ status: 'done',
552
+ startedAt: now - 10000,
553
+ completedAt: now - 5000,
554
+ followUpCount: 1,
555
+ role: 'supervisor',
556
+ },
557
+ ]
558
+
559
+ const fleetList = new FleetList({
560
+ list: () => entries,
561
+ onOpen: () => {},
562
+ })
563
+
564
+ const fakeContext = createFakeContext()
565
+ fleetList.setContext(fakeContext as unknown as ExtensionContext)
566
+
567
+ // Simulate inactive state (activeSelect = false)
568
+ fleetList['activeSelect'] = false
569
+
570
+ // Get the render output
571
+ const width = 100
572
+ const render = (
573
+ fleetList as unknown as { renderBar: (w: number) => string[] }
574
+ ).renderBar(width)
575
+
576
+ // Check that no line contains the selected bullet (●)
577
+ for (const line of render) {
578
+ expect(line).not.toContain('●')
579
+ }
580
+ })
581
+
582
+ it('renders first item with selected bullet (●) when active and selectedIndex is 1', () => {
583
+ const now = Date.now()
584
+ const entries: FleetEntry[] = [
585
+ {
586
+ id: 1,
587
+ title: 'test task',
588
+ previousEntries: [],
589
+ status: 'running',
590
+ startedAt: now,
591
+ followUpCount: 0,
592
+ role: 'worker',
593
+ },
594
+ ]
595
+
596
+ const fleetList = new FleetList({
597
+ list: () => entries,
598
+ onOpen: () => {},
599
+ })
600
+
601
+ const fakeContext = createFakeContext()
602
+ fleetList.setContext(fakeContext as unknown as ExtensionContext)
603
+
604
+ // Simulate active state (activeSelect = true, selectedIndex = 1 for first item)
605
+ fleetList['activeSelect'] = true
606
+ fleetList['selectedIndex'] = 1
607
+
608
+ // Get the render output
609
+ const width = 200
610
+ const render = (
611
+ fleetList as unknown as { renderBar: (w: number) => string[] }
612
+ ).renderBar(width)
613
+
614
+ // Check that main line has no bullet
615
+ const mainLine = render.find((line: string) => line.includes('subagents'))
616
+ expect(mainLine).toBeDefined()
617
+ expect(mainLine).not.toContain('●')
618
+ expect(mainLine).not.toContain('◯')
619
+
620
+ // Check that an item line contains the selected bullet (●)
621
+ const itemLine = render.find((line: string) =>
622
+ line.includes('[BG:selectedBg]'),
623
+ )
624
+ expect(itemLine).toBeDefined()
625
+ expect(itemLine).toContain('●')
626
+ })
627
+
628
+ it('renders previousEntries with status-colored titles, status, followUpCount, and elapsed', () => {
629
+ const now = Date.now()
630
+ const entries: FleetEntry[] = [
631
+ {
632
+ id: 1,
633
+ title: 'current task',
634
+ previousEntries: [
635
+ {
636
+ title: 'previous 1',
637
+ status: 'done',
638
+ followUpCount: 1,
639
+ startedAt: now - 8000,
640
+ completedAt: now - 5000,
641
+ },
642
+ {
643
+ title: 'previous 2',
644
+ status: 'done',
645
+ followUpCount: 0,
646
+ startedAt: now - 10000,
647
+ completedAt: now - 5000,
648
+ },
649
+ ],
650
+ status: 'done',
651
+ startedAt: now - 5000,
652
+ completedAt: now - 5000,
653
+ followUpCount: 2,
654
+ role: 'worker',
655
+ },
656
+ ]
657
+
658
+ const fleetList = new FleetList({
659
+ list: () => entries,
660
+ onOpen: () => {},
661
+ })
662
+
663
+ const fakeContext = createFakeContext()
664
+ fleetList.setContext(fakeContext as unknown as ExtensionContext)
665
+ fleetList['activeSelect'] = false
666
+
667
+ const width = 200
668
+ const render = (
669
+ fleetList as unknown as { renderBar: (w: number) => string[] }
670
+ ).renderBar(width)
671
+
672
+ expect(render).toContainEqual(expect.stringContaining('↳'))
673
+ expect(render).toContainEqual(expect.stringContaining('previous 1'))
674
+ expect(render).toContainEqual(expect.stringContaining('previous 2'))
675
+ expect(render).not.toContainEqual(
676
+ expect.stringContaining('[FG:syntaxVariable]previous 1'),
677
+ )
678
+ expect(render).not.toContainEqual(
679
+ expect.stringContaining('[FG:syntaxVariable]previous 2'),
680
+ )
681
+ expect(render).toContainEqual(expect.stringContaining('⟳ 1'))
682
+ expect(render).toContainEqual(expect.stringContaining('⟳ 0'))
683
+ expect(render).toContainEqual(expect.stringContaining('done'))
684
+ expect(render).toContainEqual(expect.stringContaining('current task'))
685
+ expect(render).not.toContainEqual(
686
+ expect.stringContaining('[FG:syntaxVariable]current task'),
687
+ )
688
+ expect(render).toContainEqual(
689
+ expect.stringContaining('[FG:muted] 0s[/-FG]'),
690
+ )
691
+ expect(render).not.toContainEqual(
692
+ expect.stringContaining('[FG:dim] 0s[/-FG]'),
693
+ )
694
+ expect(render).not.toContainEqual(
695
+ expect.stringContaining('[BG:selectedBg]'),
696
+ )
697
+ })
698
+ })
699
+
700
+ describe('FleetList renderBar selection highlight', () => {
701
+ function createFakeContext(): Record<string, unknown> {
702
+ return {
703
+ ui: {
704
+ getEditorText: () => '',
705
+ setWidget: () => {},
706
+ theme: createFakeTheme(),
707
+ onTerminalInput: () => () => {},
708
+ notify: () => {},
709
+ },
710
+ }
711
+ }
712
+
713
+ function createFleetList(entries: FleetEntry[]): FleetList {
714
+ const fleetList = new FleetList({
715
+ list: () => entries,
716
+ onOpen: () => {},
717
+ })
718
+ fleetList.setContext(createFakeContext() as unknown as ExtensionContext)
719
+ return fleetList
720
+ }
721
+
722
+ function render(fleetList: FleetList, width = 200): string[] {
723
+ return (
724
+ fleetList as unknown as { renderBar: (w: number) => string[] }
725
+ ).renderBar(width)
726
+ }
727
+
728
+ it('does not highlight the main row even when selectedIndex is 0', () => {
729
+ const now = Date.now()
730
+ const entries: FleetEntry[] = [
731
+ {
732
+ id: 1,
733
+ title: 'task 1',
734
+ previousEntries: [],
735
+ status: 'running',
736
+ startedAt: now,
737
+ followUpCount: 0,
738
+ role: 'worker',
739
+ },
740
+ ]
741
+ const fleetList = createFleetList(entries)
742
+ fleetList['activeSelect'] = true
743
+ fleetList['selectedIndex'] = 0
744
+
745
+ const lines = render(fleetList)
746
+
747
+ for (const line of lines) {
748
+ expect(line).not.toContain('[BG:selectedBg]')
749
+ }
750
+ })
751
+
752
+ it('highlights only the first item row when selectedIndex is 1', () => {
753
+ const now = Date.now()
754
+ const entries: FleetEntry[] = [
755
+ {
756
+ id: 1,
757
+ title: 'current task',
758
+ previousEntries: [
759
+ {
760
+ title: 'previous 1',
761
+ status: 'done',
762
+ followUpCount: 0,
763
+ startedAt: now - 8000,
764
+ completedAt: now - 5000,
765
+ },
766
+ {
767
+ title: 'previous 2',
768
+ status: 'done',
769
+ followUpCount: 0,
770
+ startedAt: now - 10000,
771
+ completedAt: now - 5000,
772
+ },
773
+ ],
774
+ status: 'done',
775
+ startedAt: now - 5000,
776
+ completedAt: now - 5000,
777
+ followUpCount: 0,
778
+ role: 'worker',
779
+ },
780
+ ]
781
+ const fleetList = createFleetList(entries)
782
+ fleetList['activeSelect'] = true
783
+ fleetList['selectedIndex'] = 1
784
+
785
+ const lines = render(fleetList)
786
+
787
+ const mainLine = lines.find((line) => line.includes('subagents'))
788
+ expect(mainLine).toBeDefined()
789
+ expect(mainLine).not.toContain('[BG:selectedBg]')
790
+
791
+ const itemLine = lines.find((line) => line.includes('current task'))
792
+ expect(itemLine).toBeDefined()
793
+ expect(itemLine).toContain('[BG:selectedBg]')
794
+
795
+ const prev1Line = lines.find((line) => line.includes('previous 1'))
796
+ expect(prev1Line).toBeDefined()
797
+ expect(prev1Line).not.toContain('[BG:selectedBg]')
798
+
799
+ const prev2Line = lines.find((line) => line.includes('previous 2'))
800
+ expect(prev2Line).toBeDefined()
801
+ expect(prev2Line).not.toContain('[BG:selectedBg]')
802
+ })
803
+
804
+ it('highlights only the middle item row when selectedIndex is 2', () => {
805
+ const now = Date.now()
806
+ const entries: FleetEntry[] = [
807
+ {
808
+ id: 3,
809
+ title: 'task 3',
810
+ previousEntries: [],
811
+ status: 'running',
812
+ startedAt: now,
813
+ followUpCount: 0,
814
+ role: 'worker',
815
+ },
816
+ {
817
+ id: 2,
818
+ title: 'task 2',
819
+ previousEntries: [],
820
+ status: 'running',
821
+ startedAt: now,
822
+ followUpCount: 0,
823
+ role: 'worker',
824
+ },
825
+ {
826
+ id: 1,
827
+ title: 'task 1',
828
+ previousEntries: [],
829
+ status: 'running',
830
+ startedAt: now,
831
+ followUpCount: 0,
832
+ role: 'worker',
833
+ },
834
+ ]
835
+ const fleetList = createFleetList(entries)
836
+ fleetList['activeSelect'] = true
837
+ fleetList['selectedIndex'] = 2
838
+
839
+ const lines = render(fleetList)
840
+
841
+ const mainLine = lines.find((line) => line.includes('subagents'))
842
+ expect(mainLine).toBeDefined()
843
+ expect(mainLine).not.toContain('[BG:selectedBg]')
844
+
845
+ const task2Line = lines.find((line) => line.includes('task 2'))
846
+ expect(task2Line).toBeDefined()
847
+ expect(task2Line).toContain('[BG:selectedBg]')
848
+
849
+ const task1Line = lines.find((line) => line.includes('task 1'))
850
+ expect(task1Line).toBeDefined()
851
+ expect(task1Line).not.toContain('[BG:selectedBg]')
852
+
853
+ const task3Line = lines.find((line) => line.includes('task 3'))
854
+ expect(task3Line).toBeDefined()
855
+ expect(task3Line).not.toContain('[BG:selectedBg]')
856
+ })
857
+
858
+ it('highlights no row when selection is inactive', () => {
859
+ const now = Date.now()
860
+ const entries: FleetEntry[] = [
861
+ {
862
+ id: 1,
863
+ title: 'task 1',
864
+ previousEntries: [
865
+ {
866
+ title: 'previous 1',
867
+ status: 'done',
868
+ followUpCount: 0,
869
+ startedAt: now - 8000,
870
+ completedAt: now - 5000,
871
+ },
872
+ ],
873
+ status: 'done',
874
+ startedAt: now - 5000,
875
+ completedAt: now - 5000,
876
+ followUpCount: 0,
877
+ role: 'worker',
878
+ },
879
+ ]
880
+ const fleetList = createFleetList(entries)
881
+ fleetList['activeSelect'] = false
882
+
883
+ const lines = render(fleetList)
884
+
885
+ for (const line of lines) {
886
+ expect(line).not.toContain('[BG:selectedBg]')
887
+ }
888
+ })
889
+
890
+ it('keeps title styling on the selected row', () => {
891
+ const now = Date.now()
892
+ const entries: FleetEntry[] = [
893
+ {
894
+ id: 2,
895
+ title: 'task two',
896
+ previousEntries: [],
897
+ status: 'done',
898
+ startedAt: now - 10000,
899
+ completedAt: now - 5000,
900
+ followUpCount: 0,
901
+ role: 'worker',
902
+ },
903
+ {
904
+ id: 1,
905
+ title: 'task one',
906
+ previousEntries: [],
907
+ status: 'done',
908
+ startedAt: now - 10000,
909
+ completedAt: now - 5000,
910
+ followUpCount: 0,
911
+ role: 'worker',
912
+ },
913
+ ]
914
+ const fleetList = createFleetList(entries)
915
+ fleetList['activeSelect'] = true
916
+ fleetList['selectedIndex'] = 1
917
+
918
+ const lines = render(fleetList)
919
+
920
+ const selectedLine = lines.find((line) => line.includes('task two'))
921
+ expect(selectedLine).toBeDefined()
922
+ expect(selectedLine).toContain('[BG:selectedBg]')
923
+ expect(selectedLine).toContain('task two')
924
+ expect(selectedLine).not.toContain('[FG:syntaxVariable]task two')
925
+ expect(selectedLine).not.toContain('[FG:dim]task two')
926
+
927
+ const unselectedLine = lines.find((line) => line.includes('task one'))
928
+ expect(unselectedLine).toBeDefined()
929
+ expect(unselectedLine).not.toContain('[BG:selectedBg]')
930
+ expect(unselectedLine).toContain('task one')
931
+ expect(unselectedLine).not.toContain('[FG:syntaxVariable]task one')
932
+ })
933
+
934
+ it('renders failed and killed titles with error', () => {
935
+ const now = Date.now()
936
+ const entries: FleetEntry[] = [
937
+ {
938
+ id: 1,
939
+ title: 'failed task',
940
+ previousEntries: [],
941
+ status: 'failed',
942
+ startedAt: now - 10000,
943
+ completedAt: now - 5000,
944
+ followUpCount: 0,
945
+ role: 'worker',
946
+ },
947
+ {
948
+ id: 2,
949
+ title: 'killed task',
950
+ previousEntries: [],
951
+ status: 'killed',
952
+ startedAt: now - 10000,
953
+ completedAt: now - 5000,
954
+ followUpCount: 0,
955
+ role: 'worker',
956
+ },
957
+ ]
958
+ const fleetList = createFleetList(entries)
959
+ fleetList['activeSelect'] = false
960
+
961
+ const lines = (
962
+ fleetList as unknown as { renderBar: (w: number) => string[] }
963
+ ).renderBar(200)
964
+
965
+ expect(lines).toContainEqual(
966
+ expect.stringContaining('[FG:error]failed task[/-FG]'),
967
+ )
968
+ expect(lines).toContainEqual(
969
+ expect.stringContaining('[FG:error]killed task[/-FG]'),
970
+ )
971
+ })
972
+
973
+ it('uses muted (not dim) for elapsedCol on the selected row', () => {
974
+ const now = Date.now()
975
+ const entries: FleetEntry[] = [
976
+ {
977
+ id: 2,
978
+ title: 'task two',
979
+ previousEntries: [],
980
+ status: 'running',
981
+ startedAt: now - 5000,
982
+ followUpCount: 0,
983
+ role: 'worker',
984
+ },
985
+ {
986
+ id: 1,
987
+ title: 'task one',
988
+ previousEntries: [],
989
+ status: 'running',
990
+ startedAt: now - 3000,
991
+ followUpCount: 0,
992
+ role: 'worker',
993
+ },
994
+ ]
995
+ const fleetList = createFleetList(entries)
996
+ fleetList['activeSelect'] = true
997
+ fleetList['selectedIndex'] = 1
998
+
999
+ const lines = render(fleetList)
1000
+
1001
+ const selectedLine = lines.find((line) => line.includes('task two'))
1002
+ expect(selectedLine).toBeDefined()
1003
+ expect(selectedLine).toContain('[BG:selectedBg]')
1004
+ expect(selectedLine).toContain('[BOLD]task two[/BOLD]')
1005
+ expect(selectedLine).toContain('[FG:muted]')
1006
+ expect(selectedLine).not.toContain('[FG:dim] 5s[/-FG]')
1007
+
1008
+ const unselectedLine = lines.find((line) => line.includes('task one'))
1009
+ expect(unselectedLine).toBeDefined()
1010
+ expect(unselectedLine).not.toContain('[BG:selectedBg]')
1011
+ expect(unselectedLine).toContain('[BOLD]task one[/BOLD]')
1012
+ expect(unselectedLine).toContain('[FG:muted] 3s[/-FG]')
1013
+ })
1014
+ })
1015
+
1016
+ describe('FleetList row-based selection navigation', () => {
1017
+ function createFakeContext(): Record<string, unknown> {
1018
+ return {
1019
+ ui: {
1020
+ getEditorText: () => '',
1021
+ setWidget: () => {},
1022
+ theme: createFakeTheme(),
1023
+ onTerminalInput: () => () => {},
1024
+ notify: () => {},
1025
+ },
1026
+ }
1027
+ }
1028
+
1029
+ function createFleetList(entries: FleetEntry[]): FleetList {
1030
+ const fleetList = new FleetList({
1031
+ list: () => entries,
1032
+ onOpen: () => {},
1033
+ })
1034
+ fleetList.setContext(createFakeContext() as unknown as ExtensionContext)
1035
+ return fleetList
1036
+ }
1037
+
1038
+ it('can navigate into previous rows with down arrow', () => {
1039
+ const now = Date.now()
1040
+ const entries: FleetEntry[] = [
1041
+ {
1042
+ id: 1,
1043
+ title: 'current task',
1044
+ previousEntries: [
1045
+ {
1046
+ title: 'previous 1',
1047
+ status: 'done',
1048
+ followUpCount: 0,
1049
+ startedAt: now - 8000,
1050
+ completedAt: now - 5000,
1051
+ },
1052
+ {
1053
+ title: 'previous 2',
1054
+ status: 'done',
1055
+ followUpCount: 0,
1056
+ startedAt: now - 10000,
1057
+ completedAt: now - 5000,
1058
+ },
1059
+ ],
1060
+ status: 'done',
1061
+ startedAt: now - 5000,
1062
+ completedAt: now - 5000,
1063
+ followUpCount: 0,
1064
+ role: 'worker',
1065
+ },
1066
+ ]
1067
+ const fleetList = createFleetList(entries)
1068
+ fleetList['activeSelect'] = true
1069
+ fleetList['selectedIndex'] = 1
1070
+
1071
+ const lines1 = (
1072
+ fleetList as unknown as { renderBar: (w: number) => string[] }
1073
+ ).renderBar(200)
1074
+ const itemLine = lines1.find((line) => line.includes('current task'))
1075
+ expect(itemLine).toBeDefined()
1076
+ expect(itemLine).toContain('[BG:selectedBg]')
1077
+
1078
+ fleetList['selectedIndex'] = 2
1079
+ const lines2 = (
1080
+ fleetList as unknown as { renderBar: (w: number) => string[] }
1081
+ ).renderBar(200)
1082
+ const prev1Line = lines2.find((line) => line.includes('previous 1'))
1083
+ expect(prev1Line).toBeDefined()
1084
+ expect(prev1Line).toContain('[BG:selectedBg]')
1085
+
1086
+ fleetList['selectedIndex'] = 3
1087
+ const lines3 = (
1088
+ fleetList as unknown as { renderBar: (w: number) => string[] }
1089
+ ).renderBar(200)
1090
+ const prev2Line = lines3.find((line) => line.includes('previous 2'))
1091
+ expect(prev2Line).toBeDefined()
1092
+ expect(prev2Line).toContain('[BG:selectedBg]')
1093
+ })
1094
+
1095
+ it('deactivates when up from first item row', () => {
1096
+ const now = Date.now()
1097
+ const entries: FleetEntry[] = [
1098
+ {
1099
+ id: 1,
1100
+ title: 'test task',
1101
+ previousEntries: [],
1102
+ status: 'running',
1103
+ startedAt: now,
1104
+ followUpCount: 0,
1105
+ role: 'worker',
1106
+ },
1107
+ ]
1108
+ const fleetList = createFleetList(entries)
1109
+ fleetList['activeSelect'] = true
1110
+ fleetList['selectedIndex'] = 1
1111
+ expect(fleetList['activeSelect']).toBe(true)
1112
+ expect(fleetList['selectedIndex']).toBe(1)
1113
+
1114
+ fleetList['selectedIndex'] = 0
1115
+ fleetList['activeSelect'] = false
1116
+
1117
+ expect(fleetList['activeSelect']).toBe(false)
1118
+ expect(fleetList['selectedIndex']).toBe(0)
1119
+ })
1120
+
1121
+ it('opens parent item id when ENTER pressed on previous row', async () => {
1122
+ const now = Date.now()
1123
+ const entries: FleetEntry[] = [
1124
+ {
1125
+ id: 1,
1126
+ title: 'current task',
1127
+ previousEntries: [
1128
+ {
1129
+ title: 'previous 1',
1130
+ status: 'done',
1131
+ followUpCount: 0,
1132
+ startedAt: now - 8000,
1133
+ completedAt: now - 5000,
1134
+ },
1135
+ ],
1136
+ status: 'done',
1137
+ startedAt: now - 5000,
1138
+ completedAt: now - 5000,
1139
+ followUpCount: 0,
1140
+ role: 'worker',
1141
+ },
1142
+ ]
1143
+
1144
+ let openedId: number | undefined
1145
+ const fleetList = createFleetList(entries)
1146
+ ;(fleetList as unknown as { options: FleetListOptions }).options.onOpen = (
1147
+ _ctx: unknown,
1148
+ id: number,
1149
+ ) => {
1150
+ openedId = id
1151
+ }
1152
+ fleetList['activeSelect'] = true
1153
+ fleetList['selectedIndex'] = 2
1154
+
1155
+ await (
1156
+ fleetList as unknown as {
1157
+ openSelected: () => Promise<void>
1158
+ }
1159
+ ).openSelected()
1160
+
1161
+ expect(openedId).toBe(1)
1162
+ })
1163
+
1164
+ it('highlights only one row at a time', () => {
1165
+ const now = Date.now()
1166
+ const entries: FleetEntry[] = [
1167
+ {
1168
+ id: 1,
1169
+ title: 'task 1',
1170
+ previousEntries: [
1171
+ {
1172
+ title: 'previous 1',
1173
+ status: 'done',
1174
+ followUpCount: 0,
1175
+ startedAt: now - 8000,
1176
+ completedAt: now - 5000,
1177
+ },
1178
+ {
1179
+ title: 'previous 2',
1180
+ status: 'done',
1181
+ followUpCount: 0,
1182
+ startedAt: now - 10000,
1183
+ completedAt: now - 5000,
1184
+ },
1185
+ ],
1186
+ status: 'done',
1187
+ startedAt: now - 5000,
1188
+ completedAt: now - 5000,
1189
+ followUpCount: 0,
1190
+ role: 'worker',
1191
+ },
1192
+ {
1193
+ id: 2,
1194
+ title: 'task 2',
1195
+ previousEntries: [],
1196
+ status: 'running',
1197
+ startedAt: now,
1198
+ followUpCount: 0,
1199
+ role: 'worker',
1200
+ },
1201
+ ]
1202
+ const fleetList = createFleetList(entries)
1203
+ fleetList['activeSelect'] = true
1204
+
1205
+ const indices = [1, 2, 3, 4]
1206
+ for (const idx of indices) {
1207
+ fleetList['selectedIndex'] = idx
1208
+ const lines = (
1209
+ fleetList as unknown as { renderBar: (w: number) => string[] }
1210
+ ).renderBar(200)
1211
+
1212
+ const highlightedLines = lines.filter((line) =>
1213
+ line.includes('[BG:selectedBg]'),
1214
+ )
1215
+ expect(highlightedLines).toHaveLength(1)
1216
+ }
1217
+ })
1218
+ })
1219
+
1220
+ describe('FleetList renderBar uses sorted roster order', () => {
1221
+ function createFakeContext(): Record<string, unknown> {
1222
+ return {
1223
+ ui: {
1224
+ getEditorText: () => '',
1225
+ setWidget: () => {},
1226
+ theme: createFakeTheme(),
1227
+ onTerminalInput: () => () => {},
1228
+ notify: () => {},
1229
+ },
1230
+ }
1231
+ }
1232
+
1233
+ function createFleetList(entries: FleetEntry[]): FleetList {
1234
+ const fleetList = new FleetList({
1235
+ list: () => entries,
1236
+ onOpen: () => {},
1237
+ })
1238
+ fleetList.setContext(createFakeContext() as unknown as ExtensionContext)
1239
+ return fleetList
1240
+ }
1241
+
1242
+ function render(fleetList: FleetList, width = 200): string[] {
1243
+ return (
1244
+ fleetList as unknown as { renderBar: (w: number) => string[] }
1245
+ ).renderBar(width)
1246
+ }
1247
+
1248
+ function done(
1249
+ id: number,
1250
+ previousEntries: FleetEntry['previousEntries'] = [],
1251
+ ): FleetEntry {
1252
+ const now = Date.now()
1253
+ return {
1254
+ id,
1255
+ title: `task ${id}`,
1256
+ previousEntries,
1257
+ status: 'done',
1258
+ startedAt: now - id * 10000,
1259
+ completedAt: now - id * 10000 + 5000,
1260
+ followUpCount: 0,
1261
+ role: 'worker',
1262
+ }
1263
+ }
1264
+
1265
+ it('display order matches sorted roster order for done items', () => {
1266
+ const now = Date.now()
1267
+ const entries: FleetEntry[] = [
1268
+ {
1269
+ id: 1,
1270
+ title: 'task 1',
1271
+ previousEntries: [],
1272
+ status: 'done',
1273
+ startedAt: now - 10000,
1274
+ completedAt: now - 5000,
1275
+ followUpCount: 0,
1276
+ role: 'worker',
1277
+ },
1278
+ {
1279
+ id: 2,
1280
+ title: 'task 2',
1281
+ previousEntries: [],
1282
+ status: 'done',
1283
+ startedAt: now - 20000,
1284
+ completedAt: now - 15000,
1285
+ followUpCount: 0,
1286
+ role: 'worker',
1287
+ },
1288
+ {
1289
+ id: 3,
1290
+ title: 'task 3',
1291
+ previousEntries: [],
1292
+ status: 'done',
1293
+ startedAt: now - 30000,
1294
+ completedAt: now - 25000,
1295
+ followUpCount: 0,
1296
+ role: 'worker',
1297
+ },
1298
+ ]
1299
+ const fleetList = createFleetList(entries)
1300
+ fleetList['activeSelect'] = false
1301
+
1302
+ const lines = render(fleetList)
1303
+ const task1Index = lines.findIndex((line) => line.includes('task 1'))
1304
+ const task2Index = lines.findIndex((line) => line.includes('task 2'))
1305
+ const task3Index = lines.findIndex((line) => line.includes('task 3'))
1306
+
1307
+ expect(task3Index).toBeGreaterThan(-1)
1308
+ expect(task2Index).toBeGreaterThan(-1)
1309
+ expect(task1Index).toBeGreaterThan(-1)
1310
+
1311
+ expect(task3Index).toBeLessThan(task2Index)
1312
+ expect(task2Index).toBeLessThan(task1Index)
1313
+ })
1314
+
1315
+ it('cursor moves to adjacent lines when pressing down', () => {
1316
+ const now = Date.now()
1317
+ const entries: FleetEntry[] = [
1318
+ {
1319
+ id: 1,
1320
+ title: 'task 1',
1321
+ previousEntries: [],
1322
+ status: 'done',
1323
+ startedAt: now - 10000,
1324
+ completedAt: now - 5000,
1325
+ followUpCount: 0,
1326
+ role: 'worker',
1327
+ },
1328
+ {
1329
+ id: 2,
1330
+ title: 'task 2',
1331
+ previousEntries: [],
1332
+ status: 'done',
1333
+ startedAt: now - 20000,
1334
+ completedAt: now - 15000,
1335
+ followUpCount: 0,
1336
+ role: 'worker',
1337
+ },
1338
+ {
1339
+ id: 3,
1340
+ title: 'task 3',
1341
+ previousEntries: [],
1342
+ status: 'done',
1343
+ startedAt: now - 30000,
1344
+ completedAt: now - 25000,
1345
+ followUpCount: 0,
1346
+ role: 'worker',
1347
+ },
1348
+ ]
1349
+ const fleetList = createFleetList(entries)
1350
+ fleetList['activeSelect'] = true
1351
+ fleetList['selectedIndex'] = 1
1352
+
1353
+ const lines1 = render(fleetList)
1354
+ const highlightedLines1 = lines1.filter((line) =>
1355
+ line.includes('[BG:selectedBg]'),
1356
+ )
1357
+ expect(highlightedLines1).toHaveLength(1)
1358
+ expect(highlightedLines1[0]).toContain('task 3')
1359
+
1360
+ fleetList['selectedIndex'] = 2
1361
+ const lines2 = render(fleetList)
1362
+ const highlightedLines2 = lines2.filter((line) =>
1363
+ line.includes('[BG:selectedBg]'),
1364
+ )
1365
+ expect(highlightedLines2).toHaveLength(1)
1366
+ expect(highlightedLines2[0]).toContain('task 2')
1367
+
1368
+ const task3Idx1 = lines1.findIndex((line) => line.includes('task 3'))
1369
+ const task2Idx1 = lines1.findIndex((line) => line.includes('task 2'))
1370
+ const task2Idx2 = lines2.findIndex((line) => line.includes('task 2'))
1371
+
1372
+ expect(task2Idx1).toBe(task3Idx1 + 1)
1373
+ expect(task2Idx2).toBe(task2Idx1)
1374
+ })
1375
+
1376
+ it('cursor moves to adjacent lines including previous rows', () => {
1377
+ const now = Date.now()
1378
+ const entries: FleetEntry[] = [
1379
+ {
1380
+ id: 1,
1381
+ title: 'task 1',
1382
+ previousEntries: [
1383
+ {
1384
+ title: 'previous 1',
1385
+ status: 'done',
1386
+ followUpCount: 0,
1387
+ startedAt: now - 8000,
1388
+ completedAt: now - 5000,
1389
+ },
1390
+ {
1391
+ title: 'previous 2',
1392
+ status: 'done',
1393
+ followUpCount: 0,
1394
+ startedAt: now - 10000,
1395
+ completedAt: now - 5000,
1396
+ },
1397
+ ],
1398
+ status: 'done',
1399
+ startedAt: now - 5000,
1400
+ completedAt: now - 5000,
1401
+ followUpCount: 0,
1402
+ role: 'worker',
1403
+ },
1404
+ {
1405
+ id: 2,
1406
+ title: 'task 2',
1407
+ previousEntries: [],
1408
+ status: 'done',
1409
+ startedAt: now - 20000,
1410
+ completedAt: now - 15000,
1411
+ followUpCount: 0,
1412
+ role: 'worker',
1413
+ },
1414
+ ]
1415
+ const fleetList = createFleetList(entries)
1416
+ fleetList['activeSelect'] = true
1417
+ fleetList['selectedIndex'] = 1
1418
+
1419
+ const lines1 = render(fleetList)
1420
+ const highlightedLines1 = lines1.filter((line) =>
1421
+ line.includes('[BG:selectedBg]'),
1422
+ )
1423
+ expect(highlightedLines1).toHaveLength(1)
1424
+ expect(highlightedLines1[0]).toContain('task 2')
1425
+
1426
+ const task1Idx = lines1.findIndex((line) => line.includes('task 1'))
1427
+ const task2Idx = lines1.findIndex((line) => line.includes('task 2'))
1428
+ const prev1Idx = lines1.findIndex((line) => line.includes('previous 1'))
1429
+ const prev2Idx = lines1.findIndex((line) => line.includes('previous 2'))
1430
+
1431
+ expect(task1Idx).toBeGreaterThan(-1)
1432
+ expect(task2Idx).toBeGreaterThan(-1)
1433
+ expect(prev1Idx).toBeGreaterThan(-1)
1434
+ expect(prev2Idx).toBeGreaterThan(-1)
1435
+
1436
+ expect(task2Idx).toBeLessThan(task1Idx)
1437
+ expect(prev1Idx).toBe(task1Idx + 1)
1438
+ expect(prev2Idx).toBe(prev1Idx + 1)
1439
+
1440
+ fleetList['selectedIndex'] = 2
1441
+ const lines2 = render(fleetList)
1442
+ const highlightedLines2 = lines2.filter((line) =>
1443
+ line.includes('[BG:selectedBg]'),
1444
+ )
1445
+ expect(highlightedLines2).toHaveLength(1)
1446
+ expect(highlightedLines2[0]).toContain('task 1')
1447
+
1448
+ fleetList['selectedIndex'] = 3
1449
+ const lines3 = render(fleetList)
1450
+ const highlightedLines3 = lines3.filter((line) =>
1451
+ line.includes('[BG:selectedBg]'),
1452
+ )
1453
+ expect(highlightedLines3).toHaveLength(1)
1454
+ expect(highlightedLines3[0]).toContain('previous 1')
1455
+
1456
+ fleetList['selectedIndex'] = 4
1457
+ const lines4 = render(fleetList)
1458
+ const highlightedLines4 = lines4.filter((line) =>
1459
+ line.includes('[BG:selectedBg]'),
1460
+ )
1461
+ expect(highlightedLines4).toHaveLength(1)
1462
+ expect(highlightedLines4[0]).toContain('previous 2')
1463
+ })
1464
+
1465
+ it('scrolls window when selection moves past visible items', () => {
1466
+ const now = Date.now()
1467
+ const entries: FleetEntry[] = Array.from({ length: 10 }, (_, i) => ({
1468
+ id: i + 1,
1469
+ title: `task ${i + 1}`,
1470
+ previousEntries: [],
1471
+ status: 'done',
1472
+ startedAt: now - (i + 1) * 10000,
1473
+ completedAt: now - (i + 1) * 10000 + 5000,
1474
+ followUpCount: 0,
1475
+ role: 'worker',
1476
+ }))
1477
+ const fleetList = createFleetList(entries)
1478
+ fleetList['activeSelect'] = true
1479
+
1480
+ fleetList['selectedIndex'] = 1
1481
+ const lines1 = render(fleetList)
1482
+ expect(lines1.some((line) => line.match(/↑\s+\d+\s+more/))).toBe(false)
1483
+ expect(lines1.some((line) => line.match(/↓\s+2\s+more/))).toBe(true)
1484
+
1485
+ fleetList['selectedIndex'] = 10
1486
+ const lines2 = render(fleetList)
1487
+ expect(lines2.some((line) => line.match(/↑\s+2\s+more/))).toBe(true)
1488
+ expect(lines2.some((line) => line.match(/↓\s+\d+\s+more/))).toBe(false)
1489
+ })
1490
+
1491
+ it('scrolls window so the selected row is the last visible row', () => {
1492
+ const now = Date.now()
1493
+ const entries: FleetEntry[] = [
1494
+ done(1, [
1495
+ {
1496
+ title: 'previous 1',
1497
+ status: 'done',
1498
+ followUpCount: 0,
1499
+ startedAt: now - 8000,
1500
+ completedAt: now - 5000,
1501
+ },
1502
+ {
1503
+ title: 'previous 2',
1504
+ status: 'done',
1505
+ followUpCount: 0,
1506
+ startedAt: now - 10000,
1507
+ completedAt: now - 5000,
1508
+ },
1509
+ ]),
1510
+ done(2),
1511
+ done(3),
1512
+ done(4),
1513
+ done(5),
1514
+ done(6),
1515
+ done(7),
1516
+ done(8),
1517
+ ]
1518
+ const fleetList = createFleetList(entries)
1519
+ fleetList['activeSelect'] = true
1520
+ fleetList['selectedIndex'] = 10
1521
+
1522
+ const lines = render(fleetList)
1523
+
1524
+ const highlighted = lines.filter((line) => line.includes('[BG:selectedBg]'))
1525
+ expect(highlighted).toHaveLength(1)
1526
+ expect(highlighted[0]).toContain('previous 2')
1527
+ expect(lines.some((line) => line.match(/↑\s+2\s+more/))).toBe(true)
1528
+ expect(lines.some((line) => line.match(/↓\s+\d+\s+more/))).toBe(false)
1529
+ expect(lines.some((line) => line.includes('task 8'))).toBe(false)
1530
+ expect(lines.some((line) => line.includes('task 1'))).toBe(true)
1531
+ })
1532
+
1533
+ it('counts hidden rows including previous rows when scrolling a mid-list selection', () => {
1534
+ const now = Date.now()
1535
+ const entries: FleetEntry[] = [
1536
+ done(2, [
1537
+ {
1538
+ title: 'previous 1',
1539
+ status: 'done',
1540
+ followUpCount: 0,
1541
+ startedAt: now - 8000,
1542
+ completedAt: now - 5000,
1543
+ },
1544
+ {
1545
+ title: 'previous 2',
1546
+ status: 'done',
1547
+ followUpCount: 0,
1548
+ startedAt: now - 10000,
1549
+ completedAt: now - 5000,
1550
+ },
1551
+ ]),
1552
+ done(1),
1553
+ done(3),
1554
+ done(4),
1555
+ done(5),
1556
+ done(6),
1557
+ done(7),
1558
+ done(8),
1559
+ done(9),
1560
+ ]
1561
+ const fleetList = createFleetList(entries)
1562
+ fleetList['activeSelect'] = true
1563
+ fleetList['selectedIndex'] = 10
1564
+
1565
+ const lines = render(fleetList)
1566
+
1567
+ const highlighted = lines.filter((line) => line.includes('[BG:selectedBg]'))
1568
+ expect(highlighted).toHaveLength(1)
1569
+ expect(highlighted[0]).toContain('previous 2')
1570
+ expect(lines.some((line) => line.match(/↑\s+2\s+more/))).toBe(true)
1571
+ expect(lines.some((line) => line.match(/↓\s+1\s+more/))).toBe(true)
1572
+ expect(lines.some((line) => line.includes('task 9'))).toBe(false)
1573
+ expect(lines.some((line) => line.includes('task 1'))).toBe(false)
1574
+ expect(lines.some((line) => line.includes('task 2'))).toBe(true)
1575
+ expect(lines.some((line) => line.includes('previous 1'))).toBe(true)
1576
+ })
1577
+ })
1578
+
1579
+ describe('FleetList context usage rendering', () => {
1580
+ function createFakeContext(): Record<string, unknown> {
1581
+ return {
1582
+ ui: {
1583
+ getEditorText: () => '',
1584
+ setWidget: () => {},
1585
+ theme: createFakeTheme(),
1586
+ onTerminalInput: () => () => {},
1587
+ notify: () => {},
1588
+ },
1589
+ }
1590
+ }
1591
+
1592
+ function createFleetList(entries: FleetEntry[]): FleetList {
1593
+ const fleetList = new FleetList({
1594
+ list: () => entries,
1595
+ onOpen: () => {},
1596
+ })
1597
+ fleetList.setContext(createFakeContext() as unknown as ExtensionContext)
1598
+ return fleetList
1599
+ }
1600
+
1601
+ function render(fleetList: FleetList, width = 200): string[] {
1602
+ return (
1603
+ fleetList as unknown as { renderBar: (w: number) => string[] }
1604
+ ).renderBar(width)
1605
+ }
1606
+
1607
+ it('renders normal context usage with muted color', () => {
1608
+ const now = Date.now()
1609
+ const entries: FleetEntry[] = [
1610
+ {
1611
+ id: 1,
1612
+ title: 'test task',
1613
+ previousEntries: [],
1614
+ status: 'running',
1615
+ startedAt: now,
1616
+ followUpCount: 0,
1617
+ role: 'worker',
1618
+ contextUsage: { tokens: 60000, contextWindow: 200000, percent: 30.0 },
1619
+ },
1620
+ ]
1621
+ const fleetList = createFleetList(entries)
1622
+ fleetList['activeSelect'] = false
1623
+
1624
+ const lines = render(fleetList)
1625
+ const itemLine = lines.find((line) => line.includes('test task'))
1626
+ expect(itemLine).toBeDefined()
1627
+ expect(itemLine).toContain('[FG:muted]')
1628
+ expect(itemLine).toContain('60k')
1629
+ expect(itemLine).toContain('200k')
1630
+ })
1631
+
1632
+ it('renders unknown tokens as ?', () => {
1633
+ const now = Date.now()
1634
+ const entries: FleetEntry[] = [
1635
+ {
1636
+ id: 1,
1637
+ title: 'test task',
1638
+ previousEntries: [],
1639
+ status: 'running',
1640
+ startedAt: now,
1641
+ followUpCount: 0,
1642
+ role: 'worker',
1643
+ contextUsage: { tokens: null, contextWindow: 200000, percent: null },
1644
+ },
1645
+ ]
1646
+ const fleetList = createFleetList(entries)
1647
+ fleetList['activeSelect'] = false
1648
+
1649
+ const lines = render(fleetList)
1650
+ const itemLine = lines.find((line) => line.includes('test task'))
1651
+ expect(itemLine).toBeDefined()
1652
+ expect(itemLine).toContain('[FG:muted]')
1653
+ expect(itemLine).toContain('?')
1654
+ expect(itemLine).not.toContain('?/')
1655
+ })
1656
+
1657
+ it('shows placeholder when contextUsage is undefined', () => {
1658
+ const now = Date.now()
1659
+ const entries: FleetEntry[] = [
1660
+ {
1661
+ id: 1,
1662
+ title: 'test task',
1663
+ previousEntries: [],
1664
+ status: 'running',
1665
+ startedAt: now,
1666
+ followUpCount: 0,
1667
+ role: 'worker',
1668
+ },
1669
+ ]
1670
+ const fleetList = createFleetList(entries)
1671
+ fleetList['activeSelect'] = false
1672
+
1673
+ const lines = render(fleetList)
1674
+ const itemLine = lines.find((line) => line.includes('test task'))
1675
+ expect(itemLine).toBeDefined()
1676
+ expect(itemLine).not.toContain('%/')
1677
+ expect(itemLine).not.toContain('?/')
1678
+ expect(itemLine).toContain('[FG:muted]')
1679
+ expect(itemLine).toContain(' ')
1680
+ })
1681
+
1682
+ it('shows placeholder when contextWindow is 0', () => {
1683
+ const now = Date.now()
1684
+ const entries: FleetEntry[] = [
1685
+ {
1686
+ id: 1,
1687
+ title: 'test task',
1688
+ previousEntries: [],
1689
+ status: 'running',
1690
+ startedAt: now,
1691
+ followUpCount: 0,
1692
+ role: 'worker',
1693
+ contextUsage: { tokens: 1000, contextWindow: 0, percent: 10.0 },
1694
+ },
1695
+ ]
1696
+ const fleetList = createFleetList(entries)
1697
+ fleetList['activeSelect'] = false
1698
+
1699
+ const lines = render(fleetList)
1700
+ const itemLine = lines.find((line) => line.includes('test task'))
1701
+ expect(itemLine).toBeDefined()
1702
+ expect(itemLine).not.toContain('%/')
1703
+ expect(itemLine).toContain('[FG:muted]')
1704
+ expect(itemLine).toContain(' ')
1705
+ })
1706
+
1707
+ it('uses error color when context usage > 90%', () => {
1708
+ const now = Date.now()
1709
+ const entries: FleetEntry[] = [
1710
+ {
1711
+ id: 1,
1712
+ title: 'test task',
1713
+ previousEntries: [],
1714
+ status: 'running',
1715
+ startedAt: now,
1716
+ followUpCount: 0,
1717
+ role: 'worker',
1718
+ contextUsage: { tokens: 190000, contextWindow: 200000, percent: 95.0 },
1719
+ },
1720
+ ]
1721
+ const fleetList = createFleetList(entries)
1722
+ fleetList['activeSelect'] = false
1723
+
1724
+ const lines = render(fleetList)
1725
+ const itemLine = lines.find((line) => line.includes('test task'))
1726
+ expect(itemLine).toBeDefined()
1727
+ expect(itemLine).toContain('[FG:error]')
1728
+ expect(itemLine).not.toContain('[FG:warning]')
1729
+ expect(itemLine).toContain('190k')
1730
+ expect(itemLine).toContain('200k')
1731
+ })
1732
+
1733
+ it('uses warning color when context usage > 70% and <= 90%', () => {
1734
+ const now = Date.now()
1735
+ const entries: FleetEntry[] = [
1736
+ {
1737
+ id: 1,
1738
+ title: 'test task',
1739
+ previousEntries: [],
1740
+ status: 'running',
1741
+ startedAt: now,
1742
+ followUpCount: 0,
1743
+ role: 'worker',
1744
+ contextUsage: { tokens: 160000, contextWindow: 200000, percent: 80.0 },
1745
+ },
1746
+ ]
1747
+ const fleetList = createFleetList(entries)
1748
+ fleetList['activeSelect'] = false
1749
+
1750
+ const lines = render(fleetList)
1751
+ const itemLine = lines.find((line) => line.includes('test task'))
1752
+ expect(itemLine).toBeDefined()
1753
+ expect(itemLine).toContain('[FG:warning]')
1754
+ expect(itemLine).not.toContain('[FG:error]')
1755
+ expect(itemLine).toContain('160k')
1756
+ expect(itemLine).toContain('200k')
1757
+ })
1758
+ })