@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.
- package/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- 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
|
+
})
|