@raidou/pi-notify 0.4.0 → 0.5.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.
@@ -1,19 +1,27 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
3
 
4
+ import type { ResolvedNotifyConfig } from './config.js'
4
5
  import type { JobTracker } from './jobs.js'
5
- import { StateTracker } from './state-tracker.js'
6
+ import { PI_NOTIFY_EVENT, StateTracker } from './state-tracker.js'
7
+
8
+ type EventsListener = (payload?: unknown) => void
6
9
 
7
10
  interface FakePi {
8
- listeners: Map<string, Set<() => void>>
9
- on(event: string, listener: () => void): void
10
- emit(event: string): void
11
+ listeners: Map<string, Set<(payload?: unknown) => void>>
12
+ eventListeners: Map<string, Set<EventsListener>>
13
+ on(event: string, listener: (payload?: unknown) => void): void
14
+ events: { on(event: string, listener: EventsListener): () => void }
15
+ emit(event: string, payload?: unknown): void
16
+ emitEvent(event: string, payload?: unknown): void
11
17
  }
12
18
 
13
19
  function makeFakePi(): FakePi {
14
- const listeners = new Map<string, Set<() => void>>()
20
+ const listeners = new Map<string, Set<(payload?: unknown) => void>>()
21
+ const eventListeners = new Map<string, Set<EventsListener>>()
15
22
  return {
16
23
  listeners,
24
+ eventListeners,
17
25
  on(event, listener) {
18
26
  let set = listeners.get(event)
19
27
  if (!set) {
@@ -22,10 +30,28 @@ function makeFakePi(): FakePi {
22
30
  }
23
31
  set.add(listener)
24
32
  },
25
- emit(event) {
33
+ events: {
34
+ on(event, listener) {
35
+ let set = eventListeners.get(event)
36
+ if (!set) {
37
+ set = new Set()
38
+ eventListeners.set(event, set)
39
+ }
40
+ set.add(listener)
41
+ return () => {
42
+ set.delete(listener)
43
+ }
44
+ },
45
+ },
46
+ emit(event, payload) {
26
47
  const set = listeners.get(event)
27
48
  if (!set) return
28
- for (const listener of [...set]) listener()
49
+ for (const listener of [...set]) listener(payload)
50
+ },
51
+ emitEvent(event, payload) {
52
+ const set = eventListeners.get(event)
53
+ if (!set) return
54
+ for (const listener of [...set]) listener(payload)
29
55
  },
30
56
  }
31
57
  }
@@ -41,23 +67,42 @@ async function flush(): Promise<void> {
41
67
  for (let i = 0; i < 5; i++) await Promise.resolve()
42
68
  }
43
69
 
44
- function makeTracker(pi: FakePi): {
70
+ const BASE_CONFIG: ResolvedNotifyConfig = {
71
+ enabled: true,
72
+ notifyTools: new Set(['bash', 'read']),
73
+ events: {
74
+ 'permissions:ui_prompt': 'msg',
75
+ 'disabled:channel': false,
76
+ },
77
+ finished: true,
78
+ onlyNotifyWhenUnfocused: true,
79
+ unfocusedActivityThresholdMs: 0,
80
+ tmuxSymbol: '',
81
+ } as unknown as ResolvedNotifyConfig
82
+
83
+ function makeTracker(
84
+ pi: FakePi,
85
+ config: ResolvedNotifyConfig = BASE_CONFIG,
86
+ ): {
45
87
  tracker: StateTracker
46
88
  states: string[]
89
+ bodies: string[]
47
90
  } {
48
91
  const states: string[] = []
92
+ const bodies: string[] = []
49
93
  const tracker = new StateTracker(
50
94
  pi as unknown as ExtensionAPI,
51
95
  makeFakeJobTracker(),
96
+ config,
52
97
  )
53
- tracker.register(() => {})
98
+ tracker.register((body) => bodies.push(body))
54
99
  tracker.events.on('running', () => {
55
100
  states.push('running')
56
101
  })
57
102
  tracker.events.on('idle', () => {
58
103
  states.push('idle')
59
104
  })
60
- return { tracker, states }
105
+ return { tracker, states, bodies }
61
106
  }
62
107
 
63
108
  describe('StateTracker', () => {
@@ -99,7 +144,11 @@ describe('StateTracker', () => {
99
144
 
100
145
  pi.emit('turn_start')
101
146
  pi.emit('message_start')
102
- pi.emit('tool_call')
147
+ pi.emit('tool_call', {
148
+ type: 'tool_call',
149
+ toolCallId: 't1',
150
+ toolName: 'read',
151
+ })
103
152
  pi.emit('turn_start')
104
153
 
105
154
  await flush()
@@ -107,6 +156,62 @@ describe('StateTracker', () => {
107
156
  expect(states).toEqual(['running'])
108
157
  })
109
158
 
159
+ it('emits tool only for tools in notifyTools', async () => {
160
+ const pi = makeFakePi()
161
+ const { tracker } = makeTracker(pi)
162
+ const tools: string[] = []
163
+ tracker.events.on('tool', (event) => {
164
+ tools.push(event.data)
165
+ })
166
+
167
+ pi.emit('tool_call', {
168
+ type: 'tool_call',
169
+ toolCallId: 't1',
170
+ toolName: 'bash',
171
+ })
172
+ pi.emit('tool_call', {
173
+ type: 'tool_call',
174
+ toolCallId: 't2',
175
+ toolName: 'grep',
176
+ })
177
+
178
+ await flush()
179
+
180
+ expect(tools).toEqual(['bash'])
181
+ })
182
+
183
+ it('emits event for configured channels and not for disabled ones', async () => {
184
+ const pi = makeFakePi()
185
+ const { tracker } = makeTracker(pi)
186
+ const events: string[] = []
187
+ tracker.events.on('event', (event) => {
188
+ events.push(event.data)
189
+ })
190
+
191
+ pi.emitEvent('permissions:ui_prompt', {})
192
+ pi.emitEvent('disabled:channel', {})
193
+
194
+ await flush()
195
+
196
+ expect(events).toEqual(['permissions:ui_prompt'])
197
+ })
198
+
199
+ it('unsubscribes from channel events on stop', async () => {
200
+ const pi = makeFakePi()
201
+ const { tracker } = makeTracker(pi)
202
+ const events: string[] = []
203
+ tracker.events.on('event', (event) => {
204
+ events.push(event.data)
205
+ })
206
+
207
+ tracker.stop()
208
+ pi.emitEvent('permissions:ui_prompt', {})
209
+
210
+ await flush()
211
+
212
+ expect(events).toEqual([])
213
+ })
214
+
110
215
  it('emits running again after becoming idle', async () => {
111
216
  const pi = makeFakePi()
112
217
  const { states } = makeTracker(pi)
@@ -121,6 +226,112 @@ describe('StateTracker', () => {
121
226
  expect(states).toEqual(['running', 'idle', 'running'])
122
227
  })
123
228
 
229
+ it('notifies with the configured message when a custom event fires', () => {
230
+ const pi = makeFakePi()
231
+ const { bodies } = makeTracker(pi)
232
+
233
+ pi.emitEvent('permissions:ui_prompt', {})
234
+
235
+ expect(bodies).toEqual(['msg'])
236
+ })
237
+
238
+ it('does not notify for events disabled with false', () => {
239
+ const pi = makeFakePi()
240
+ const { bodies } = makeTracker(pi)
241
+
242
+ pi.emitEvent('disabled:channel', {})
243
+
244
+ expect(bodies).toEqual([])
245
+ })
246
+
247
+ it('does not notify for events disabled with an empty string', () => {
248
+ const pi = makeFakePi()
249
+ const { bodies } = makeTracker(pi, {
250
+ ...BASE_CONFIG,
251
+ events: { 'my:custom:event': '' },
252
+ })
253
+
254
+ pi.emitEvent('my:custom:event', {})
255
+
256
+ expect(bodies).toEqual([])
257
+ })
258
+
259
+ it('notifies from the custom channel', () => {
260
+ const pi = makeFakePi()
261
+ const { bodies } = makeTracker(pi)
262
+
263
+ pi.emitEvent(PI_NOTIFY_EVENT, 'custom payload')
264
+
265
+ expect(bodies).toEqual(['custom payload'])
266
+ })
267
+
268
+ it('notifies for tools in notifyTools', () => {
269
+ const pi = makeFakePi()
270
+ const { bodies } = makeTracker(pi)
271
+
272
+ pi.emit('tool_call', {
273
+ type: 'tool_call',
274
+ toolCallId: 't1',
275
+ toolName: 'read',
276
+ })
277
+
278
+ expect(bodies).toEqual(['Tool call: read'])
279
+ })
280
+
281
+ it('does not notify for tools not in notifyTools', () => {
282
+ const pi = makeFakePi()
283
+ const { bodies } = makeTracker(pi)
284
+
285
+ pi.emit('tool_call', {
286
+ type: 'tool_call',
287
+ toolCallId: 't1',
288
+ toolName: 'grep',
289
+ })
290
+
291
+ expect(bodies).toEqual([])
292
+ })
293
+
294
+ it('notifies Idle on idle when there was activity', async () => {
295
+ const pi = makeFakePi()
296
+ const { bodies } = makeTracker(pi)
297
+
298
+ pi.emit('turn_start')
299
+ pi.emit('agent_settled')
300
+ vi.advanceTimersByTime(10000)
301
+
302
+ await flush()
303
+
304
+ expect(bodies).toEqual(['Idle'])
305
+ })
306
+
307
+ it('does not notify Idle on idle without activity', async () => {
308
+ const pi = makeFakePi()
309
+ const { bodies } = makeTracker(pi)
310
+
311
+ pi.emit('agent_settled')
312
+ vi.advanceTimersByTime(10000)
313
+
314
+ await flush()
315
+
316
+ expect(bodies).toEqual([])
317
+ })
318
+
319
+ it('does not notify Idle when finished is disabled', async () => {
320
+ const pi = makeFakePi()
321
+ const { bodies } = makeTracker(pi, {
322
+ ...BASE_CONFIG,
323
+ finished: false,
324
+ })
325
+
326
+ pi.emit('turn_start')
327
+ pi.emit('agent_settled')
328
+ vi.advanceTimersByTime(10000)
329
+
330
+ await flush()
331
+
332
+ expect(bodies).toEqual([])
333
+ })
334
+
124
335
  it('resets the idle timer on activity', async () => {
125
336
  const pi = makeFakePi()
126
337
  const { states } = makeTracker(pi)
@@ -128,7 +339,11 @@ describe('StateTracker', () => {
128
339
  pi.emit('turn_start')
129
340
  pi.emit('agent_settled')
130
341
  vi.advanceTimersByTime(9000)
131
- pi.emit('tool_call')
342
+ pi.emit('tool_call', {
343
+ type: 'tool_call',
344
+ toolCallId: 't2',
345
+ toolName: 'bash',
346
+ })
132
347
  vi.advanceTimersByTime(9000)
133
348
 
134
349
  await flush()
@@ -1,8 +1,12 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
2
  import Emittery from 'emittery'
3
3
 
4
+ import type { ResolvedNotifyConfig } from './config.js'
4
5
  import type { JobTracker } from './jobs.js'
5
6
  import { Registrar } from './shared/registrar.js'
7
+ import type { NotifyAction } from './shared/types.js'
8
+
9
+ export const PI_NOTIFY_EVENT = 'pi-notify:notify'
6
10
 
7
11
  const IDLE_TIMEOUT_MS = 10000
8
12
 
@@ -10,15 +14,25 @@ export class StateTracker extends Registrar {
10
14
  readonly events = new Emittery<{
11
15
  running: never
12
16
  idle: never
17
+ tool: string
18
+ event: string
13
19
  }>()
14
20
 
15
21
  private readonly jobTracker: JobTracker
22
+ private readonly config: ResolvedNotifyConfig
16
23
  private idleTimer: NodeJS.Timeout | null = null
17
24
  private running = false
25
+ private hasActivity = false
26
+ private notify: NotifyAction = () => {}
18
27
 
19
- constructor(pi: ExtensionAPI, jobTracker: JobTracker) {
28
+ constructor(
29
+ pi: ExtensionAPI,
30
+ jobTracker: JobTracker,
31
+ config: ResolvedNotifyConfig,
32
+ ) {
20
33
  super(pi)
21
34
  this.jobTracker = jobTracker
35
+ this.config = config
22
36
  }
23
37
 
24
38
  private startIdleTimer(): void {
@@ -28,6 +42,9 @@ export class StateTracker extends Registrar {
28
42
  this.idleTimer = null
29
43
  this.running = false
30
44
  void this.events.emit('idle')
45
+ if (this.hasActivity && this.config.finished) {
46
+ this.notify('Idle')
47
+ }
31
48
  }, IDLE_TIMEOUT_MS)
32
49
  }
33
50
 
@@ -44,8 +61,41 @@ export class StateTracker extends Registrar {
44
61
  void this.events.emit('running')
45
62
  }
46
63
 
47
- protected override setup(): void {
64
+ private setupPiEvents() {
65
+ for (const [channel, message] of Object.entries(this.config.events)) {
66
+ if (typeof message !== 'string' || message === '') continue
67
+ const unsubscribe = this.pi.events.on(channel, () => {
68
+ this.notify(message)
69
+ void this.events.emit('event', channel)
70
+ })
71
+ this.unsubscribes.push(unsubscribe)
72
+ }
73
+
74
+ const customEventUnsub = this.pi.events.on(PI_NOTIFY_EVENT, (payload) => {
75
+ this.notify(String(payload))
76
+ void this.events.emit('event', PI_NOTIFY_EVENT)
77
+ })
78
+ this.unsubscribes.push(customEventUnsub)
79
+ }
80
+
81
+ private setupToolCall() {
82
+ this.pi.on('tool_call', (event) => {
83
+ if (this.config.notifyTools.has(event.toolName)) {
84
+ this.notify(`Tool call: ${event.toolName}`)
85
+ void this.events.emit('tool', event.toolName)
86
+ }
87
+ this.clearIdleTimer()
88
+ })
89
+ }
90
+
91
+ protected override setup(notify: NotifyAction): void {
92
+ this.notify = notify
93
+
94
+ this.setupPiEvents()
95
+ this.setupToolCall()
96
+
48
97
  this.pi.on('turn_start', () => {
98
+ this.hasActivity = true
49
99
  this.markRunning()
50
100
  this.clearIdleTimer()
51
101
  })
@@ -58,10 +108,6 @@ export class StateTracker extends Registrar {
58
108
  this.startIdleTimer()
59
109
  })
60
110
 
61
- this.pi.on('tool_call', () => {
62
- this.clearIdleTimer()
63
- })
64
-
65
111
  this.unsubscribes.push(
66
112
  this.jobTracker.onEnd(() => {
67
113
  this.startIdleTimer()
@@ -72,5 +118,6 @@ export class StateTracker extends Registrar {
72
118
  override stop(): void {
73
119
  super.stop()
74
120
  this.clearIdleTimer()
121
+ this.hasActivity = false
75
122
  }
76
123
  }
@@ -1,193 +0,0 @@
1
- import type { Theme } from '@earendil-works/pi-coding-agent'
2
- import { DynamicBorder } from '@earendil-works/pi-coding-agent'
3
- import {
4
- Container,
5
- Key,
6
- matchesKey,
7
- Spacer,
8
- Text,
9
- truncateToWidth,
10
- } from '@earendil-works/pi-tui'
11
-
12
- import type { SessionRecord } from './state-store.js'
13
-
14
- const COLUMNS = [
15
- { name: 'SESSION_ID', width: 8 },
16
- { name: 'PID', width: 8 },
17
- { name: 'STATE', width: 8 },
18
- { name: 'PROJECT', width: 15 },
19
- { name: 'UPTIME', width: 10 },
20
- ] as const
21
-
22
- const [SESSION_ID_COL, PID_COL, STATE_COL, PROJECT_COL, UPTIME_COL] = COLUMNS
23
-
24
- const MAX_ROWS = Math.max(1, (process.stdout.rows || 20) - 6)
25
-
26
- export interface DashboardProps {
27
- tui: { requestRender: () => void }
28
- theme: Theme
29
- initialSessions: SessionRecord[]
30
- onRefresh: () => Promise<SessionRecord[]>
31
- onClose: () => void
32
- onDispose?: () => void
33
- }
34
-
35
- function formatUptime(startedAt: number): string {
36
- const elapsed = Date.now() - startedAt
37
- const seconds = Math.floor(elapsed / 1000)
38
- const minutes = Math.floor(seconds / 60)
39
- const hours = Math.floor(minutes / 60)
40
-
41
- if (hours > 0) {
42
- const mins = minutes % 60
43
- return `${hours}h ${mins}m`
44
- }
45
- if (minutes > 0) {
46
- const secs = seconds % 60
47
- return `${minutes}m ${secs}s`
48
- }
49
- return `${seconds}s`
50
- }
51
-
52
- export function createDashboard(props: DashboardProps) {
53
- let sessions = [...props.initialSessions]
54
- let scrollOffset = 0
55
- let cachedWidth: number | null = null
56
- let cachedLines: string[] = []
57
- let disposed = false
58
-
59
- const dashboardContainer = new Container()
60
-
61
- function refresh(): void {
62
- props
63
- .onRefresh()
64
- .then((newSessions) => {
65
- sessions = [...newSessions]
66
- scrollOffset = 0
67
- cachedWidth = null
68
- props.tui.requestRender()
69
- })
70
- .catch(() => {})
71
- }
72
-
73
- const timer = setInterval(() => {
74
- if (!disposed) refresh()
75
- }, 1000)
76
-
77
- function updateChildren(): void {
78
- const { theme } = props
79
-
80
- const stats = sessions.reduce(
81
- (acc, s) => {
82
- acc[s.state]++
83
- return acc
84
- },
85
- { running: 0, idle: 0 },
86
- )
87
-
88
- const headerLine = COLUMNS.map((col) => col.name.padEnd(col.width)).join(
89
- ' ',
90
- )
91
-
92
- dashboardContainer.clear()
93
- dashboardContainer.addChild(
94
- new Text(
95
- `${theme.fg('accent', theme.bold('pi sessions dashboard'))} ${theme.fg(
96
- 'dim',
97
- `total=${sessions.length} running=${stats.running} idle=${stats.idle}`,
98
- )}`,
99
- 0,
100
- 0,
101
- ),
102
- )
103
- dashboardContainer.addChild(new Spacer(1))
104
- dashboardContainer.addChild(
105
- new Text(theme.fg('borderAccent', headerLine), 0, 0),
106
- )
107
- dashboardContainer.addChild(
108
- new DynamicBorder((str) => theme.fg('borderAccent', str)),
109
- )
110
-
111
- for (const session of sessions.slice(
112
- scrollOffset,
113
- scrollOffset + MAX_ROWS,
114
- )) {
115
- const stateColor = session.state === 'running' ? 'success' : 'muted'
116
- const line = [
117
- theme.fg(
118
- 'dim',
119
- session.sessionId.slice(-6).padEnd(SESSION_ID_COL.width),
120
- ),
121
- theme.fg('dim', String(session.pid).padEnd(PID_COL.width)),
122
- theme.fg(stateColor, session.state.padEnd(STATE_COL.width)),
123
- theme.fg(
124
- 'text',
125
- truncateToWidth(session.projectName, PROJECT_COL.width, '…', true),
126
- ),
127
- theme.fg(
128
- 'dim',
129
- formatUptime(session.startedAt).padEnd(UPTIME_COL.width),
130
- ),
131
- ].join(' ')
132
- dashboardContainer.addChild(new Text(line, 0, 0))
133
- }
134
-
135
- dashboardContainer.addChild(new Spacer(1))
136
- dashboardContainer.addChild(
137
- new Text(theme.fg('dim', '↑↓ scroll • r refresh • q or esc close'), 0, 0),
138
- )
139
- }
140
-
141
- const component = {
142
- render(width: number): string[] {
143
- if (cachedWidth !== width) {
144
- updateChildren()
145
- cachedLines = dashboardContainer.render(width)
146
- cachedWidth = width
147
- }
148
- return cachedLines
149
- },
150
-
151
- handleInput(data: string): void {
152
- if (matchesKey(data, 'r')) {
153
- refresh()
154
- return
155
- }
156
-
157
- if (matchesKey(data, 'q') || matchesKey(data, Key.escape)) {
158
- props.onClose()
159
- return
160
- }
161
-
162
- if (matchesKey(data, Key.up) || matchesKey(data, 'k')) {
163
- if (scrollOffset > 0) {
164
- scrollOffset--
165
- cachedWidth = null
166
- props.tui.requestRender()
167
- }
168
- return
169
- }
170
-
171
- if (matchesKey(data, Key.down) || matchesKey(data, 'j')) {
172
- if (scrollOffset + MAX_ROWS < sessions.length) {
173
- scrollOffset++
174
- cachedWidth = null
175
- props.tui.requestRender()
176
- }
177
- }
178
- },
179
-
180
- invalidate(): void {
181
- cachedWidth = null
182
- dashboardContainer.invalidate()
183
- },
184
-
185
- dispose(): void {
186
- disposed = true
187
- clearInterval(timer)
188
- props.onDispose?.()
189
- },
190
- }
191
-
192
- return component
193
- }