@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 +12 -9
- package/package.json +4 -3
- package/src/config.ts +2 -15
- package/src/dashboard/session-store.test.ts +57 -15
- package/src/dashboard/session-store.ts +3 -0
- package/src/dashboard/state-store.ts +11 -3
- package/src/dashboard/ui/dashboard.test.ts +248 -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 +137 -13
- package/src/state-tracker.ts +39 -5
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,
|
|
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": [
|
|
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
|
-
"
|
|
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
|
|
46
|
-
| ----------------- |
|
|
47
|
-
| **Finished** | `agent_settled` (pi idle, no active jobs)
|
|
48
|
-
| **Tool calls** | `tool_call` on tools in `notifyTools`
|
|
49
|
-
| **
|
|
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.
|
|
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.
|
|
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 ??
|
|
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(
|
|
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('
|
|
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(['
|
|
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
|
-
|
|
378
|
+
pi.emitEvent('permissions:ui_prompt', {})
|
|
379
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
377
380
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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('
|
|
421
|
+
expect(record?.state).toBe('ui_prompt:confirm')
|
|
387
422
|
})
|
|
388
423
|
|
|
389
|
-
it('
|
|
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(['
|
|
401
|
-
events: {
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
|
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('[
|
|
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('[
|
|
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
|
|
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> {
|
|
@@ -71,7 +89,7 @@ const BASE_CONFIG: ResolvedNotifyConfig = {
|
|
|
71
89
|
enabled: true,
|
|
72
90
|
notifyTools: new Set(['bash', 'read']),
|
|
73
91
|
events: {
|
|
74
|
-
'
|
|
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
|
-
|
|
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: '
|
|
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('
|
|
236
|
+
pi.emitEvent('my:custom:event', {})
|
|
192
237
|
pi.emitEvent('disabled:channel', {})
|
|
193
238
|
|
|
194
239
|
await flush()
|
|
195
240
|
|
|
196
|
-
expect(events).toEqual(['
|
|
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('
|
|
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('
|
|
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)
|
package/src/state-tracker.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
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 (
|
|
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
|
}
|