@raidou/pi-notify 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/dashboard/session-store.test.ts +2 -0
- package/src/dashboard/ui/columns.ts +8 -2
- package/src/dashboard/ui/dashboard.test.ts +283 -3
- package/src/dashboard/ui/dashboard.ts +56 -7
- package/src/jobs.test.ts +106 -0
- package/src/jobs.ts +30 -14
- package/src/state-tracker.test.ts +53 -8
- package/src/state-tracker.ts +11 -4
package/README.md
CHANGED
|
@@ -76,6 +76,8 @@ Columns: `SESSION_ID` (last 6 chars), `PID`, `STATE` (running/idle, color-coded)
|
|
|
76
76
|
|
|
77
77
|
Keybindings:
|
|
78
78
|
|
|
79
|
+
- `j`/`k` or `↑`/`↓` — move selection
|
|
80
|
+
- `x` — kill (SIGTERM) the selected session
|
|
79
81
|
- `o` — show/hide the SESSION_ID and PID columns
|
|
80
82
|
- `r` — refresh
|
|
81
83
|
- `q` or `esc` — close
|
package/package.json
CHANGED
|
@@ -352,6 +352,7 @@ describe('SessionStore', () => {
|
|
|
352
352
|
const pi = makeFakePi()
|
|
353
353
|
const jobTracker = {
|
|
354
354
|
hasActiveJobs: false,
|
|
355
|
+
onStart: () => () => {},
|
|
355
356
|
onEnd: () => () => {},
|
|
356
357
|
} as unknown as JobTracker
|
|
357
358
|
const tracker = new StateTracker(
|
|
@@ -391,6 +392,7 @@ describe('SessionStore', () => {
|
|
|
391
392
|
const pi = makeFakePi()
|
|
392
393
|
const jobTracker = {
|
|
393
394
|
hasActiveJobs: false,
|
|
395
|
+
onStart: () => () => {},
|
|
394
396
|
onEnd: () => () => {},
|
|
395
397
|
} as unknown as JobTracker
|
|
396
398
|
const tracker = new StateTracker(
|
|
@@ -30,10 +30,16 @@ const COLUMNS: Column[] = [
|
|
|
30
30
|
name: 'STATE',
|
|
31
31
|
width: 20,
|
|
32
32
|
render: (session, theme, width) => {
|
|
33
|
-
const
|
|
33
|
+
const isDashboard = session.pid === process.pid
|
|
34
|
+
const label = isDashboard ? 'dashboard' : session.state
|
|
35
|
+
const color = isDashboard
|
|
36
|
+
? 'syntaxKeyword'
|
|
37
|
+
: session.state === 'running'
|
|
38
|
+
? 'success'
|
|
39
|
+
: 'muted'
|
|
34
40
|
return theme.fg(
|
|
35
41
|
color,
|
|
36
|
-
truncateToWidth(
|
|
42
|
+
truncateToWidth(label, width, '…', true).padEnd(width),
|
|
37
43
|
)
|
|
38
44
|
},
|
|
39
45
|
},
|
|
@@ -70,18 +70,31 @@ describe('Dashboard render clipping', () => {
|
|
|
70
70
|
const visible = lines.map(stripAnsi)
|
|
71
71
|
const header = visible.find((l) => l.includes('STATE'))
|
|
72
72
|
expect(header).toBeDefined()
|
|
73
|
-
const footer = visible.find((l) => l.startsWith('[
|
|
73
|
+
const footer = visible.find((l) => l.startsWith('[j/k/'))
|
|
74
74
|
expect(footer).toBeDefined()
|
|
75
75
|
if (!header || !footer) throw new Error('unreachable')
|
|
76
|
-
expect(header.startsWith('STATE')).toBe(true)
|
|
76
|
+
expect(header.startsWith(' STATE')).toBe(true)
|
|
77
77
|
expect(header.endsWith('…')).toBe(true)
|
|
78
|
-
expect(footer.startsWith('[
|
|
78
|
+
expect(footer.startsWith('[j/k/↑↓] move · [x]…')).toBe(true)
|
|
79
79
|
for (const line of visible) {
|
|
80
80
|
expect(visibleWidth(line)).toBeLessThanOrEqual(20)
|
|
81
81
|
}
|
|
82
82
|
dashboard.dispose()
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it('aligns header columns with session rows via the 2-char gutter', () => {
|
|
86
|
+
const dashboard = makeDashboard([makeSession({})])
|
|
87
|
+
const lines = dashboard.render(200).map(stripAnsi)
|
|
88
|
+
const header = lines.find((l) => l.includes('STATE'))
|
|
89
|
+
const row = lines.find((l) => l.includes('proj'))
|
|
90
|
+
expect(header).toBeDefined()
|
|
91
|
+
expect(row).toBeDefined()
|
|
92
|
+
if (!header || !row) throw new Error('unreachable')
|
|
93
|
+
expect(header.indexOf('PROJECT')).toBe(row.indexOf('proj'))
|
|
94
|
+
expect(header.indexOf('STATE')).toBe(2)
|
|
95
|
+
dashboard.dispose()
|
|
96
|
+
})
|
|
97
|
+
|
|
85
98
|
it('counts CJK project names by display width', () => {
|
|
86
99
|
const dashboard = makeDashboard([
|
|
87
100
|
makeSession({ projectName: '中文项目名' }),
|
|
@@ -122,6 +135,44 @@ describe('hidden column toggle', () => {
|
|
|
122
135
|
})
|
|
123
136
|
})
|
|
124
137
|
|
|
138
|
+
describe('dashboard state display', () => {
|
|
139
|
+
it('renders dashboard as state for the current process pid', () => {
|
|
140
|
+
const dashboard = makeDashboard([
|
|
141
|
+
makeSession({ pid: process.pid, state: 'idle' }),
|
|
142
|
+
])
|
|
143
|
+
dashboard.handleInput('o')
|
|
144
|
+
try {
|
|
145
|
+
const row = dashboard
|
|
146
|
+
.render(200)
|
|
147
|
+
.map(stripAnsi)
|
|
148
|
+
.find((l) => l.includes('abc123'))
|
|
149
|
+
expect(row).toBeDefined()
|
|
150
|
+
expect(row).toContain('dashboard')
|
|
151
|
+
expect(row).not.toContain('idle')
|
|
152
|
+
} finally {
|
|
153
|
+
dashboard.dispose()
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('keeps real state for other pids', () => {
|
|
158
|
+
const dashboard = makeDashboard([
|
|
159
|
+
makeSession({ pid: 999, state: 'running' }),
|
|
160
|
+
])
|
|
161
|
+
dashboard.handleInput('o')
|
|
162
|
+
try {
|
|
163
|
+
const row = dashboard
|
|
164
|
+
.render(200)
|
|
165
|
+
.map(stripAnsi)
|
|
166
|
+
.find((l) => l.includes('abc123'))
|
|
167
|
+
expect(row).toBeDefined()
|
|
168
|
+
expect(row).toContain('running')
|
|
169
|
+
expect(row).not.toContain('dashboard')
|
|
170
|
+
} finally {
|
|
171
|
+
dashboard.dispose()
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
125
176
|
describe('auto-refresh', () => {
|
|
126
177
|
it('manual r triggers refresh', () => {
|
|
127
178
|
const onRefresh = vi.fn(async () => [makeSession({})])
|
|
@@ -139,3 +190,232 @@ describe('auto-refresh', () => {
|
|
|
139
190
|
expect(onRefresh).not.toHaveBeenCalled()
|
|
140
191
|
})
|
|
141
192
|
})
|
|
193
|
+
|
|
194
|
+
describe('selection navigation', () => {
|
|
195
|
+
const rows = (dashboard: Dashboard) =>
|
|
196
|
+
dashboard
|
|
197
|
+
.render(200)
|
|
198
|
+
.map(stripAnsi)
|
|
199
|
+
.filter((l) => l.includes('proj'))
|
|
200
|
+
|
|
201
|
+
it('starts with first row selected and moves with j/k and arrows', () => {
|
|
202
|
+
const dashboard = makeDashboard([
|
|
203
|
+
makeSession({ pid: 1, sessionId: 'abc123' }),
|
|
204
|
+
makeSession({ pid: 2, sessionId: 'def456' }),
|
|
205
|
+
makeSession({ pid: 3, sessionId: 'ghi789' }),
|
|
206
|
+
])
|
|
207
|
+
try {
|
|
208
|
+
dashboard.handleInput('o')
|
|
209
|
+
let [first, second, third] = rows(dashboard)
|
|
210
|
+
expect(first?.startsWith('> ')).toBe(true)
|
|
211
|
+
expect(second?.startsWith(' ')).toBe(true)
|
|
212
|
+
|
|
213
|
+
dashboard.handleInput('j')
|
|
214
|
+
;[first, second, third] = rows(dashboard)
|
|
215
|
+
expect(first?.startsWith(' ')).toBe(true)
|
|
216
|
+
expect(second?.startsWith('> ')).toBe(true)
|
|
217
|
+
|
|
218
|
+
dashboard.handleInput('\x1b[B')
|
|
219
|
+
;[first, second, third] = rows(dashboard)
|
|
220
|
+
expect(third?.startsWith('> ')).toBe(true)
|
|
221
|
+
|
|
222
|
+
dashboard.handleInput('k')
|
|
223
|
+
dashboard.handleInput('\x1b[A')
|
|
224
|
+
;[first, second, third] = rows(dashboard)
|
|
225
|
+
expect(first?.startsWith('> ')).toBe(true)
|
|
226
|
+
} finally {
|
|
227
|
+
dashboard.dispose()
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('clamps selection at first and last row', () => {
|
|
232
|
+
const dashboard = makeDashboard([
|
|
233
|
+
makeSession({ pid: 1, sessionId: 'abc123' }),
|
|
234
|
+
makeSession({ pid: 2, sessionId: 'def456' }),
|
|
235
|
+
])
|
|
236
|
+
try {
|
|
237
|
+
dashboard.handleInput('o')
|
|
238
|
+
dashboard.handleInput('k')
|
|
239
|
+
expect(rows(dashboard)[0]?.startsWith('> ')).toBe(true)
|
|
240
|
+
|
|
241
|
+
dashboard.handleInput('j')
|
|
242
|
+
dashboard.handleInput('j')
|
|
243
|
+
const rowsNow = rows(dashboard)
|
|
244
|
+
expect(rowsNow[0]?.startsWith(' ')).toBe(true)
|
|
245
|
+
expect(rowsNow[1]?.startsWith('> ')).toBe(true)
|
|
246
|
+
} finally {
|
|
247
|
+
dashboard.dispose()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('clamps selection to the last row after refresh shrinks the list', async () => {
|
|
252
|
+
const onRefresh = vi.fn(async () => [
|
|
253
|
+
makeSession({ pid: 3, sessionId: 'ghi789' }),
|
|
254
|
+
])
|
|
255
|
+
const dashboard = makeDashboard(
|
|
256
|
+
[
|
|
257
|
+
makeSession({ pid: 1, sessionId: 'abc123' }),
|
|
258
|
+
makeSession({ pid: 2, sessionId: 'def456' }),
|
|
259
|
+
makeSession({ pid: 4, sessionId: 'jkl012' }),
|
|
260
|
+
],
|
|
261
|
+
{ onRefresh },
|
|
262
|
+
)
|
|
263
|
+
try {
|
|
264
|
+
dashboard.handleInput('o')
|
|
265
|
+
dashboard.handleInput('j')
|
|
266
|
+
dashboard.handleInput('j')
|
|
267
|
+
expect(rows(dashboard)[2]?.startsWith('> ')).toBe(true)
|
|
268
|
+
|
|
269
|
+
dashboard.handleInput('r')
|
|
270
|
+
await vi.waitFor(() => {
|
|
271
|
+
expect(onRefresh).toHaveBeenCalled()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// selection stays on the last row (position-based), moving down must not go out of bounds
|
|
275
|
+
dashboard.handleInput('j')
|
|
276
|
+
const visible = rows(dashboard)
|
|
277
|
+
expect(visible).toHaveLength(1)
|
|
278
|
+
expect(visible[0]?.startsWith('> ')).toBe(true)
|
|
279
|
+
} finally {
|
|
280
|
+
dashboard.dispose()
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('no-ops on empty session list', () => {
|
|
285
|
+
const dashboard = makeDashboard([])
|
|
286
|
+
try {
|
|
287
|
+
dashboard.handleInput('j')
|
|
288
|
+
dashboard.handleInput('k')
|
|
289
|
+
dashboard.handleInput('x')
|
|
290
|
+
expect(
|
|
291
|
+
dashboard.render(200).some((l) => stripAnsi(l).startsWith('> ')),
|
|
292
|
+
).toBe(false)
|
|
293
|
+
} finally {
|
|
294
|
+
dashboard.dispose()
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('kill action', () => {
|
|
300
|
+
let killSpy: ReturnType<typeof vi.spyOn>
|
|
301
|
+
|
|
302
|
+
const rows = (dashboard: Dashboard) =>
|
|
303
|
+
dashboard
|
|
304
|
+
.render(200)
|
|
305
|
+
.map(stripAnsi)
|
|
306
|
+
.filter((l) => l.includes('proj'))
|
|
307
|
+
|
|
308
|
+
it('kills the selected session with SIGTERM', async () => {
|
|
309
|
+
killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
|
310
|
+
const onRefresh = vi.fn(async () => [
|
|
311
|
+
makeSession({ pid: process.pid, sessionId: 'abc123' }),
|
|
312
|
+
])
|
|
313
|
+
const dashboard = makeDashboard(
|
|
314
|
+
[
|
|
315
|
+
makeSession({ pid: process.pid, sessionId: 'self001' }),
|
|
316
|
+
makeSession({ pid: 999, sessionId: 'target1' }),
|
|
317
|
+
],
|
|
318
|
+
{ onRefresh },
|
|
319
|
+
)
|
|
320
|
+
try {
|
|
321
|
+
dashboard.handleInput('o')
|
|
322
|
+
dashboard.handleInput('j')
|
|
323
|
+
expect(rows(dashboard)[1]?.startsWith('> ')).toBe(true)
|
|
324
|
+
dashboard.handleInput('x')
|
|
325
|
+
expect(killSpy).toHaveBeenCalledWith(999, 'SIGTERM')
|
|
326
|
+
await vi.waitFor(() => {
|
|
327
|
+
expect(onRefresh).toHaveBeenCalled()
|
|
328
|
+
})
|
|
329
|
+
} finally {
|
|
330
|
+
dashboard.dispose()
|
|
331
|
+
killSpy.mockRestore()
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('does not kill the dashboard own row', () => {
|
|
336
|
+
killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
|
337
|
+
const dashboard = makeDashboard([
|
|
338
|
+
makeSession({ pid: process.pid, sessionId: 'abc123' }),
|
|
339
|
+
])
|
|
340
|
+
try {
|
|
341
|
+
dashboard.handleInput('x')
|
|
342
|
+
expect(killSpy).not.toHaveBeenCalled()
|
|
343
|
+
} finally {
|
|
344
|
+
dashboard.dispose()
|
|
345
|
+
killSpy.mockRestore()
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('ignores ESRCH errors from kill', () => {
|
|
350
|
+
killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
351
|
+
throw Object.assign(new Error('no such process'), { code: 'ESRCH' })
|
|
352
|
+
})
|
|
353
|
+
const dashboard = makeDashboard([
|
|
354
|
+
makeSession({ pid: 999, sessionId: 'abc123' }),
|
|
355
|
+
])
|
|
356
|
+
try {
|
|
357
|
+
expect(() => {
|
|
358
|
+
dashboard.handleInput('x')
|
|
359
|
+
}).not.toThrow()
|
|
360
|
+
} finally {
|
|
361
|
+
dashboard.dispose()
|
|
362
|
+
killSpy.mockRestore()
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
describe('selection degradation', () => {
|
|
368
|
+
it('follows the selected session as the list shrinks', async () => {
|
|
369
|
+
const onRefresh = vi.fn(async () => [
|
|
370
|
+
makeSession({ pid: 2, sessionId: 'bbb' }),
|
|
371
|
+
])
|
|
372
|
+
const dashboard = makeDashboard(
|
|
373
|
+
[
|
|
374
|
+
makeSession({ pid: 1, sessionId: 'aaa' }),
|
|
375
|
+
makeSession({ pid: 2, sessionId: 'bbb' }),
|
|
376
|
+
],
|
|
377
|
+
{ onRefresh },
|
|
378
|
+
)
|
|
379
|
+
try {
|
|
380
|
+
// select row 0 (session 'aaa'), refresh removes it -> marker lands on 'bbb'
|
|
381
|
+
dashboard.handleInput('r')
|
|
382
|
+
await vi.waitFor(() => {
|
|
383
|
+
expect(onRefresh).toHaveBeenCalled()
|
|
384
|
+
})
|
|
385
|
+
const visible = dashboard
|
|
386
|
+
.render(200)
|
|
387
|
+
.map(stripAnsi)
|
|
388
|
+
.filter((l) => l.includes('proj'))
|
|
389
|
+
expect(visible).toHaveLength(1)
|
|
390
|
+
expect(visible[0]?.startsWith('> ')).toBe(true)
|
|
391
|
+
|
|
392
|
+
// refresh to empty -> no marker; navigation/kill no-op
|
|
393
|
+
const emptyRefresh = vi.fn(async () => [])
|
|
394
|
+
const dashboard2 = makeDashboard(
|
|
395
|
+
[makeSession({ pid: 2, sessionId: 'abc123' })],
|
|
396
|
+
{ onRefresh: emptyRefresh },
|
|
397
|
+
)
|
|
398
|
+
try {
|
|
399
|
+
dashboard2.handleInput('r')
|
|
400
|
+
await vi.waitFor(() => {
|
|
401
|
+
expect(emptyRefresh).toHaveBeenCalled()
|
|
402
|
+
})
|
|
403
|
+
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
|
404
|
+
try {
|
|
405
|
+
dashboard2.handleInput('j')
|
|
406
|
+
dashboard2.handleInput('x')
|
|
407
|
+
expect(
|
|
408
|
+
dashboard2.render(200).some((l) => stripAnsi(l).startsWith('> ')),
|
|
409
|
+
).toBe(false)
|
|
410
|
+
expect(killSpy).not.toHaveBeenCalled()
|
|
411
|
+
} finally {
|
|
412
|
+
killSpy.mockRestore()
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
dashboard2.dispose()
|
|
416
|
+
}
|
|
417
|
+
} finally {
|
|
418
|
+
dashboard.dispose()
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
import type { Component } from '@earendil-works/pi-tui'
|
|
3
3
|
import { Key, matchesKey, truncateToWidth } from '@earendil-works/pi-tui'
|
|
4
|
+
import { clamp } from 'lodash-es'
|
|
4
5
|
|
|
5
6
|
import type { SessionRecord } from '../state-store.js'
|
|
6
7
|
import { watchStore } from '../watch-store.js'
|
|
@@ -30,6 +31,7 @@ export class Dashboard implements Component {
|
|
|
30
31
|
private cachedLines: string[] = []
|
|
31
32
|
private disposed = false
|
|
32
33
|
private showHidden = false
|
|
34
|
+
private selectedIndex = 0
|
|
33
35
|
private readonly stopWatching: () => void
|
|
34
36
|
|
|
35
37
|
constructor({
|
|
@@ -57,11 +59,36 @@ export class Dashboard implements Component {
|
|
|
57
59
|
this.tui.requestRender()
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
private clampSelection(): void {
|
|
63
|
+
if (this.sessions.length === 0) return
|
|
64
|
+
this.selectedIndex = clamp(this.selectedIndex, 0, this.sessions.length - 1)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private moveSelection(delta: 1 | -1): void {
|
|
68
|
+
if (this.sessions.length === 0) return
|
|
69
|
+
this.selectedIndex += delta
|
|
70
|
+
this.clampSelection()
|
|
71
|
+
this.forceRender()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private killSelected(): void {
|
|
75
|
+
const session = this.sessions[this.selectedIndex]
|
|
76
|
+
if (!session || session.pid === process.pid) return
|
|
77
|
+
try {
|
|
78
|
+
process.kill(session.pid, 'SIGTERM')
|
|
79
|
+
} catch {
|
|
80
|
+
// ignore ESRCH etc.
|
|
81
|
+
}
|
|
82
|
+
this.refresh()
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
private refresh(): void {
|
|
61
86
|
if (this.disposed) return
|
|
62
87
|
this.onRefresh()
|
|
63
88
|
.then((newSessions) => {
|
|
89
|
+
if (this.disposed) return
|
|
64
90
|
this.sessions = [...newSessions]
|
|
91
|
+
this.clampSelection()
|
|
65
92
|
this.forceRender()
|
|
66
93
|
})
|
|
67
94
|
.catch(() => {})
|
|
@@ -78,17 +105,24 @@ export class Dashboard implements Component {
|
|
|
78
105
|
return this.cachedLines
|
|
79
106
|
}
|
|
80
107
|
this.cachedWidth = width
|
|
81
|
-
const
|
|
108
|
+
const tableWidth = width - 2
|
|
109
|
+
const columns = resolveColumns(tableWidth, this.showHidden)
|
|
82
110
|
const rows = [
|
|
83
|
-
this.theme.fg('borderAccent', this.headerLine(columns)),
|
|
84
|
-
this.theme.fg('borderAccent', '─'.repeat(Math.max(1,
|
|
85
|
-
...this.sessions.map((session) =>
|
|
86
|
-
columns
|
|
111
|
+
this.theme.fg('borderAccent', ` ${this.headerLine(columns)}`),
|
|
112
|
+
this.theme.fg('borderAccent', ` ${'─'.repeat(Math.max(1, tableWidth))}`),
|
|
113
|
+
...this.sessions.map((session, index) => {
|
|
114
|
+
const row = columns
|
|
87
115
|
.map(({ col, width }) => col.render(session, this.theme, width))
|
|
88
|
-
.join(COLUMN_SEPARATOR)
|
|
89
|
-
|
|
116
|
+
.join(COLUMN_SEPARATOR)
|
|
117
|
+
const selected = index === this.selectedIndex
|
|
118
|
+
const gutter = selected ? '> ' : ' '
|
|
119
|
+
const styledRow = selected ? this.theme.underline(row) : row
|
|
120
|
+
return gutter + styledRow
|
|
121
|
+
}),
|
|
90
122
|
'',
|
|
91
123
|
this.footerLine(width, [
|
|
124
|
+
['j/k/↑↓', 'move'],
|
|
125
|
+
['x', 'kill'],
|
|
92
126
|
['o', 'show/hide ids'],
|
|
93
127
|
['r', 'refresh'],
|
|
94
128
|
['q/esc', 'close'],
|
|
@@ -113,6 +147,21 @@ export class Dashboard implements Component {
|
|
|
113
147
|
}
|
|
114
148
|
|
|
115
149
|
handleInput(data: string): void {
|
|
150
|
+
if (matchesKey(data, 'j') || matchesKey(data, Key.down)) {
|
|
151
|
+
this.moveSelection(1)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (matchesKey(data, 'k') || matchesKey(data, Key.up)) {
|
|
156
|
+
this.moveSelection(-1)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (matchesKey(data, 'x')) {
|
|
161
|
+
this.killSelected()
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
116
165
|
if (matchesKey(data, 'r')) {
|
|
117
166
|
this.refresh()
|
|
118
167
|
return
|
package/src/jobs.test.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { JOB_END_EVENT, JOB_START_EVENT, JobTracker } from './jobs.js'
|
|
5
|
+
|
|
6
|
+
type EventsListener = (payload?: unknown) => void
|
|
7
|
+
|
|
8
|
+
interface FakePi {
|
|
9
|
+
on(event: string, listener: (payload?: unknown) => void): () => void
|
|
10
|
+
events: { on(event: string, listener: EventsListener): () => void }
|
|
11
|
+
emitEvent(event: string, payload?: unknown): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeFakePi(): FakePi {
|
|
15
|
+
const eventListeners = new Map<string, Set<EventsListener>>()
|
|
16
|
+
return {
|
|
17
|
+
on() {
|
|
18
|
+
return () => {}
|
|
19
|
+
},
|
|
20
|
+
events: {
|
|
21
|
+
on(event, listener) {
|
|
22
|
+
let set = eventListeners.get(event)
|
|
23
|
+
if (!set) {
|
|
24
|
+
set = new Set()
|
|
25
|
+
eventListeners.set(event, set)
|
|
26
|
+
}
|
|
27
|
+
set.add(listener)
|
|
28
|
+
return () => {
|
|
29
|
+
set.delete(listener)
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
emitEvent(event, payload) {
|
|
34
|
+
const set = eventListeners.get(event)
|
|
35
|
+
if (!set) return
|
|
36
|
+
for (const listener of [...set]) listener(payload)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeTracker(pi: FakePi = makeFakePi()): JobTracker {
|
|
42
|
+
const tracker = new JobTracker(pi as unknown as ExtensionAPI)
|
|
43
|
+
tracker.register(() => {})
|
|
44
|
+
return tracker
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('JobTracker', () => {
|
|
48
|
+
it('fires onStart listeners on job start', () => {
|
|
49
|
+
const pi = makeFakePi()
|
|
50
|
+
const tracker = makeTracker(pi)
|
|
51
|
+
const onStart = vi.fn()
|
|
52
|
+
|
|
53
|
+
tracker.onStart(onStart)
|
|
54
|
+
|
|
55
|
+
pi.emitEvent(JOB_START_EVENT, { id: 'job-1' })
|
|
56
|
+
|
|
57
|
+
expect(onStart).toHaveBeenCalledOnce()
|
|
58
|
+
expect(tracker.hasActiveJobs).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('stops firing onStart after unsubscribe', () => {
|
|
62
|
+
const pi = makeFakePi()
|
|
63
|
+
const tracker = makeTracker(pi)
|
|
64
|
+
const onStart = vi.fn()
|
|
65
|
+
|
|
66
|
+
const unsubscribe = tracker.onStart(onStart)
|
|
67
|
+
unsubscribe()
|
|
68
|
+
|
|
69
|
+
pi.emitEvent(JOB_START_EVENT, { id: 'job-1' })
|
|
70
|
+
|
|
71
|
+
expect(onStart).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('fires onEnd while the job is still active', () => {
|
|
75
|
+
const pi = makeFakePi()
|
|
76
|
+
const tracker = makeTracker(pi)
|
|
77
|
+
|
|
78
|
+
pi.emitEvent(JOB_START_EVENT, { id: 'job-1' })
|
|
79
|
+
|
|
80
|
+
let activeDuringEnd: boolean | undefined
|
|
81
|
+
tracker.onEnd(() => {
|
|
82
|
+
activeDuringEnd = tracker.hasActiveJobs
|
|
83
|
+
})
|
|
84
|
+
pi.emitEvent(JOB_END_EVENT, { id: 'job-1' })
|
|
85
|
+
|
|
86
|
+
expect(activeDuringEnd).toBe(true)
|
|
87
|
+
expect(tracker.hasActiveJobs).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('drops old listeners when re-registering after stop', () => {
|
|
91
|
+
const pi = makeFakePi()
|
|
92
|
+
const tracker = makeTracker(pi)
|
|
93
|
+
const oldListener = vi.fn()
|
|
94
|
+
const newListener = vi.fn()
|
|
95
|
+
|
|
96
|
+
tracker.onStart(oldListener)
|
|
97
|
+
tracker.stop()
|
|
98
|
+
tracker.register(() => {})
|
|
99
|
+
tracker.onStart(newListener)
|
|
100
|
+
|
|
101
|
+
pi.emitEvent(JOB_START_EVENT, { id: 'job-1' })
|
|
102
|
+
|
|
103
|
+
expect(oldListener).not.toHaveBeenCalled()
|
|
104
|
+
expect(newListener).toHaveBeenCalledOnce()
|
|
105
|
+
})
|
|
106
|
+
})
|
package/src/jobs.ts
CHANGED
|
@@ -5,12 +5,21 @@ export const JOB_END_EVENT = 'pi-notify:job:end'
|
|
|
5
5
|
|
|
6
6
|
export class JobTracker extends Registrar {
|
|
7
7
|
private activeJobs = new Set<string>()
|
|
8
|
+
private onStartListeners: Array<() => void> = []
|
|
8
9
|
private onEndListeners: Array<() => void> = []
|
|
9
10
|
|
|
10
11
|
get hasActiveJobs(): boolean {
|
|
11
12
|
return this.activeJobs.size > 0
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
onStart(listener: () => void): () => void {
|
|
16
|
+
this.onStartListeners.push(listener)
|
|
17
|
+
return () => {
|
|
18
|
+
const index = this.onStartListeners.indexOf(listener)
|
|
19
|
+
if (index !== -1) this.onStartListeners.splice(index, 1)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
onEnd(listener: () => void): () => void {
|
|
15
24
|
this.onEndListeners.push(listener)
|
|
16
25
|
return () => {
|
|
@@ -19,28 +28,33 @@ export class JobTracker extends Registrar {
|
|
|
19
28
|
}
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
private extractJobId(params: unknown): string | undefined {
|
|
32
|
+
if (
|
|
33
|
+
typeof params === 'object' &&
|
|
34
|
+
params !== null &&
|
|
35
|
+
'id' in params &&
|
|
36
|
+
typeof params.id === 'string'
|
|
37
|
+
) {
|
|
38
|
+
return params.id
|
|
39
|
+
}
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
protected override setup(): void {
|
|
23
44
|
const startUnsub = this.pi.events.on(JOB_START_EVENT, (params) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
typeof params.id === 'string'
|
|
29
|
-
) {
|
|
30
|
-
this.activeJobs.add(params.id)
|
|
45
|
+
const id = this.extractJobId(params)
|
|
46
|
+
if (id !== undefined) {
|
|
47
|
+
this.activeJobs.add(id)
|
|
48
|
+
for (const listener of this.onStartListeners) listener()
|
|
31
49
|
}
|
|
32
50
|
})
|
|
33
51
|
this.unsubscribes.push(startUnsub)
|
|
34
52
|
|
|
35
53
|
const endUnsub = this.pi.events.on(JOB_END_EVENT, (params) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
params !== null &&
|
|
39
|
-
'id' in params &&
|
|
40
|
-
typeof params.id === 'string'
|
|
41
|
-
) {
|
|
54
|
+
const id = this.extractJobId(params)
|
|
55
|
+
if (id !== undefined) {
|
|
42
56
|
for (const listener of this.onEndListeners) listener()
|
|
43
|
-
this.activeJobs.delete(
|
|
57
|
+
this.activeJobs.delete(id)
|
|
44
58
|
}
|
|
45
59
|
})
|
|
46
60
|
this.unsubscribes.push(endUnsub)
|
|
@@ -49,5 +63,7 @@ export class JobTracker extends Registrar {
|
|
|
49
63
|
override stop(): void {
|
|
50
64
|
super.stop()
|
|
51
65
|
this.activeJobs.clear()
|
|
66
|
+
this.onStartListeners = []
|
|
67
|
+
this.onEndListeners = []
|
|
52
68
|
}
|
|
53
69
|
}
|
|
@@ -56,11 +56,29 @@ function makeFakePi(): FakePi {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
type FakeJobTracker = {
|
|
60
|
+
hasActiveJobs: boolean
|
|
61
|
+
onStart: (listener: () => void) => () => void
|
|
62
|
+
onEnd: (listener: () => void) => () => void
|
|
63
|
+
startListener?: () => void
|
|
64
|
+
endListener?: () => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeFakeJobTracker(): FakeJobTracker {
|
|
68
|
+
const instance: FakeJobTracker = {
|
|
61
69
|
hasActiveJobs: false,
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
onStart: (listener: () => void) => {
|
|
71
|
+
instance.startListener = listener
|
|
72
|
+
return () => {}
|
|
73
|
+
},
|
|
74
|
+
onEnd: (listener: () => void) => {
|
|
75
|
+
instance.endListener = listener
|
|
76
|
+
return () => {}
|
|
77
|
+
},
|
|
78
|
+
startListener: undefined as (() => void) | undefined,
|
|
79
|
+
endListener: undefined as (() => void) | undefined,
|
|
80
|
+
}
|
|
81
|
+
return instance
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
async function flush(): Promise<void> {
|
|
@@ -87,12 +105,14 @@ function makeTracker(
|
|
|
87
105
|
tracker: StateTracker
|
|
88
106
|
states: string[]
|
|
89
107
|
bodies: string[]
|
|
108
|
+
jobs: FakeJobTracker
|
|
90
109
|
} {
|
|
91
110
|
const states: string[] = []
|
|
92
111
|
const bodies: string[] = []
|
|
112
|
+
const jobs = makeFakeJobTracker()
|
|
93
113
|
const tracker = new StateTracker(
|
|
94
114
|
pi as unknown as ExtensionAPI,
|
|
95
|
-
|
|
115
|
+
jobs as unknown as JobTracker,
|
|
96
116
|
config,
|
|
97
117
|
)
|
|
98
118
|
tracker.register((body) => bodies.push(body))
|
|
@@ -102,7 +122,7 @@ function makeTracker(
|
|
|
102
122
|
tracker.events.on('idle', () => {
|
|
103
123
|
states.push('idle')
|
|
104
124
|
})
|
|
105
|
-
return { tracker, states, bodies }
|
|
125
|
+
return { tracker, states, bodies, jobs }
|
|
106
126
|
}
|
|
107
127
|
|
|
108
128
|
describe('StateTracker', () => {
|
|
@@ -144,16 +164,28 @@ describe('StateTracker', () => {
|
|
|
144
164
|
|
|
145
165
|
pi.emit('turn_start')
|
|
146
166
|
pi.emit('message_start')
|
|
167
|
+
pi.emit('turn_start')
|
|
168
|
+
|
|
169
|
+
await flush()
|
|
170
|
+
|
|
171
|
+
expect(states).toEqual(['running'])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('re-emits running after a notified tool call resets the state', async () => {
|
|
175
|
+
const pi = makeFakePi()
|
|
176
|
+
const { states } = makeTracker(pi)
|
|
177
|
+
|
|
178
|
+
pi.emit('turn_start')
|
|
147
179
|
pi.emit('tool_call', {
|
|
148
180
|
type: 'tool_call',
|
|
149
181
|
toolCallId: 't1',
|
|
150
|
-
toolName: '
|
|
182
|
+
toolName: 'bash',
|
|
151
183
|
})
|
|
152
184
|
pi.emit('turn_start')
|
|
153
185
|
|
|
154
186
|
await flush()
|
|
155
187
|
|
|
156
|
-
expect(states).toEqual(['running'])
|
|
188
|
+
expect(states).toEqual(['running', 'running'])
|
|
157
189
|
})
|
|
158
190
|
|
|
159
191
|
it('emits tool only for tools in notifyTools', async () => {
|
|
@@ -304,6 +336,19 @@ describe('StateTracker', () => {
|
|
|
304
336
|
expect(bodies).toEqual(['Idle'])
|
|
305
337
|
})
|
|
306
338
|
|
|
339
|
+
it('notifies Idle after background job activity without a turn', async () => {
|
|
340
|
+
const pi = makeFakePi()
|
|
341
|
+
const { bodies, jobs } = makeTracker(pi)
|
|
342
|
+
|
|
343
|
+
jobs.startListener?.()
|
|
344
|
+
jobs.endListener?.()
|
|
345
|
+
vi.advanceTimersByTime(10000)
|
|
346
|
+
|
|
347
|
+
await flush()
|
|
348
|
+
|
|
349
|
+
expect(bodies).toEqual(['Idle'])
|
|
350
|
+
})
|
|
351
|
+
|
|
307
352
|
it('does not notify Idle on idle without activity', async () => {
|
|
308
353
|
const pi = makeFakePi()
|
|
309
354
|
const { bodies } = makeTracker(pi)
|
package/src/state-tracker.ts
CHANGED
|
@@ -22,7 +22,6 @@ export class StateTracker extends Registrar {
|
|
|
22
22
|
private readonly config: ResolvedNotifyConfig
|
|
23
23
|
private idleTimer: NodeJS.Timeout | null = null
|
|
24
24
|
private running = false
|
|
25
|
-
private hasActivity = false
|
|
26
25
|
private notify: NotifyAction = () => {}
|
|
27
26
|
|
|
28
27
|
constructor(
|
|
@@ -40,9 +39,10 @@ export class StateTracker extends Registrar {
|
|
|
40
39
|
this.clearIdleTimer()
|
|
41
40
|
this.idleTimer = setTimeout(() => {
|
|
42
41
|
this.idleTimer = null
|
|
42
|
+
const wasRunning = this.running
|
|
43
43
|
this.running = false
|
|
44
44
|
void this.events.emit('idle')
|
|
45
|
-
if (
|
|
45
|
+
if (wasRunning && this.config.finished) {
|
|
46
46
|
this.notify('Idle')
|
|
47
47
|
}
|
|
48
48
|
}, IDLE_TIMEOUT_MS)
|
|
@@ -66,6 +66,7 @@ export class StateTracker extends Registrar {
|
|
|
66
66
|
if (typeof message !== 'string' || message === '') continue
|
|
67
67
|
const unsubscribe = this.pi.events.on(channel, () => {
|
|
68
68
|
this.notify(message)
|
|
69
|
+
this.running = false
|
|
69
70
|
void this.events.emit('event', channel)
|
|
70
71
|
})
|
|
71
72
|
this.unsubscribes.push(unsubscribe)
|
|
@@ -73,6 +74,7 @@ export class StateTracker extends Registrar {
|
|
|
73
74
|
|
|
74
75
|
const customEventUnsub = this.pi.events.on(PI_NOTIFY_EVENT, (payload) => {
|
|
75
76
|
this.notify(String(payload))
|
|
77
|
+
this.running = false
|
|
76
78
|
void this.events.emit('event', PI_NOTIFY_EVENT)
|
|
77
79
|
})
|
|
78
80
|
this.unsubscribes.push(customEventUnsub)
|
|
@@ -82,6 +84,7 @@ export class StateTracker extends Registrar {
|
|
|
82
84
|
this.pi.on('tool_call', (event) => {
|
|
83
85
|
if (this.config.notifyTools.has(event.toolName)) {
|
|
84
86
|
this.notify(`Tool call: ${event.toolName}`)
|
|
87
|
+
this.running = false
|
|
85
88
|
void this.events.emit('tool', event.toolName)
|
|
86
89
|
}
|
|
87
90
|
this.clearIdleTimer()
|
|
@@ -95,12 +98,12 @@ export class StateTracker extends Registrar {
|
|
|
95
98
|
this.setupToolCall()
|
|
96
99
|
|
|
97
100
|
this.pi.on('turn_start', () => {
|
|
98
|
-
this.hasActivity = true
|
|
99
101
|
this.markRunning()
|
|
100
102
|
this.clearIdleTimer()
|
|
101
103
|
})
|
|
102
104
|
|
|
103
105
|
this.pi.on('message_start', () => {
|
|
106
|
+
this.markRunning()
|
|
104
107
|
this.clearIdleTimer()
|
|
105
108
|
})
|
|
106
109
|
|
|
@@ -109,6 +112,10 @@ export class StateTracker extends Registrar {
|
|
|
109
112
|
})
|
|
110
113
|
|
|
111
114
|
this.unsubscribes.push(
|
|
115
|
+
this.jobTracker.onStart(() => {
|
|
116
|
+
this.markRunning()
|
|
117
|
+
this.clearIdleTimer()
|
|
118
|
+
}),
|
|
112
119
|
this.jobTracker.onEnd(() => {
|
|
113
120
|
this.startIdleTimer()
|
|
114
121
|
}),
|
|
@@ -117,7 +124,7 @@ export class StateTracker extends Registrar {
|
|
|
117
124
|
|
|
118
125
|
override stop(): void {
|
|
119
126
|
super.stop()
|
|
127
|
+
this.running = false
|
|
120
128
|
this.clearIdleTimer()
|
|
121
|
-
this.hasActivity = false
|
|
122
129
|
}
|
|
123
130
|
}
|