@raidou/pi-notify 0.5.3 → 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/dashboard.test.ts +247 -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(
|
|
@@ -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: '中文项目名' }),
|
|
@@ -127,6 +140,7 @@ describe('dashboard state display', () => {
|
|
|
127
140
|
const dashboard = makeDashboard([
|
|
128
141
|
makeSession({ pid: process.pid, state: 'idle' }),
|
|
129
142
|
])
|
|
143
|
+
dashboard.handleInput('o')
|
|
130
144
|
try {
|
|
131
145
|
const row = dashboard
|
|
132
146
|
.render(200)
|
|
@@ -144,6 +158,7 @@ describe('dashboard state display', () => {
|
|
|
144
158
|
const dashboard = makeDashboard([
|
|
145
159
|
makeSession({ pid: 999, state: 'running' }),
|
|
146
160
|
])
|
|
161
|
+
dashboard.handleInput('o')
|
|
147
162
|
try {
|
|
148
163
|
const row = dashboard
|
|
149
164
|
.render(200)
|
|
@@ -175,3 +190,232 @@ describe('auto-refresh', () => {
|
|
|
175
190
|
expect(onRefresh).not.toHaveBeenCalled()
|
|
176
191
|
})
|
|
177
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
|
}
|