@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/README.md +33 -33
- package/package.json +13 -6
- package/src/config.ts +2 -4
- package/src/dashboard/command.ts +11 -14
- package/src/dashboard/consts.ts +6 -0
- package/src/dashboard/session-store.test.ts +438 -0
- package/src/dashboard/session-store.ts +87 -0
- package/src/dashboard/state-store.test.ts +303 -0
- package/src/dashboard/state-store.ts +124 -99
- 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/focus.ts +25 -25
- package/src/index.ts +16 -18
- package/src/jobs.ts +5 -22
- package/src/notify-test.ts +12 -8
- package/src/shared/registrar.test.ts +137 -0
- package/src/shared/registrar.ts +30 -0
- package/src/{types.ts → shared/types.ts} +4 -0
- package/src/state-tracker.test.ts +360 -0
- package/src/state-tracker.ts +123 -0
- package/src/states.ts +3 -1
- package/src/tmux-title.ts +12 -10
- package/src/dashboard/state-tracker.ts +0 -174
- package/src/dashboard/ui.ts +0 -198
- package/src/events.ts +0 -46
- package/src/idle.ts +0 -52
- package/src/tool.ts +0 -18
- /package/src/{utils.ts → shared/utils.ts} +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { truncateToWidth } from '@earendil-works/pi-tui'
|
|
3
|
+
import { sumBy } from 'lodash-es'
|
|
4
|
+
|
|
5
|
+
import type { SessionRecord } from '../state-store.js'
|
|
6
|
+
|
|
7
|
+
interface Column {
|
|
8
|
+
name: string
|
|
9
|
+
width?: number
|
|
10
|
+
hiddenByDefault?: boolean
|
|
11
|
+
render: (session: SessionRecord, theme: Theme, width: number) => string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const COLUMNS: Column[] = [
|
|
15
|
+
{
|
|
16
|
+
name: 'SESSION_ID',
|
|
17
|
+
width: 10,
|
|
18
|
+
hiddenByDefault: true,
|
|
19
|
+
render: (session, theme, width) =>
|
|
20
|
+
theme.fg('dim', session.sessionId.slice(-6).padEnd(width)),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'PID',
|
|
24
|
+
width: 8,
|
|
25
|
+
hiddenByDefault: true,
|
|
26
|
+
render: (session, theme, width) =>
|
|
27
|
+
theme.fg('dim', String(session.pid).padEnd(width)),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'STATE',
|
|
31
|
+
width: 20,
|
|
32
|
+
render: (session, theme, width) => {
|
|
33
|
+
const color = session.state === 'running' ? 'success' : 'muted'
|
|
34
|
+
return theme.fg(
|
|
35
|
+
color,
|
|
36
|
+
truncateToWidth(session.state, width, '…', true).padEnd(width),
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'PROJECT',
|
|
42
|
+
render: (session, theme, width) =>
|
|
43
|
+
theme.fg('text', truncateToWidth(session.projectName, width, '…', true)),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'RUNNING',
|
|
47
|
+
width: 10,
|
|
48
|
+
render: (session, theme, width) => {
|
|
49
|
+
const startedRunningAt = session.startedRunningAt
|
|
50
|
+
if (!startedRunningAt) return ''
|
|
51
|
+
const duration = Date.now() - startedRunningAt
|
|
52
|
+
return theme.fg('dim', formatDuration(duration).padEnd(width))
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'UPTIME',
|
|
57
|
+
width: 10,
|
|
58
|
+
render: (session, theme, width) =>
|
|
59
|
+
theme.fg(
|
|
60
|
+
'dim',
|
|
61
|
+
formatDuration(Date.now() - session.startedAt).padEnd(width),
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
export const COLUMN_SEPARATOR = ' '
|
|
67
|
+
const MIN_PROJECT_WIDTH = 10
|
|
68
|
+
|
|
69
|
+
export interface ResolvedColumn {
|
|
70
|
+
col: Column
|
|
71
|
+
width: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveColumns(
|
|
75
|
+
totalWidth: number,
|
|
76
|
+
includeHidden = true,
|
|
77
|
+
): ResolvedColumn[] {
|
|
78
|
+
const columns = COLUMNS.filter((col) => includeHidden || !col.hiddenByDefault)
|
|
79
|
+
const fixedWidth = sumBy(columns, (col) => col.width ?? 0)
|
|
80
|
+
const separatorWidth = COLUMN_SEPARATOR.length * (columns.length - 1)
|
|
81
|
+
const flexibleWidth = Math.max(
|
|
82
|
+
MIN_PROJECT_WIDTH,
|
|
83
|
+
totalWidth - fixedWidth - separatorWidth,
|
|
84
|
+
)
|
|
85
|
+
return columns.map((col) => ({ col, width: col.width ?? flexibleWidth }))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatDuration(milliseconds: number): string {
|
|
89
|
+
const seconds = Math.floor(milliseconds / 1000)
|
|
90
|
+
const minutes = Math.floor(seconds / 60)
|
|
91
|
+
const hours = Math.floor(minutes / 60)
|
|
92
|
+
|
|
93
|
+
if (hours > 0) {
|
|
94
|
+
const mins = minutes % 60
|
|
95
|
+
return `${hours}h${mins}m`
|
|
96
|
+
}
|
|
97
|
+
if (minutes > 0) {
|
|
98
|
+
const secs = seconds % 60
|
|
99
|
+
return `${minutes}m${secs}s`
|
|
100
|
+
}
|
|
101
|
+
return `${seconds}s`
|
|
102
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Theme, ThemeColor } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { visibleWidth } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import type { SessionRecord } from '../state-store.js'
|
|
6
|
+
import { Dashboard } from './dashboard.js'
|
|
7
|
+
|
|
8
|
+
const theme = {
|
|
9
|
+
fg: (color: ThemeColor, text: string) => `\x1b[90m${text}\x1b[0m`,
|
|
10
|
+
bold: (text: string) => `\x1b[1m${text}\x1b[0m`,
|
|
11
|
+
} as unknown as Theme
|
|
12
|
+
|
|
13
|
+
const ANSI_RE =
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
/\[[0-9;?]*[ -/]*[@-~]/g
|
|
16
|
+
const stripAnsi = (line: string): string => line.replace(ANSI_RE, '')
|
|
17
|
+
|
|
18
|
+
function makeSession(overrides: Partial<SessionRecord>): SessionRecord {
|
|
19
|
+
return {
|
|
20
|
+
pid: 123,
|
|
21
|
+
sessionId: 'abc123',
|
|
22
|
+
cwd: '/tmp',
|
|
23
|
+
projectName: 'proj',
|
|
24
|
+
startedAt: Date.now(),
|
|
25
|
+
state: 'idle',
|
|
26
|
+
...overrides,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeDashboard(sessions: SessionRecord[]) {
|
|
31
|
+
return new Dashboard({
|
|
32
|
+
tui: { requestRender: () => {} },
|
|
33
|
+
theme,
|
|
34
|
+
initialSessions: sessions,
|
|
35
|
+
onRefresh: async () => sessions,
|
|
36
|
+
onClose: () => {},
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Dashboard render clipping', () => {
|
|
41
|
+
const longNameSession = makeSession({
|
|
42
|
+
projectName: 'a-very-long-project-name-that-exceeds-any-narrow-width',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('clips every line to the terminal width at narrow widths', () => {
|
|
46
|
+
const dashboard = makeDashboard([longNameSession])
|
|
47
|
+
for (const line of dashboard.render(40)) {
|
|
48
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(40)
|
|
49
|
+
}
|
|
50
|
+
dashboard.dispose()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does not add ellipsis or padding at wide widths', () => {
|
|
54
|
+
const dashboard = makeDashboard([longNameSession])
|
|
55
|
+
for (const line of dashboard.render(200)) {
|
|
56
|
+
expect(line).not.toContain('…')
|
|
57
|
+
expect(line.endsWith(' ')).toBe(false)
|
|
58
|
+
}
|
|
59
|
+
dashboard.dispose()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('clips the header and footer hint lines at width 20', () => {
|
|
63
|
+
const dashboard = makeDashboard([makeSession({})])
|
|
64
|
+
const lines = dashboard.render(20)
|
|
65
|
+
const visible = lines.map(stripAnsi)
|
|
66
|
+
const header = visible.find((l) => l.includes('STATE'))
|
|
67
|
+
expect(header).toBeDefined()
|
|
68
|
+
const footer = visible.find((l) => l.startsWith('o ids'))
|
|
69
|
+
expect(footer).toBeDefined()
|
|
70
|
+
if (!header || !footer) throw new Error('unreachable')
|
|
71
|
+
expect(header.startsWith('STATE')).toBe(true)
|
|
72
|
+
expect(header.endsWith('…')).toBe(true)
|
|
73
|
+
expect(footer.startsWith('o ids • r refresh •…')).toBe(true)
|
|
74
|
+
for (const line of visible) {
|
|
75
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(20)
|
|
76
|
+
}
|
|
77
|
+
dashboard.dispose()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('counts CJK project names by display width', () => {
|
|
81
|
+
const dashboard = makeDashboard([
|
|
82
|
+
makeSession({ projectName: '中文项目名' }),
|
|
83
|
+
])
|
|
84
|
+
for (const line of dashboard.render(40)) {
|
|
85
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(40)
|
|
86
|
+
}
|
|
87
|
+
dashboard.dispose()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('hidden column toggle', () => {
|
|
92
|
+
it('hides hidden columns by default and toggles with o', () => {
|
|
93
|
+
const dashboard = makeDashboard([makeSession({})])
|
|
94
|
+
try {
|
|
95
|
+
const headerOf = () => {
|
|
96
|
+
const lines = dashboard.render(200).map(stripAnsi)
|
|
97
|
+
return lines.find((l) => l.includes('STATE') && l.includes('RUNNING'))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let header = headerOf()
|
|
101
|
+
expect(header).toContain('STATE')
|
|
102
|
+
expect(header).not.toContain('SESSION_ID')
|
|
103
|
+
expect(header).not.toContain('PID')
|
|
104
|
+
|
|
105
|
+
dashboard.handleInput('o')
|
|
106
|
+
header = headerOf()
|
|
107
|
+
expect(header).toContain('SESSION_ID')
|
|
108
|
+
expect(header).toContain('PID')
|
|
109
|
+
|
|
110
|
+
dashboard.handleInput('o')
|
|
111
|
+
header = headerOf()
|
|
112
|
+
expect(header).not.toContain('SESSION_ID')
|
|
113
|
+
expect(header).not.toContain('PID')
|
|
114
|
+
} finally {
|
|
115
|
+
dashboard.dispose()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { Component } from '@earendil-works/pi-tui'
|
|
3
|
+
import { Key, matchesKey, truncateToWidth } from '@earendil-works/pi-tui'
|
|
4
|
+
|
|
5
|
+
import type { SessionRecord } from '../state-store.js'
|
|
6
|
+
import {
|
|
7
|
+
COLUMN_SEPARATOR,
|
|
8
|
+
resolveColumns,
|
|
9
|
+
type ResolvedColumn,
|
|
10
|
+
} from './columns.js'
|
|
11
|
+
|
|
12
|
+
export interface DashboardProps {
|
|
13
|
+
tui: { requestRender: () => void }
|
|
14
|
+
theme: Theme
|
|
15
|
+
initialSessions: SessionRecord[]
|
|
16
|
+
onRefresh: () => Promise<SessionRecord[]>
|
|
17
|
+
onClose: () => void
|
|
18
|
+
onDispose?: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Dashboard implements Component {
|
|
22
|
+
private readonly tui: DashboardProps['tui']
|
|
23
|
+
private readonly theme: Theme
|
|
24
|
+
private readonly onRefresh: DashboardProps['onRefresh']
|
|
25
|
+
private readonly onClose: DashboardProps['onClose']
|
|
26
|
+
private readonly onDispose: DashboardProps['onDispose']
|
|
27
|
+
private sessions: SessionRecord[]
|
|
28
|
+
private cachedWidth: number | null = null
|
|
29
|
+
private cachedLines: string[] = []
|
|
30
|
+
private disposed = false
|
|
31
|
+
private showHidden = false
|
|
32
|
+
private timer: NodeJS.Timeout
|
|
33
|
+
|
|
34
|
+
constructor({
|
|
35
|
+
tui,
|
|
36
|
+
theme,
|
|
37
|
+
initialSessions,
|
|
38
|
+
onRefresh,
|
|
39
|
+
onClose,
|
|
40
|
+
onDispose,
|
|
41
|
+
}: DashboardProps) {
|
|
42
|
+
this.tui = tui
|
|
43
|
+
this.theme = theme
|
|
44
|
+
this.onRefresh = onRefresh
|
|
45
|
+
this.onClose = onClose
|
|
46
|
+
this.onDispose = onDispose
|
|
47
|
+
this.sessions = [...initialSessions]
|
|
48
|
+
|
|
49
|
+
// FIXME: 改成用 fs.watch
|
|
50
|
+
this.timer = setInterval(() => {
|
|
51
|
+
if (!this.disposed) this.refresh()
|
|
52
|
+
}, 10000)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private forceRender(): void {
|
|
56
|
+
this.cachedWidth = null
|
|
57
|
+
this.tui.requestRender()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private refresh(): void {
|
|
61
|
+
this.onRefresh()
|
|
62
|
+
.then((newSessions) => {
|
|
63
|
+
this.sessions = [...newSessions]
|
|
64
|
+
this.forceRender()
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private headerLine(columns: ResolvedColumn[]): string {
|
|
70
|
+
return columns
|
|
71
|
+
.map(({ col, width }) => col.name.padEnd(width))
|
|
72
|
+
.join(COLUMN_SEPARATOR)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
render(width: number): string[] {
|
|
76
|
+
if (this.cachedWidth === width) {
|
|
77
|
+
return this.cachedLines
|
|
78
|
+
}
|
|
79
|
+
this.cachedWidth = width
|
|
80
|
+
const columns = resolveColumns(width, this.showHidden)
|
|
81
|
+
const rows = [
|
|
82
|
+
this.theme.fg('borderAccent', this.headerLine(columns)),
|
|
83
|
+
this.theme.fg('borderAccent', '─'.repeat(Math.max(1, width))),
|
|
84
|
+
...this.sessions.map((session) =>
|
|
85
|
+
columns
|
|
86
|
+
.map(({ col, width }) => col.render(session, this.theme, width))
|
|
87
|
+
.join(COLUMN_SEPARATOR),
|
|
88
|
+
),
|
|
89
|
+
'',
|
|
90
|
+
this.theme.fg('dim', 'o ids • r refresh • q or esc close'),
|
|
91
|
+
]
|
|
92
|
+
this.cachedLines = rows.map((line) => truncateToWidth(line, width, '…'))
|
|
93
|
+
return this.cachedLines
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
handleInput(data: string): void {
|
|
97
|
+
if (matchesKey(data, 'r')) {
|
|
98
|
+
this.refresh()
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (matchesKey(data, 'q') || matchesKey(data, Key.escape)) {
|
|
103
|
+
this.onClose()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (matchesKey(data, 'o')) {
|
|
108
|
+
this.showHidden = !this.showHidden
|
|
109
|
+
this.forceRender()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
invalidate(): void {
|
|
114
|
+
this.cachedWidth = null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
dispose(): void {
|
|
118
|
+
this.disposed = true
|
|
119
|
+
clearInterval(this.timer)
|
|
120
|
+
this.onDispose?.()
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/focus.ts
CHANGED
|
@@ -1,36 +1,39 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import type { ResolvedNotifyConfig } from './config.js'
|
|
4
|
+
import { Registrar } from './shared/registrar.js'
|
|
4
5
|
import type { TmuxTitleTracker } from './tmux-title.js'
|
|
5
|
-
import type { Unsubscribe } from './types.js'
|
|
6
6
|
|
|
7
|
-
// xterm focus reporting (CSI ?1004): emitted by the terminal on focus gain/loss.
|
|
8
7
|
const FOCUS_IN = '\x1b[I'
|
|
9
8
|
const FOCUS_OUT = '\x1b[O'
|
|
10
9
|
const ENABLE_FOCUS_REPORTING = '\x1b[?1004h'
|
|
11
10
|
const DISABLE_FOCUS_REPORTING = '\x1b[?1004l'
|
|
12
11
|
|
|
13
|
-
export class FocusTracker {
|
|
12
|
+
export class FocusTracker extends Registrar {
|
|
13
|
+
private readonly titleTracker: TmuxTitleTracker
|
|
14
|
+
private readonly config: ResolvedNotifyConfig
|
|
14
15
|
private _focused: boolean | undefined = undefined
|
|
15
16
|
private _lastActivityAt = Date.now()
|
|
16
|
-
private unsubscribe: Unsubscribe | undefined
|
|
17
17
|
|
|
18
18
|
constructor(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
) {
|
|
19
|
+
pi: ExtensionAPI,
|
|
20
|
+
titleTracker: TmuxTitleTracker,
|
|
21
|
+
config: ResolvedNotifyConfig,
|
|
22
|
+
) {
|
|
23
|
+
super(pi)
|
|
24
|
+
this.titleTracker = titleTracker
|
|
25
|
+
this.config = config
|
|
26
|
+
}
|
|
23
27
|
|
|
24
28
|
get isFocused(): boolean | undefined {
|
|
25
29
|
return this._focused
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
/** Timestamp of the last observed terminal input (fallback focus signal). */
|
|
29
32
|
get lastActivityAt(): number {
|
|
30
33
|
return this._lastActivityAt
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
protected override setup(): void {
|
|
34
37
|
this.pi.on('session_start', (_event, ctx) => {
|
|
35
38
|
const activate =
|
|
36
39
|
ctx.mode === 'tui' &&
|
|
@@ -38,24 +41,21 @@ export class FocusTracker {
|
|
|
38
41
|
if (!activate) return
|
|
39
42
|
this._lastActivityAt = Date.now()
|
|
40
43
|
process.stdout.write(ENABLE_FOCUS_REPORTING)
|
|
41
|
-
this.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
this.unsubscribes.push(
|
|
45
|
+
ctx.ui.onTerminalInput((data) => {
|
|
46
|
+
this._lastActivityAt = Date.now()
|
|
47
|
+
const result = this.consume(data)
|
|
48
|
+
if (result.gainedFocus) this.titleTracker.restore()
|
|
49
|
+
if (result.data !== data) return { consume: true }
|
|
50
|
+
return undefined
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
50
53
|
})
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
stop(): void {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.unsubscribe()
|
|
57
|
-
this.unsubscribe = undefined
|
|
58
|
-
}
|
|
56
|
+
override stop(): void {
|
|
57
|
+
process.stdout.write(DISABLE_FOCUS_REPORTING)
|
|
58
|
+
super.stop()
|
|
59
59
|
this._focused = undefined
|
|
60
60
|
}
|
|
61
61
|
|
package/src/index.ts
CHANGED
|
@@ -4,19 +4,18 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
4
4
|
|
|
5
5
|
import { loadConfig } from './config.js'
|
|
6
6
|
import { DashboardCommand } from './dashboard/command.js'
|
|
7
|
-
import {
|
|
8
|
-
import { EventsNotifier } from './events.js'
|
|
7
|
+
import { SessionStore } from './dashboard/session-store.js'
|
|
9
8
|
import { FocusTracker } from './focus.js'
|
|
10
|
-
import { IdleNotifier } from './idle.js'
|
|
11
9
|
import { JobTracker } from './jobs.js'
|
|
12
10
|
import { notify } from './notifier.js'
|
|
13
11
|
import { NotifyTest } from './notify-test.js'
|
|
12
|
+
import type { Registerable } from './shared/types.js'
|
|
13
|
+
import { StateTracker } from './state-tracker.js'
|
|
14
14
|
import { SessionState } from './states.js'
|
|
15
15
|
import { TmuxTitleTracker } from './tmux-title.js'
|
|
16
|
-
import { ToolCallNotifier } from './tool.js'
|
|
17
16
|
|
|
18
|
-
export { PI_NOTIFY_EVENT } from './events.js'
|
|
19
17
|
export { JOB_END_EVENT, JOB_START_EVENT } from './jobs.js'
|
|
18
|
+
export { PI_NOTIFY_EVENT } from './state-tracker.js'
|
|
20
19
|
|
|
21
20
|
export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
22
21
|
const config = loadConfig()
|
|
@@ -25,13 +24,11 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
|
25
24
|
|
|
26
25
|
const tmuxTitleTracker = new TmuxTitleTracker(pi, config)
|
|
27
26
|
const focusTracker = new FocusTracker(pi, tmuxTitleTracker, config)
|
|
28
|
-
const eventsNotifier = new EventsNotifier(pi, config)
|
|
29
|
-
const toolNotifier = new ToolCallNotifier(pi, config)
|
|
30
27
|
const jobTracker = new JobTracker(pi)
|
|
31
|
-
const
|
|
28
|
+
const stateTracker = new StateTracker(pi, jobTracker, config)
|
|
32
29
|
const notifyTest = new NotifyTest(pi, title, tmuxTitleTracker)
|
|
33
30
|
const sessionState = new SessionState(pi)
|
|
34
|
-
const
|
|
31
|
+
const sessionStore = new SessionStore(pi, stateTracker)
|
|
35
32
|
const dashboardCommand = new DashboardCommand(pi)
|
|
36
33
|
|
|
37
34
|
function notifyReal(body: string): void {
|
|
@@ -48,13 +45,14 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
|
48
45
|
notify(title, body)
|
|
49
46
|
}
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
const registrables: Registerable[] = [
|
|
49
|
+
tmuxTitleTracker,
|
|
50
|
+
focusTracker,
|
|
51
|
+
jobTracker,
|
|
52
|
+
stateTracker,
|
|
53
|
+
sessionStore,
|
|
54
|
+
notifyTest,
|
|
55
|
+
dashboardCommand,
|
|
56
|
+
]
|
|
57
|
+
for (const r of registrables) r.register(notifyReal)
|
|
60
58
|
}
|
package/src/jobs.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import type { Unsubscribe } from './types.js'
|
|
1
|
+
import { Registrar } from './shared/registrar.js'
|
|
4
2
|
|
|
5
3
|
export const JOB_START_EVENT = 'pi-notify:job:start'
|
|
6
4
|
export const JOB_END_EVENT = 'pi-notify:job:end'
|
|
7
5
|
|
|
8
|
-
export class JobTracker {
|
|
6
|
+
export class JobTracker extends Registrar {
|
|
9
7
|
private activeJobs = new Set<string>()
|
|
10
|
-
private unsubscribes: Unsubscribe[] = []
|
|
11
|
-
private registered = false
|
|
12
8
|
private onEndListeners: Array<() => void> = []
|
|
13
9
|
|
|
14
|
-
constructor(private readonly pi: ExtensionAPI) {}
|
|
15
|
-
|
|
16
10
|
get hasActiveJobs(): boolean {
|
|
17
11
|
return this.activeJobs.size > 0
|
|
18
12
|
}
|
|
@@ -25,10 +19,7 @@ export class JobTracker {
|
|
|
25
19
|
}
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
|
|
29
|
-
if (this.registered) return
|
|
30
|
-
this.registered = true
|
|
31
|
-
|
|
22
|
+
protected override setup(): void {
|
|
32
23
|
const startUnsub = this.pi.events.on(JOB_START_EVENT, (params) => {
|
|
33
24
|
if (
|
|
34
25
|
typeof params === 'object' &&
|
|
@@ -53,18 +44,10 @@ export class JobTracker {
|
|
|
53
44
|
}
|
|
54
45
|
})
|
|
55
46
|
this.unsubscribes.push(endUnsub)
|
|
56
|
-
|
|
57
|
-
this.pi.on('session_shutdown', () => {
|
|
58
|
-
this.stop()
|
|
59
|
-
})
|
|
60
47
|
}
|
|
61
48
|
|
|
62
|
-
stop(): void {
|
|
63
|
-
|
|
64
|
-
unsub()
|
|
65
|
-
})
|
|
66
|
-
this.unsubscribes = []
|
|
49
|
+
override stop(): void {
|
|
50
|
+
super.stop()
|
|
67
51
|
this.activeJobs.clear()
|
|
68
|
-
this.registered = false
|
|
69
52
|
}
|
|
70
53
|
}
|
package/src/notify-test.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
|
-
import { notify } from './notifier.js'
|
|
3
|
+
import { notify as sendNotification } from './notifier.js'
|
|
4
|
+
import { sleep } from './shared/utils.js'
|
|
4
5
|
import type { TmuxTitleTracker } from './tmux-title.js'
|
|
5
|
-
import { sleep } from './utils.js'
|
|
6
6
|
|
|
7
7
|
const DEFAULT_BODY = 'This is a test notification.'
|
|
8
8
|
|
|
9
9
|
export class NotifyTest {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
) {
|
|
10
|
+
private readonly pi: ExtensionAPI
|
|
11
|
+
private readonly title: string
|
|
12
|
+
private readonly titleTracker: TmuxTitleTracker
|
|
13
|
+
|
|
14
|
+
constructor(pi: ExtensionAPI, title: string, titleTracker: TmuxTitleTracker) {
|
|
15
|
+
this.pi = pi
|
|
16
|
+
this.title = title
|
|
17
|
+
this.titleTracker = titleTracker
|
|
18
|
+
}
|
|
15
19
|
|
|
16
20
|
register(): void {
|
|
17
21
|
this.pi.registerCommand('notify-test', {
|
|
@@ -19,7 +23,7 @@ export class NotifyTest {
|
|
|
19
23
|
handler: async (args) => {
|
|
20
24
|
await sleep(3000)
|
|
21
25
|
this.titleTracker.mark()
|
|
22
|
-
|
|
26
|
+
sendNotification(this.title, args.trim() || DEFAULT_BODY)
|
|
23
27
|
},
|
|
24
28
|
})
|
|
25
29
|
}
|