@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.
- package/README.md +15 -1
- package/package.json +3 -1
- package/src/config.ts +0 -3
- package/src/dashboard/command.ts +3 -8
- package/src/dashboard/consts.ts +6 -0
- package/src/dashboard/session-store.test.ts +156 -8
- package/src/dashboard/session-store.ts +16 -12
- package/src/dashboard/state-store.test.ts +89 -17
- package/src/dashboard/state-store.ts +88 -32
- package/src/dashboard/ui/columns.ts +102 -0
- package/src/dashboard/ui/dashboard.test.ts +118 -0
- package/src/dashboard/ui/dashboard.ts +122 -0
- package/src/index.ts +2 -11
- package/src/state-tracker.test.ts +227 -12
- package/src/state-tracker.ts +53 -6
- package/src/dashboard/ui.ts +0 -193
- package/src/events.test.ts +0 -115
- package/src/events.ts +0 -31
- package/src/idle.ts +0 -37
- package/src/tool.ts +0 -21
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
package/src/state-tracker.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
}
|
package/src/dashboard/ui.ts
DELETED
|
@@ -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
|
-
}
|