@raidou/pi-notify 0.5.3 → 0.7.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A notification extension for the [pi](https://github.com/earendil-works/pi-coding-agent) coding agent.
4
4
 
5
- `@raidou/pi-notify` fires a native desktop notification on idle, configured tool calls (e.g., `Tool call: ask_user`), and custom pi events (default: `permissions:ui_prompt`).
5
+ `@raidou/pi-notify` fires a native desktop notification on idle, tool calls (optional, e.g. `Tool call: ask_user`), user-facing UI prompts (via pi's `ui_prompt_start`, e.g. permission approvals or `select`/`confirm`/`input` dialogs, requires pi >= 0.85.0), and custom pi events.
6
6
 
7
7
  ## Installation
8
8
 
@@ -25,12 +25,11 @@ All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everyt
25
25
  {
26
26
  "piNotify": {
27
27
  "enabled": true, // master on/off switch (default: true)
28
- "notifyTools": ["ask_user", "ask_user_question"], // tools that trigger "Tool call" notifications
28
+ "notifyTools": [], // tools that trigger "Tool call" notifications (default: empty, i.e. no tool notifications)
29
29
  "tmuxSymbol": "🔔", // symbol appended to tmux window title (empty string to disable)
30
30
  "finished": true, // enable/disable "Idle" notification
31
31
  "events": {
32
- "permissions:ui_prompt": "Permission prompt", // custom event channel -> notification message
33
- "my:custom:event": "Custom event triggered", // add your own custom events
32
+ "my:custom:event": "Custom event triggered", // custom event channel -> notification message
34
33
  "other:event": false, // set to false to disable a specific event
35
34
  },
36
35
  "finishedThrottleSecs": 0, // 0 = always notify; >0 = skip finished toasts for runs shorter than N seconds
@@ -42,11 +41,13 @@ All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everyt
42
41
 
43
42
  ## What triggers a notification
44
43
 
45
- | Event | Source | Default body |
46
- | ----------------- | ----------------------------------------------------------- | ------------------------------------------- |
47
- | **Finished** | `agent_settled` (pi idle, no active jobs) | `Idle` |
48
- | **Tool calls** | `tool_call` on tools in `notifyTools` | `Tool call: <toolName>` |
49
- | **Custom events** | Custom pi event channels (default: `permissions:ui_prompt`) | Customizable (default: `Permission prompt`) |
44
+ | Event | Source | Default body |
45
+ | ----------------- | --------------------------------------------------------------------- | --------------------------------- |
46
+ | **Finished** | `agent_settled` (pi idle, no active jobs) | `Idle` |
47
+ | **Tool calls** | `tool_call` on tools in `notifyTools` (default: none) | `Tool call: <toolName>` |
48
+ | **UI prompts** | `ui_prompt_start` (requires pi >= 0.85.0) | `Waiting: <kind>[ <title>]` |
49
+ | **Custom events** | Custom pi event channels configured in `events` | Customizable |
50
+ | **External API** | `pi.events.emit('pi-notify:notify', 'message')` from other extensions | The emitted message |
50
51
 
51
52
  ### Job tracking for background tasks
52
53
 
@@ -76,6 +77,8 @@ Columns: `SESSION_ID` (last 6 chars), `PID`, `STATE` (running/idle, color-coded)
76
77
 
77
78
  Keybindings:
78
79
 
80
+ - `j`/`k` or `↑`/`↓` — move selection
81
+ - `x` — kill (SIGTERM) the selected session
79
82
  - `o` — show/hide the SESSION_ID and PID columns
80
83
  - `r` — refresh
81
84
  - `q` or `esc` — close
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidou/pi-notify",
3
- "version": "0.5.3",
3
+ "version": "0.7.0",
4
4
  "description": "Desktop notification extension for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi",
@@ -34,16 +34,17 @@
34
34
  "test:unit": "vitest run"
35
35
  },
36
36
  "dependencies": {
37
- "@earendil-works/pi-tui": "0.80.7",
38
37
  "emittery": "^2.0.0",
39
38
  "lodash-es": "^4.18.1",
40
39
  "node-notifier": "^10.0.1",
41
40
  "proper-lockfile": "^4.1.2"
42
41
  },
43
42
  "peerDependencies": {
44
- "@earendil-works/pi-coding-agent": ">=0.79.0"
43
+ "@earendil-works/pi-coding-agent": ">=0.85.0",
44
+ "@earendil-works/pi-tui": ">=0.85.0"
45
45
  },
46
46
  "devDependencies": {
47
+ "@earendil-works/pi-tui": "0.85.1",
47
48
  "@raidou/eslint-config-base": "^4.4.3",
48
49
  "@types/lodash-es": "^4.17.12",
49
50
  "@types/node": "^22.0.0",
package/src/config.ts CHANGED
@@ -28,20 +28,8 @@ export interface ResolvedNotifyConfig {
28
28
  readonly tmuxSymbol: string
29
29
  }
30
30
 
31
- /**
32
- * Default tool names that trigger notifications.
33
- *
34
- * Note: These are built-in pi tool names. If pi renames these tools, the default should be updated.
35
- * Source: @earendil-works/pi-coding-agent
36
- */
37
- const DEFAULT_NOTIFY_TOOLS = ['ask_user', 'ask_user_question'] as const
38
-
39
31
  const DEFAULT_TMUX_SYMBOL = '🔔'
40
32
 
41
- const DEFAULT_EVENTS: NotifyEventsConfig = {
42
- 'permissions:ui_prompt': 'Permission prompt',
43
- }
44
-
45
33
  const SETTINGS_PATH = join(getAgentDir(), 'settings.json')
46
34
 
47
35
  function readRawConfig(): NotifyConfig {
@@ -59,11 +47,10 @@ function readRawConfig(): NotifyConfig {
59
47
 
60
48
  export function loadConfig(): ResolvedNotifyConfig {
61
49
  const cfg = readRawConfig()
62
- const events = cfg.events ?? DEFAULT_EVENTS
63
50
  return {
64
51
  enabled: cfg.enabled ?? true,
65
- notifyTools: new Set(cfg.notifyTools ?? DEFAULT_NOTIFY_TOOLS),
66
- events,
52
+ notifyTools: new Set(cfg.notifyTools ?? []),
53
+ events: cfg.events ?? {},
67
54
  finished: cfg.finished ?? true,
68
55
  onlyNotifyWhenUnfocused: cfg.onlyNotifyWhenUnfocused ?? true,
69
56
  unfocusedActivityThresholdMs: Math.max(
@@ -171,10 +171,11 @@ describe('SessionStore', () => {
171
171
  )
172
172
  store.register(vi.fn())
173
173
 
174
- expect(emitSpy).toHaveBeenCalledTimes(4)
174
+ expect(emitSpy).toHaveBeenCalledTimes(5)
175
175
  expect(emitSpy).toHaveBeenCalledWith('running', expect.any(Function))
176
176
  expect(emitSpy).toHaveBeenCalledWith('idle', expect.any(Function))
177
177
  expect(emitSpy).toHaveBeenCalledWith('tool', expect.any(Function))
178
+ expect(emitSpy).toHaveBeenCalledWith('ui_prompt', expect.any(Function))
178
179
  expect(emitSpy).toHaveBeenCalledWith('event', expect.any(Function))
179
180
  expect(piOnSpy).toHaveBeenCalledWith('session_start', expect.any(Function))
180
181
  })
@@ -347,19 +348,20 @@ describe('SessionStore', () => {
347
348
  nowSpy.mockRestore()
348
349
  })
349
350
 
350
- it('does not update state on tool emission outside notifyTools', async () => {
351
+ it('updates state immediately on event emission', async () => {
351
352
  await updateState(() => ({ version: 2, sessions: {} }))
352
353
  const pi = makeFakePi()
353
354
  const jobTracker = {
354
355
  hasActiveJobs: false,
356
+ onStart: () => () => {},
355
357
  onEnd: () => () => {},
356
358
  } as unknown as JobTracker
357
359
  const tracker = new StateTracker(
358
360
  pi as unknown as ExtensionAPI,
359
361
  jobTracker,
360
362
  {
361
- notifyTools: new Set(['read']),
362
- events: {},
363
+ notifyTools: new Set(['bash']),
364
+ events: { 'permissions:ui_prompt': 'msg' },
363
365
  } as unknown as ResolvedNotifyConfig,
364
366
  )
365
367
  tracker.register(() => {})
@@ -373,32 +375,66 @@ describe('SessionStore', () => {
373
375
  })
374
376
  await new Promise((resolve) => setTimeout(resolve, 20))
375
377
 
376
- expect(readState().sessions[String(process.pid)]?.state).toBe('idle')
378
+ pi.emitEvent('permissions:ui_prompt', {})
379
+ await new Promise((resolve) => setTimeout(resolve, 20))
377
380
 
378
- pi.emit('tool_call', {
379
- type: 'tool_call',
380
- toolCallId: 't1',
381
- toolName: 'grep',
381
+ const record = readState().sessions[String(process.pid)]
382
+ expect(record?.state).toBe('event:permissions:ui_prompt')
383
+ })
384
+
385
+ it('updates state immediately on ui_prompt emission', async () => {
386
+ await updateState(() => ({ version: 2, sessions: {} }))
387
+ const pi = makeFakePi()
388
+ const jobTracker = {
389
+ hasActiveJobs: false,
390
+ onStart: () => () => {},
391
+ onEnd: () => () => {},
392
+ } as unknown as JobTracker
393
+ const tracker = new StateTracker(
394
+ pi as unknown as ExtensionAPI,
395
+ jobTracker,
396
+ {
397
+ notifyTools: new Set([]),
398
+ events: {},
399
+ } as unknown as ResolvedNotifyConfig,
400
+ )
401
+ tracker.register(() => {})
402
+
403
+ const store = new SessionStore(pi as unknown as ExtensionAPI, tracker)
404
+ store.register(() => {})
405
+
406
+ pi.emitSessionStart({
407
+ cwd: META.cwd,
408
+ sessionManager: { getSessionId: () => SESSION_ID },
382
409
  })
383
410
  await new Promise((resolve) => setTimeout(resolve, 20))
384
411
 
412
+ pi.emit('ui_prompt_start', {
413
+ type: 'ui_prompt_start',
414
+ reason: 'ui_prompt',
415
+ kind: 'confirm',
416
+ })
417
+
418
+ await new Promise((resolve) => setTimeout(resolve, 20))
419
+
385
420
  const record = readState().sessions[String(process.pid)]
386
- expect(record?.state).toBe('idle')
421
+ expect(record?.state).toBe('ui_prompt:confirm')
387
422
  })
388
423
 
389
- it('updates state immediately on event emission', async () => {
424
+ it('does not update state on tool emission outside notifyTools', async () => {
390
425
  await updateState(() => ({ version: 2, sessions: {} }))
391
426
  const pi = makeFakePi()
392
427
  const jobTracker = {
393
428
  hasActiveJobs: false,
429
+ onStart: () => () => {},
394
430
  onEnd: () => () => {},
395
431
  } as unknown as JobTracker
396
432
  const tracker = new StateTracker(
397
433
  pi as unknown as ExtensionAPI,
398
434
  jobTracker,
399
435
  {
400
- notifyTools: new Set(['bash']),
401
- events: { 'permissions:ui_prompt': 'msg' },
436
+ notifyTools: new Set(['read']),
437
+ events: {},
402
438
  } as unknown as ResolvedNotifyConfig,
403
439
  )
404
440
  tracker.register(() => {})
@@ -412,11 +448,17 @@ describe('SessionStore', () => {
412
448
  })
413
449
  await new Promise((resolve) => setTimeout(resolve, 20))
414
450
 
415
- pi.emitEvent('permissions:ui_prompt', {})
451
+ expect(readState().sessions[String(process.pid)]?.state).toBe('idle')
452
+
453
+ pi.emit('tool_call', {
454
+ type: 'tool_call',
455
+ toolCallId: 't1',
456
+ toolName: 'grep',
457
+ })
416
458
  await new Promise((resolve) => setTimeout(resolve, 20))
417
459
 
418
460
  const record = readState().sessions[String(process.pid)]
419
- expect(record?.state).toBe('event:permissions:ui_prompt')
461
+ expect(record?.state).toBe('idle')
420
462
  })
421
463
 
422
464
  it('ignores tool and event emissions before session_start', async () => {
@@ -79,6 +79,9 @@ export class SessionStore extends Registrar {
79
79
  this.stateTracker.events.on('tool', async ({ data }) => {
80
80
  await this.saveSession(`tool_call:${data}`)
81
81
  }),
82
+ this.stateTracker.events.on('ui_prompt', async ({ data }) => {
83
+ await this.saveSession(`ui_prompt:${data}`)
84
+ }),
82
85
  this.stateTracker.events.on('event', async ({ data }) => {
83
86
  await this.saveSession(`event:${data}`)
84
87
  }),
@@ -54,7 +54,11 @@ export async function readSessions(): Promise<SessionRecord[]> {
54
54
  }
55
55
 
56
56
  export type SessionState =
57
- 'running' | 'idle' | `tool_call:${string}` | `event:${string}`
57
+ | 'running'
58
+ | 'idle'
59
+ | `tool_call:${string}`
60
+ | `event:${string}`
61
+ | `ui_prompt:${string}`
58
62
 
59
63
  export interface SessionRecord {
60
64
  pid: number
@@ -112,8 +116,12 @@ function parseSessionRecord(value: unknown): SessionRecord | undefined {
112
116
 
113
117
  function isActivityState(
114
118
  value: string,
115
- ): value is `tool_call:${string}` | `event:${string}` {
116
- return value.startsWith('tool_call:') || value.startsWith('event:')
119
+ ): value is `tool_call:${string}` | `event:${string}` | `ui_prompt:${string}` {
120
+ return (
121
+ value.startsWith('tool_call:') ||
122
+ value.startsWith('event:') ||
123
+ value.startsWith('ui_prompt:')
124
+ )
117
125
  }
118
126
 
119
127
  function isSessionState(value: unknown): value is SessionState {
@@ -8,6 +8,7 @@ import { Dashboard } from './dashboard.js'
8
8
  const theme = {
9
9
  fg: (color: ThemeColor, text: string) => `\x1b[90m${text}\x1b[0m`,
10
10
  bold: (text: string) => `\x1b[1m${text}\x1b[0m`,
11
+ underline: (text: string) => `\x1b[4m${text}\x1b[0m`,
11
12
  } as unknown as Theme
12
13
 
13
14
  const ANSI_RE =
@@ -70,18 +71,31 @@ describe('Dashboard render clipping', () => {
70
71
  const visible = lines.map(stripAnsi)
71
72
  const header = visible.find((l) => l.includes('STATE'))
72
73
  expect(header).toBeDefined()
73
- const footer = visible.find((l) => l.startsWith('[o] show/hide ids'))
74
+ const footer = visible.find((l) => l.startsWith('[j/k/'))
74
75
  expect(footer).toBeDefined()
75
76
  if (!header || !footer) throw new Error('unreachable')
76
- expect(header.startsWith('STATE')).toBe(true)
77
+ expect(header.startsWith(' STATE')).toBe(true)
77
78
  expect(header.endsWith('…')).toBe(true)
78
- expect(footer.startsWith('[o] show/hide ids ·…')).toBe(true)
79
+ expect(footer.startsWith('[j/k/↑↓] move · [x]…')).toBe(true)
79
80
  for (const line of visible) {
80
81
  expect(visibleWidth(line)).toBeLessThanOrEqual(20)
81
82
  }
82
83
  dashboard.dispose()
83
84
  })
84
85
 
86
+ it('aligns header columns with session rows via the 2-char gutter', () => {
87
+ const dashboard = makeDashboard([makeSession({})])
88
+ const lines = dashboard.render(200).map(stripAnsi)
89
+ const header = lines.find((l) => l.includes('STATE'))
90
+ const row = lines.find((l) => l.includes('proj'))
91
+ expect(header).toBeDefined()
92
+ expect(row).toBeDefined()
93
+ if (!header || !row) throw new Error('unreachable')
94
+ expect(header.indexOf('PROJECT')).toBe(row.indexOf('proj'))
95
+ expect(header.indexOf('STATE')).toBe(2)
96
+ dashboard.dispose()
97
+ })
98
+
85
99
  it('counts CJK project names by display width', () => {
86
100
  const dashboard = makeDashboard([
87
101
  makeSession({ projectName: '中文项目名' }),
@@ -127,6 +141,7 @@ describe('dashboard state display', () => {
127
141
  const dashboard = makeDashboard([
128
142
  makeSession({ pid: process.pid, state: 'idle' }),
129
143
  ])
144
+ dashboard.handleInput('o')
130
145
  try {
131
146
  const row = dashboard
132
147
  .render(200)
@@ -144,6 +159,7 @@ describe('dashboard state display', () => {
144
159
  const dashboard = makeDashboard([
145
160
  makeSession({ pid: 999, state: 'running' }),
146
161
  ])
162
+ dashboard.handleInput('o')
147
163
  try {
148
164
  const row = dashboard
149
165
  .render(200)
@@ -175,3 +191,232 @@ describe('auto-refresh', () => {
175
191
  expect(onRefresh).not.toHaveBeenCalled()
176
192
  })
177
193
  })
194
+
195
+ describe('selection navigation', () => {
196
+ const rows = (dashboard: Dashboard) =>
197
+ dashboard
198
+ .render(200)
199
+ .map(stripAnsi)
200
+ .filter((l) => l.includes('proj'))
201
+
202
+ it('starts with first row selected and moves with j/k and arrows', () => {
203
+ const dashboard = makeDashboard([
204
+ makeSession({ pid: 1, sessionId: 'abc123' }),
205
+ makeSession({ pid: 2, sessionId: 'def456' }),
206
+ makeSession({ pid: 3, sessionId: 'ghi789' }),
207
+ ])
208
+ try {
209
+ dashboard.handleInput('o')
210
+ let [first, second, third] = rows(dashboard)
211
+ expect(first?.startsWith('> ')).toBe(true)
212
+ expect(second?.startsWith(' ')).toBe(true)
213
+
214
+ dashboard.handleInput('j')
215
+ ;[first, second, third] = rows(dashboard)
216
+ expect(first?.startsWith(' ')).toBe(true)
217
+ expect(second?.startsWith('> ')).toBe(true)
218
+
219
+ dashboard.handleInput('\x1b[B')
220
+ ;[first, second, third] = rows(dashboard)
221
+ expect(third?.startsWith('> ')).toBe(true)
222
+
223
+ dashboard.handleInput('k')
224
+ dashboard.handleInput('\x1b[A')
225
+ ;[first, second, third] = rows(dashboard)
226
+ expect(first?.startsWith('> ')).toBe(true)
227
+ } finally {
228
+ dashboard.dispose()
229
+ }
230
+ })
231
+
232
+ it('clamps selection at first and last row', () => {
233
+ const dashboard = makeDashboard([
234
+ makeSession({ pid: 1, sessionId: 'abc123' }),
235
+ makeSession({ pid: 2, sessionId: 'def456' }),
236
+ ])
237
+ try {
238
+ dashboard.handleInput('o')
239
+ dashboard.handleInput('k')
240
+ expect(rows(dashboard)[0]?.startsWith('> ')).toBe(true)
241
+
242
+ dashboard.handleInput('j')
243
+ dashboard.handleInput('j')
244
+ const rowsNow = rows(dashboard)
245
+ expect(rowsNow[0]?.startsWith(' ')).toBe(true)
246
+ expect(rowsNow[1]?.startsWith('> ')).toBe(true)
247
+ } finally {
248
+ dashboard.dispose()
249
+ }
250
+ })
251
+
252
+ it('clamps selection to the last row after refresh shrinks the list', async () => {
253
+ const onRefresh = vi.fn(async () => [
254
+ makeSession({ pid: 3, sessionId: 'ghi789' }),
255
+ ])
256
+ const dashboard = makeDashboard(
257
+ [
258
+ makeSession({ pid: 1, sessionId: 'abc123' }),
259
+ makeSession({ pid: 2, sessionId: 'def456' }),
260
+ makeSession({ pid: 4, sessionId: 'jkl012' }),
261
+ ],
262
+ { onRefresh },
263
+ )
264
+ try {
265
+ dashboard.handleInput('o')
266
+ dashboard.handleInput('j')
267
+ dashboard.handleInput('j')
268
+ expect(rows(dashboard)[2]?.startsWith('> ')).toBe(true)
269
+
270
+ dashboard.handleInput('r')
271
+ await vi.waitFor(() => {
272
+ expect(onRefresh).toHaveBeenCalled()
273
+ })
274
+
275
+ // selection stays on the last row (position-based), moving down must not go out of bounds
276
+ dashboard.handleInput('j')
277
+ const visible = rows(dashboard)
278
+ expect(visible).toHaveLength(1)
279
+ expect(visible[0]?.startsWith('> ')).toBe(true)
280
+ } finally {
281
+ dashboard.dispose()
282
+ }
283
+ })
284
+
285
+ it('no-ops on empty session list', () => {
286
+ const dashboard = makeDashboard([])
287
+ try {
288
+ dashboard.handleInput('j')
289
+ dashboard.handleInput('k')
290
+ dashboard.handleInput('x')
291
+ expect(
292
+ dashboard.render(200).some((l) => stripAnsi(l).startsWith('> ')),
293
+ ).toBe(false)
294
+ } finally {
295
+ dashboard.dispose()
296
+ }
297
+ })
298
+ })
299
+
300
+ describe('kill action', () => {
301
+ let killSpy: ReturnType<typeof vi.spyOn>
302
+
303
+ const rows = (dashboard: Dashboard) =>
304
+ dashboard
305
+ .render(200)
306
+ .map(stripAnsi)
307
+ .filter((l) => l.includes('proj'))
308
+
309
+ it('kills the selected session with SIGTERM', async () => {
310
+ killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
311
+ const onRefresh = vi.fn(async () => [
312
+ makeSession({ pid: process.pid, sessionId: 'abc123' }),
313
+ ])
314
+ const dashboard = makeDashboard(
315
+ [
316
+ makeSession({ pid: process.pid, sessionId: 'self001' }),
317
+ makeSession({ pid: 999, sessionId: 'target1' }),
318
+ ],
319
+ { onRefresh },
320
+ )
321
+ try {
322
+ dashboard.handleInput('o')
323
+ dashboard.handleInput('j')
324
+ expect(rows(dashboard)[1]?.startsWith('> ')).toBe(true)
325
+ dashboard.handleInput('x')
326
+ expect(killSpy).toHaveBeenCalledWith(999, 'SIGTERM')
327
+ await vi.waitFor(() => {
328
+ expect(onRefresh).toHaveBeenCalled()
329
+ })
330
+ } finally {
331
+ dashboard.dispose()
332
+ killSpy.mockRestore()
333
+ }
334
+ })
335
+
336
+ it('does not kill the dashboard own row', () => {
337
+ killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
338
+ const dashboard = makeDashboard([
339
+ makeSession({ pid: process.pid, sessionId: 'abc123' }),
340
+ ])
341
+ try {
342
+ dashboard.handleInput('x')
343
+ expect(killSpy).not.toHaveBeenCalled()
344
+ } finally {
345
+ dashboard.dispose()
346
+ killSpy.mockRestore()
347
+ }
348
+ })
349
+
350
+ it('ignores ESRCH errors from kill', () => {
351
+ killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
352
+ throw Object.assign(new Error('no such process'), { code: 'ESRCH' })
353
+ })
354
+ const dashboard = makeDashboard([
355
+ makeSession({ pid: 999, sessionId: 'abc123' }),
356
+ ])
357
+ try {
358
+ expect(() => {
359
+ dashboard.handleInput('x')
360
+ }).not.toThrow()
361
+ } finally {
362
+ dashboard.dispose()
363
+ killSpy.mockRestore()
364
+ }
365
+ })
366
+ })
367
+
368
+ describe('selection degradation', () => {
369
+ it('follows the selected session as the list shrinks', async () => {
370
+ const onRefresh = vi.fn(async () => [
371
+ makeSession({ pid: 2, sessionId: 'bbb' }),
372
+ ])
373
+ const dashboard = makeDashboard(
374
+ [
375
+ makeSession({ pid: 1, sessionId: 'aaa' }),
376
+ makeSession({ pid: 2, sessionId: 'bbb' }),
377
+ ],
378
+ { onRefresh },
379
+ )
380
+ try {
381
+ // select row 0 (session 'aaa'), refresh removes it -> marker lands on 'bbb'
382
+ dashboard.handleInput('r')
383
+ await vi.waitFor(() => {
384
+ expect(onRefresh).toHaveBeenCalled()
385
+ })
386
+ const visible = dashboard
387
+ .render(200)
388
+ .map(stripAnsi)
389
+ .filter((l) => l.includes('proj'))
390
+ expect(visible).toHaveLength(1)
391
+ expect(visible[0]?.startsWith('> ')).toBe(true)
392
+
393
+ // refresh to empty -> no marker; navigation/kill no-op
394
+ const emptyRefresh = vi.fn(async () => [])
395
+ const dashboard2 = makeDashboard(
396
+ [makeSession({ pid: 2, sessionId: 'abc123' })],
397
+ { onRefresh: emptyRefresh },
398
+ )
399
+ try {
400
+ dashboard2.handleInput('r')
401
+ await vi.waitFor(() => {
402
+ expect(emptyRefresh).toHaveBeenCalled()
403
+ })
404
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
405
+ try {
406
+ dashboard2.handleInput('j')
407
+ dashboard2.handleInput('x')
408
+ expect(
409
+ dashboard2.render(200).some((l) => stripAnsi(l).startsWith('> ')),
410
+ ).toBe(false)
411
+ expect(killSpy).not.toHaveBeenCalled()
412
+ } finally {
413
+ killSpy.mockRestore()
414
+ }
415
+ } finally {
416
+ dashboard2.dispose()
417
+ }
418
+ } finally {
419
+ dashboard.dispose()
420
+ }
421
+ })
422
+ })
@@ -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> {
@@ -71,7 +89,7 @@ const BASE_CONFIG: ResolvedNotifyConfig = {
71
89
  enabled: true,
72
90
  notifyTools: new Set(['bash', 'read']),
73
91
  events: {
74
- 'permissions:ui_prompt': 'msg',
92
+ 'my:custom:event': 'msg',
75
93
  'disabled:channel': false,
76
94
  },
77
95
  finished: true,
@@ -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,41 @@ 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 custom event resets the state', async () => {
175
+ const pi = makeFakePi()
176
+ const { states } = makeTracker(pi)
177
+
178
+ pi.emit('turn_start')
179
+ pi.emitEvent('my:custom:event', {})
180
+ pi.emit('turn_start')
181
+
182
+ await flush()
183
+
184
+ expect(states).toEqual(['running', 'running'])
185
+ })
186
+
187
+ it('re-emits running after a notified tool call resets the state', async () => {
188
+ const pi = makeFakePi()
189
+ const { states } = makeTracker(pi)
190
+
191
+ pi.emit('turn_start')
147
192
  pi.emit('tool_call', {
148
193
  type: 'tool_call',
149
194
  toolCallId: 't1',
150
- toolName: 'read',
195
+ toolName: 'bash',
151
196
  })
152
197
  pi.emit('turn_start')
153
198
 
154
199
  await flush()
155
200
 
156
- expect(states).toEqual(['running'])
201
+ expect(states).toEqual(['running', 'running'])
157
202
  })
158
203
 
159
204
  it('emits tool only for tools in notifyTools', async () => {
@@ -188,12 +233,12 @@ describe('StateTracker', () => {
188
233
  events.push(event.data)
189
234
  })
190
235
 
191
- pi.emitEvent('permissions:ui_prompt', {})
236
+ pi.emitEvent('my:custom:event', {})
192
237
  pi.emitEvent('disabled:channel', {})
193
238
 
194
239
  await flush()
195
240
 
196
- expect(events).toEqual(['permissions:ui_prompt'])
241
+ expect(events).toEqual(['my:custom:event'])
197
242
  })
198
243
 
199
244
  it('unsubscribes from channel events on stop', async () => {
@@ -205,7 +250,7 @@ describe('StateTracker', () => {
205
250
  })
206
251
 
207
252
  tracker.stop()
208
- pi.emitEvent('permissions:ui_prompt', {})
253
+ pi.emitEvent('my:custom:event', {})
209
254
 
210
255
  await flush()
211
256
 
@@ -230,7 +275,7 @@ describe('StateTracker', () => {
230
275
  const pi = makeFakePi()
231
276
  const { bodies } = makeTracker(pi)
232
277
 
233
- pi.emitEvent('permissions:ui_prompt', {})
278
+ pi.emitEvent('my:custom:event', {})
234
279
 
235
280
  expect(bodies).toEqual(['msg'])
236
281
  })
@@ -265,6 +310,56 @@ describe('StateTracker', () => {
265
310
  expect(bodies).toEqual(['custom payload'])
266
311
  })
267
312
 
313
+ it('notifies on ui_prompt_start with kind and title', () => {
314
+ const pi = makeFakePi()
315
+ const { bodies } = makeTracker(pi)
316
+
317
+ pi.emit('ui_prompt_start', {
318
+ type: 'ui_prompt_start',
319
+ reason: 'ui_prompt',
320
+ kind: 'confirm',
321
+ title: 'Apply changes?',
322
+ })
323
+
324
+ expect(bodies).toEqual(['Waiting: Confirm — Apply changes?'])
325
+ })
326
+
327
+ it('notifies on ui_prompt_start without a title', () => {
328
+ const pi = makeFakePi()
329
+ const { bodies } = makeTracker(pi)
330
+
331
+ pi.emit('ui_prompt_start', {
332
+ type: 'ui_prompt_start',
333
+ reason: 'ui_prompt',
334
+ kind: 'select',
335
+ })
336
+
337
+ expect(bodies).toEqual(['Waiting: Select'])
338
+ })
339
+
340
+ it('emits ui_prompt with the raw kind and resets running', async () => {
341
+ const pi = makeFakePi()
342
+ const { tracker, states } = makeTracker(pi)
343
+ const kinds: string[] = []
344
+ tracker.events.on('ui_prompt', (event) => {
345
+ kinds.push(event.data)
346
+ })
347
+
348
+ pi.emit('turn_start')
349
+ pi.emit('ui_prompt_start', {
350
+ type: 'ui_prompt_start',
351
+ reason: 'ui_prompt',
352
+ kind: 'confirm',
353
+ title: 'Apply changes?',
354
+ })
355
+ pi.emit('turn_start')
356
+
357
+ await flush()
358
+
359
+ expect(kinds).toEqual(['confirm'])
360
+ expect(states).toEqual(['running', 'running'])
361
+ })
362
+
268
363
  it('notifies for tools in notifyTools', () => {
269
364
  const pi = makeFakePi()
270
365
  const { bodies } = makeTracker(pi)
@@ -291,6 +386,22 @@ describe('StateTracker', () => {
291
386
  expect(bodies).toEqual([])
292
387
  })
293
388
 
389
+ it('does not notify when notifyTools is empty', () => {
390
+ const pi = makeFakePi()
391
+ const { bodies } = makeTracker(pi, {
392
+ ...BASE_CONFIG,
393
+ notifyTools: new Set([]),
394
+ })
395
+
396
+ pi.emit('tool_call', {
397
+ type: 'tool_call',
398
+ toolCallId: 't1',
399
+ toolName: 'bash',
400
+ })
401
+
402
+ expect(bodies).toEqual([])
403
+ })
404
+
294
405
  it('notifies Idle on idle when there was activity', async () => {
295
406
  const pi = makeFakePi()
296
407
  const { bodies } = makeTracker(pi)
@@ -304,6 +415,19 @@ describe('StateTracker', () => {
304
415
  expect(bodies).toEqual(['Idle'])
305
416
  })
306
417
 
418
+ it('notifies Idle after background job activity without a turn', async () => {
419
+ const pi = makeFakePi()
420
+ const { bodies, jobs } = makeTracker(pi)
421
+
422
+ jobs.startListener?.()
423
+ jobs.endListener?.()
424
+ vi.advanceTimersByTime(10000)
425
+
426
+ await flush()
427
+
428
+ expect(bodies).toEqual(['Idle'])
429
+ })
430
+
307
431
  it('does not notify Idle on idle without activity', async () => {
308
432
  const pi = makeFakePi()
309
433
  const { bodies } = makeTracker(pi)
@@ -1,4 +1,7 @@
1
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
1
+ import type {
2
+ ExtensionAPI,
3
+ UIPromptKind,
4
+ } from '@earendil-works/pi-coding-agent'
2
5
  import Emittery from 'emittery'
3
6
 
4
7
  import type { ResolvedNotifyConfig } from './config.js'
@@ -10,19 +13,33 @@ export const PI_NOTIFY_EVENT = 'pi-notify:notify'
10
13
 
11
14
  const IDLE_TIMEOUT_MS = 10000
12
15
 
16
+ const PROMPT_KIND_LABELS: Record<UIPromptKind, string> = {
17
+ select: 'Select',
18
+ confirm: 'Confirm',
19
+ input: 'Input',
20
+ editor: 'Editor',
21
+ custom: 'Prompt',
22
+ }
23
+
24
+ function promptMessage(kind: UIPromptKind, title?: string): string {
25
+ const label = PROMPT_KIND_LABELS[kind]
26
+ if (!title) return `Waiting: ${label}`
27
+ return `Waiting: ${label} — ${title}`
28
+ }
29
+
13
30
  export class StateTracker extends Registrar {
14
31
  readonly events = new Emittery<{
15
32
  running: never
16
33
  idle: never
17
34
  tool: string
18
35
  event: string
36
+ ui_prompt: string
19
37
  }>()
20
38
 
21
39
  private readonly jobTracker: JobTracker
22
40
  private readonly config: ResolvedNotifyConfig
23
41
  private idleTimer: NodeJS.Timeout | null = null
24
42
  private running = false
25
- private hasActivity = false
26
43
  private notify: NotifyAction = () => {}
27
44
 
28
45
  constructor(
@@ -40,9 +57,10 @@ export class StateTracker extends Registrar {
40
57
  this.clearIdleTimer()
41
58
  this.idleTimer = setTimeout(() => {
42
59
  this.idleTimer = null
60
+ const wasRunning = this.running
43
61
  this.running = false
44
62
  void this.events.emit('idle')
45
- if (this.hasActivity && this.config.finished) {
63
+ if (wasRunning && this.config.finished) {
46
64
  this.notify('Idle')
47
65
  }
48
66
  }, IDLE_TIMEOUT_MS)
@@ -66,6 +84,7 @@ export class StateTracker extends Registrar {
66
84
  if (typeof message !== 'string' || message === '') continue
67
85
  const unsubscribe = this.pi.events.on(channel, () => {
68
86
  this.notify(message)
87
+ this.running = false
69
88
  void this.events.emit('event', channel)
70
89
  })
71
90
  this.unsubscribes.push(unsubscribe)
@@ -73,15 +92,25 @@ export class StateTracker extends Registrar {
73
92
 
74
93
  const customEventUnsub = this.pi.events.on(PI_NOTIFY_EVENT, (payload) => {
75
94
  this.notify(String(payload))
95
+ this.running = false
76
96
  void this.events.emit('event', PI_NOTIFY_EVENT)
77
97
  })
78
98
  this.unsubscribes.push(customEventUnsub)
79
99
  }
80
100
 
101
+ private setupUiPrompt() {
102
+ this.pi.on('ui_prompt_start', (event) => {
103
+ this.notify(promptMessage(event.kind, event.title))
104
+ this.running = false
105
+ void this.events.emit('ui_prompt', event.kind)
106
+ })
107
+ }
108
+
81
109
  private setupToolCall() {
82
110
  this.pi.on('tool_call', (event) => {
83
111
  if (this.config.notifyTools.has(event.toolName)) {
84
112
  this.notify(`Tool call: ${event.toolName}`)
113
+ this.running = false
85
114
  void this.events.emit('tool', event.toolName)
86
115
  }
87
116
  this.clearIdleTimer()
@@ -92,15 +121,16 @@ export class StateTracker extends Registrar {
92
121
  this.notify = notify
93
122
 
94
123
  this.setupPiEvents()
124
+ this.setupUiPrompt()
95
125
  this.setupToolCall()
96
126
 
97
127
  this.pi.on('turn_start', () => {
98
- this.hasActivity = true
99
128
  this.markRunning()
100
129
  this.clearIdleTimer()
101
130
  })
102
131
 
103
132
  this.pi.on('message_start', () => {
133
+ this.markRunning()
104
134
  this.clearIdleTimer()
105
135
  })
106
136
 
@@ -109,6 +139,10 @@ export class StateTracker extends Registrar {
109
139
  })
110
140
 
111
141
  this.unsubscribes.push(
142
+ this.jobTracker.onStart(() => {
143
+ this.markRunning()
144
+ this.clearIdleTimer()
145
+ }),
112
146
  this.jobTracker.onEnd(() => {
113
147
  this.startIdleTimer()
114
148
  }),
@@ -117,7 +151,7 @@ export class StateTracker extends Registrar {
117
151
 
118
152
  override stop(): void {
119
153
  super.stop()
154
+ this.running = false
120
155
  this.clearIdleTimer()
121
- this.hasActivity = false
122
156
  }
123
157
  }