@raidou/pi-notify 0.3.1 → 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/src/tmux-title.ts CHANGED
@@ -4,6 +4,8 @@ import { readlinkSync } from 'node:fs'
4
4
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
5
 
6
6
  import type { ResolvedNotifyConfig } from './config.js'
7
+ import { Registrar } from './shared/registrar.js'
8
+ import type { NotifyAction } from './shared/types.js'
7
9
 
8
10
  const WINDOW_ID_FORMAT = '#{window_id}'
9
11
  const WINDOW_NAME_FORMAT = '#{window_name}'
@@ -11,22 +13,24 @@ const LIST_FORMAT = '#{pane_tty}\t#{window_id}'
11
13
  const NEWLINE = '\n'
12
14
  const TAB = '\t'
13
15
 
14
- export class TmuxTitleTracker {
16
+ export class TmuxTitleTracker extends Registrar {
17
+ private readonly config: ResolvedNotifyConfig
15
18
  private windowId: string | undefined
16
19
  private originalTitle: string | undefined
17
20
  private autoRename: boolean | undefined
18
21
  private modified = false
19
22
 
20
- constructor(
21
- private readonly pi: ExtensionAPI,
22
- private readonly config: ResolvedNotifyConfig,
23
- ) {}
23
+ constructor(pi: ExtensionAPI, config: ResolvedNotifyConfig) {
24
+ super(pi)
25
+ this.config = config
26
+ }
24
27
 
25
28
  get enabled(): boolean {
26
29
  return this.config.tmuxSymbol.length > 0
27
30
  }
28
31
 
29
- register(): void {
32
+ protected override setup(notify: NotifyAction): void {
33
+ void notify
30
34
  this.pi.on('session_start', (_event, ctx) => {
31
35
  if (ctx.mode !== 'tui' || !this.enabled) return
32
36
  this.restore()
@@ -36,9 +40,6 @@ export class TmuxTitleTracker {
36
40
  this.originalTitle = this.queryWindowName(id)
37
41
  this.autoRename = this.queryAutomaticRename(id)
38
42
  })
39
- this.pi.on('session_shutdown', () => {
40
- this.stop()
41
- })
42
43
  }
43
44
 
44
45
  mark(): void {
@@ -72,7 +73,8 @@ export class TmuxTitleTracker {
72
73
  this.modified = false
73
74
  }
74
75
 
75
- stop(): void {
76
+ override stop(): void {
77
+ super.stop()
76
78
  this.restore()
77
79
  this.windowId = undefined
78
80
  this.originalTitle = undefined
@@ -1,174 +0,0 @@
1
- import { basename } from 'node:path'
2
-
3
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
4
-
5
- import { type SessionRecord, updateState } from './state-store.js'
6
-
7
- const IDLE_TIMEOUT_MS = 10000
8
- const HEARTBEAT_INTERVAL_MS = 5000
9
-
10
- export class StateTracker {
11
- private registered = false
12
- private heartbeatTimer: NodeJS.Timeout | null = null
13
- private idleTimer: NodeJS.Timeout | null = null
14
- private sessionId: string | undefined = undefined
15
- private cwd: string | undefined = undefined
16
- private projectName: string | undefined = undefined
17
- private model: string | undefined = undefined
18
-
19
- private get cwdOrCwd(): string {
20
- return this.cwd ?? process.cwd()
21
- }
22
-
23
- private get projectNameOrCwd(): string {
24
- return this.projectName ?? basename(process.cwd())
25
- }
26
-
27
- constructor(private readonly pi: ExtensionAPI) {}
28
-
29
- private updateSessionRecord(updates: Partial<SessionRecord>): void {
30
- if (!this.sessionId) return
31
- const now = Date.now()
32
- const id = this.sessionId
33
-
34
- void updateState((state) => {
35
- const existing = state.sessions[id]
36
- const stateChanged =
37
- updates.state !== undefined && updates.state !== existing?.state
38
-
39
- const record: SessionRecord = {
40
- pid: process.pid,
41
- cwd: this.cwdOrCwd,
42
- projectName: this.projectNameOrCwd,
43
- lastHeartbeatAt: now,
44
- startedAt: existing?.startedAt ?? now,
45
- state: updates.state ?? existing?.state ?? 'running',
46
- stateChangedAt: stateChanged ? now : (existing?.stateChangedAt ?? now),
47
- model: this.model ?? existing?.model,
48
- lastEvent: updates.lastEvent ?? existing?.lastEvent,
49
- }
50
-
51
- return {
52
- ...state,
53
- sessions: { ...state.sessions, [id]: record },
54
- }
55
- })
56
- }
57
-
58
- private removeSession(): void {
59
- if (!this.sessionId) return
60
- const id = this.sessionId
61
-
62
- void updateState((state) => {
63
- const sessions: Record<string, SessionRecord | undefined> = {}
64
- for (const [key, value] of Object.entries(state.sessions)) {
65
- if (key !== id) sessions[key] = value
66
- }
67
- return { ...state, sessions }
68
- })
69
- }
70
-
71
- private clearIdleTimer(): void {
72
- if (this.idleTimer) {
73
- clearTimeout(this.idleTimer)
74
- this.idleTimer = null
75
- }
76
- }
77
-
78
- private startIdleTimer(): void {
79
- this.clearIdleTimer()
80
- this.idleTimer = setTimeout(() => {
81
- this.clearIdleTimer()
82
- this.updateSessionRecord({
83
- state: 'idle',
84
- lastEvent: { type: 'idle', summary: 'idle', at: Date.now() },
85
- })
86
- }, IDLE_TIMEOUT_MS)
87
- }
88
-
89
- register(): void {
90
- if (this.registered) return
91
- this.registered = true
92
-
93
- this.pi.on('session_start', (_event, ctx) => {
94
- this.sessionId = ctx.sessionManager.getSessionId()
95
- this.cwd = ctx.cwd
96
- this.projectName = basename(ctx.cwd)
97
- this.model = ctx.model?.id
98
-
99
- this.updateSessionRecord({
100
- lastEvent: {
101
- type: 'session_start',
102
- summary: 'started',
103
- at: Date.now(),
104
- },
105
- })
106
-
107
- this.heartbeatTimer = setInterval(() => {
108
- if (!this.sessionId) return
109
- const id = this.sessionId
110
-
111
- void updateState((state) => {
112
- const existing = state.sessions[id]
113
- if (!existing) return state
114
-
115
- return {
116
- ...state,
117
- sessions: {
118
- ...state.sessions,
119
- [id]: {
120
- ...existing,
121
- lastHeartbeatAt: Date.now(),
122
- },
123
- },
124
- }
125
- })
126
- }, HEARTBEAT_INTERVAL_MS)
127
- })
128
-
129
- this.pi.on('turn_start', () => {
130
- this.clearIdleTimer()
131
- this.updateSessionRecord({
132
- state: 'running',
133
- lastEvent: { type: 'turn_start', summary: 'running', at: Date.now() },
134
- })
135
- })
136
-
137
- this.pi.on('agent_start', () => {
138
- this.clearIdleTimer()
139
- this.updateSessionRecord({
140
- state: 'running',
141
- lastEvent: { type: 'agent_start', summary: 'running', at: Date.now() },
142
- })
143
- })
144
-
145
- this.pi.on('agent_settled', () => {
146
- this.startIdleTimer()
147
- })
148
-
149
- this.pi.on('tool_call', (event) => {
150
- this.updateSessionRecord({
151
- state: 'running',
152
- lastEvent: {
153
- type: 'tool_call',
154
- summary: `tool:${event.toolName}`,
155
- at: Date.now(),
156
- },
157
- })
158
- })
159
-
160
- this.pi.on('session_shutdown', () => {
161
- this.stop()
162
- this.removeSession()
163
- })
164
- }
165
-
166
- stop(): void {
167
- this.clearIdleTimer()
168
- if (this.heartbeatTimer) {
169
- clearInterval(this.heartbeatTimer)
170
- this.heartbeatTimer = null
171
- }
172
- this.registered = false
173
- }
174
- }
@@ -1,198 +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: 'PID', width: 8 },
16
- { name: 'STATE', width: 8 },
17
- { name: 'PROJECT', width: 15 },
18
- { name: 'UPTIME', width: 10 },
19
- { name: 'MODEL', width: 20 },
20
- { name: 'LAST EVENT', width: undefined },
21
- ] as const
22
-
23
- const [PID_COL, STATE_COL, PROJECT_COL, UPTIME_COL, MODEL_COL] = COLUMNS
24
-
25
- const FIXED_COLUMNS_WIDTH =
26
- PID_COL.width +
27
- STATE_COL.width +
28
- PROJECT_COL.width +
29
- UPTIME_COL.width +
30
- MODEL_COL.width
31
- const GAPS_WIDTH = (COLUMNS.length - 1) * 2
32
-
33
- const MAX_ROWS = Math.max(1, (process.stdout.rows || 20) - 6)
34
-
35
- export interface DashboardProps {
36
- tui: { requestRender: () => void }
37
- theme: Theme
38
- initialSessions: SessionRecord[]
39
- onRefresh: () => Promise<SessionRecord[]>
40
- onClose: () => void
41
- }
42
-
43
- function formatUptime(startedAt: number): string {
44
- const elapsed = Date.now() - startedAt
45
- const seconds = Math.floor(elapsed / 1000)
46
- const minutes = Math.floor(seconds / 60)
47
- const hours = Math.floor(minutes / 60)
48
-
49
- if (hours > 0) {
50
- const mins = minutes % 60
51
- return `${hours}h ${mins}m`
52
- }
53
- if (minutes > 0) {
54
- const secs = seconds % 60
55
- return `${minutes}m ${secs}s`
56
- }
57
- return `${seconds}s`
58
- }
59
-
60
- export function createDashboard(props: DashboardProps) {
61
- let sessions = [...props.initialSessions]
62
- let scrollOffset = 0
63
- let cachedWidth: number | null = null
64
- let cachedLines: string[] = []
65
-
66
- const dashboardContainer = new Container()
67
-
68
- function updateChildren(width: number): void {
69
- const remainingWidth = Math.max(1, width - FIXED_COLUMNS_WIDTH - GAPS_WIDTH)
70
- const { theme } = props
71
-
72
- const stats = sessions.reduce(
73
- (acc, s) => {
74
- acc[s.state]++
75
- return acc
76
- },
77
- { running: 0, idle: 0 },
78
- )
79
-
80
- const headerLine = COLUMNS.map((col) =>
81
- col.width === undefined ? col.name : col.name.padEnd(col.width),
82
- ).join(' ')
83
-
84
- dashboardContainer.clear()
85
- dashboardContainer.addChild(
86
- new Text(
87
- `${theme.fg('accent', theme.bold('pi sessions dashboard'))} ${theme.fg(
88
- 'dim',
89
- `total=${sessions.length} running=${stats.running} idle=${stats.idle}`,
90
- )}`,
91
- 0,
92
- 0,
93
- ),
94
- )
95
- dashboardContainer.addChild(new Spacer(1))
96
- dashboardContainer.addChild(
97
- new Text(theme.fg('borderAccent', headerLine), 0, 0),
98
- )
99
- dashboardContainer.addChild(
100
- new DynamicBorder((str) => theme.fg('borderAccent', str)),
101
- )
102
-
103
- for (const session of sessions.slice(
104
- scrollOffset,
105
- scrollOffset + MAX_ROWS,
106
- )) {
107
- const stateColor = session.state === 'running' ? 'success' : 'muted'
108
- const line = [
109
- theme.fg('dim', String(session.pid).padEnd(PID_COL.width)),
110
- theme.fg(stateColor, session.state.padEnd(STATE_COL.width)),
111
- theme.fg(
112
- 'text',
113
- truncateToWidth(session.projectName, PROJECT_COL.width, '…', true),
114
- ),
115
- theme.fg(
116
- 'dim',
117
- formatUptime(session.startedAt).padEnd(UPTIME_COL.width),
118
- ),
119
- theme.fg(
120
- 'muted',
121
- session.model
122
- ? truncateToWidth(session.model, MODEL_COL.width, '…', true)
123
- : '',
124
- ),
125
- theme.fg(
126
- 'dim',
127
- session.lastEvent
128
- ? truncateToWidth(
129
- `${session.lastEvent.type}:${session.lastEvent.summary}`,
130
- remainingWidth,
131
- )
132
- : '',
133
- ),
134
- ].join(' ')
135
- dashboardContainer.addChild(new Text(line, 0, 0))
136
- }
137
-
138
- dashboardContainer.addChild(new Spacer(1))
139
- dashboardContainer.addChild(
140
- new Text(theme.fg('dim', '↑↓ scroll • r refresh • q or esc close'), 0, 0),
141
- )
142
- }
143
-
144
- const component = {
145
- render(width: number): string[] {
146
- if (cachedWidth !== width) {
147
- updateChildren(width)
148
- cachedLines = dashboardContainer.render(width)
149
- cachedWidth = width
150
- }
151
- return cachedLines
152
- },
153
-
154
- handleInput(data: string): void {
155
- if (matchesKey(data, 'r')) {
156
- props
157
- .onRefresh()
158
- .then((newSessions) => {
159
- sessions = [...newSessions]
160
- scrollOffset = 0
161
- cachedWidth = null
162
- props.tui.requestRender()
163
- })
164
- .catch(() => {})
165
- return
166
- }
167
-
168
- if (matchesKey(data, 'q') || matchesKey(data, Key.escape)) {
169
- props.onClose()
170
- return
171
- }
172
-
173
- if (matchesKey(data, Key.up) || matchesKey(data, 'k')) {
174
- if (scrollOffset > 0) {
175
- scrollOffset--
176
- cachedWidth = null
177
- props.tui.requestRender()
178
- }
179
- return
180
- }
181
-
182
- if (matchesKey(data, Key.down) || matchesKey(data, 'j')) {
183
- if (scrollOffset + MAX_ROWS < sessions.length) {
184
- scrollOffset++
185
- cachedWidth = null
186
- props.tui.requestRender()
187
- }
188
- }
189
- },
190
-
191
- invalidate(): void {
192
- cachedWidth = null
193
- dashboardContainer.invalidate()
194
- },
195
- }
196
-
197
- return component
198
- }
package/src/events.ts DELETED
@@ -1,46 +0,0 @@
1
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
-
3
- import type { ResolvedNotifyConfig } from './config.js'
4
- import type { NotifyAction, Unsubscribe } from './types.js'
5
-
6
- export const PI_NOTIFY_EVENT = 'pi-notify:notify'
7
-
8
- export class EventsNotifier {
9
- private unsubscribes: Unsubscribe[] = []
10
- private registered = false
11
-
12
- constructor(
13
- private readonly pi: ExtensionAPI,
14
- private readonly config: ResolvedNotifyConfig,
15
- ) {}
16
-
17
- register(notify: NotifyAction): void {
18
- if (this.registered) return
19
- this.registered = true
20
-
21
- for (const [channel, message] of Object.entries(this.config.events)) {
22
- if (!message) continue
23
- const unsubscribe = this.pi.events.on(channel, () => {
24
- notify(message)
25
- })
26
- this.unsubscribes.push(unsubscribe)
27
- }
28
-
29
- const customEventUnsub = this.pi.events.on(PI_NOTIFY_EVENT, (payload) => {
30
- notify(String(payload))
31
- })
32
- this.unsubscribes.push(customEventUnsub)
33
-
34
- this.pi.on('session_shutdown', () => {
35
- this.stop()
36
- })
37
- }
38
-
39
- stop(): void {
40
- this.unsubscribes.forEach((unsub) => {
41
- unsub()
42
- })
43
- this.unsubscribes = []
44
- this.registered = false
45
- }
46
- }
package/src/idle.ts DELETED
@@ -1,52 +0,0 @@
1
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
-
3
- import type { ResolvedNotifyConfig } from './config.js'
4
- import type { JobTracker } from './jobs.js'
5
- import type { NotifyAction } from './types.js'
6
-
7
- const IDLE_TIMEOUT_MS = 10000
8
-
9
- export class IdleNotifier {
10
- private timer: NodeJS.Timeout | null = null
11
-
12
- constructor(
13
- private readonly pi: ExtensionAPI,
14
- private readonly config: ResolvedNotifyConfig,
15
- private readonly jobTracker: JobTracker,
16
- ) {}
17
-
18
- clearIdleTimer(): void {
19
- if (this.timer) {
20
- clearTimeout(this.timer)
21
- this.timer = null
22
- }
23
- }
24
- startIdleTimer(notify: NotifyAction): void {
25
- this.clearIdleTimer()
26
- this.timer = setTimeout(() => {
27
- this.clearIdleTimer()
28
- notify('Idle')
29
- }, IDLE_TIMEOUT_MS)
30
- }
31
-
32
- register(notify: NotifyAction): void {
33
- if (!this.config.finished) return
34
-
35
- this.pi.on('turn_start', () => {
36
- this.clearIdleTimer()
37
- })
38
-
39
- this.pi.on('message_start', () => {
40
- this.clearIdleTimer()
41
- })
42
-
43
- this.pi.on('agent_settled', () => {
44
- if (this.jobTracker.hasActiveJobs) return
45
- this.startIdleTimer(notify)
46
- })
47
-
48
- this.jobTracker.onEnd(() => {
49
- this.startIdleTimer(notify)
50
- })
51
- }
52
- }
package/src/tool.ts DELETED
@@ -1,18 +0,0 @@
1
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
-
3
- import type { ResolvedNotifyConfig } from './config.js'
4
- import type { NotifyAction } from './types.js'
5
-
6
- export class ToolCallNotifier {
7
- constructor(
8
- private readonly pi: ExtensionAPI,
9
- private readonly config: ResolvedNotifyConfig,
10
- ) {}
11
-
12
- register(notify: NotifyAction): void {
13
- this.pi.on('tool_call', (event) => {
14
- if (!this.config.notifyTools.has(event.toolName)) return
15
- notify(`Tool call: ${event.toolName}`)
16
- })
17
- }
18
- }
File without changes