@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidou/pi-notify",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Desktop notification extension for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi",
@@ -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 color = session.state === 'running' ? 'success' : 'muted'
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(session.state, width, '…', true).padEnd(width),
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('[o] show/hide ids'))
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('[o] show/hide ids ·…')).toBe(true)
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 columns = resolveColumns(width, this.showHidden)
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, width))),
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
@@ -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
- if (
25
- typeof params === 'object' &&
26
- params !== null &&
27
- 'id' in params &&
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
- if (
37
- typeof params === 'object' &&
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(params.id)
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
- function makeFakeJobTracker(): JobTracker {
60
- return {
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
- onEnd: () => () => {},
63
- } as unknown as JobTracker
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
- makeFakeJobTracker(),
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: 'read',
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)
@@ -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 (this.hasActivity && this.config.finished) {
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
  }