@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
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
renameSync,
|
|
6
6
|
writeFileSync,
|
|
7
7
|
} from 'node:fs'
|
|
8
|
-
import { dirname
|
|
8
|
+
import { dirname } from 'node:path'
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { omit, partition } from 'lodash-es'
|
|
11
11
|
import lockfile from 'proper-lockfile'
|
|
12
12
|
|
|
13
|
+
import { STATE_FILE, STATE_TMP_FILE } from './consts.js'
|
|
14
|
+
|
|
13
15
|
const ESRCH = 'ESRCH'
|
|
14
16
|
const EPERM = 'EPERM'
|
|
15
17
|
|
|
@@ -32,49 +34,43 @@ export function isProcessAlive(pid: number): boolean {
|
|
|
32
34
|
|
|
33
35
|
export async function readSessions(): Promise<SessionRecord[]> {
|
|
34
36
|
const state = readState()
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
deadIds.push(String(session.pid))
|
|
44
|
-
}
|
|
45
|
-
}
|
|
37
|
+
const sessions = Object.values(state.sessions).filter(
|
|
38
|
+
(session): session is SessionRecord => session !== undefined,
|
|
39
|
+
)
|
|
40
|
+
const [alive, dead] = partition(sessions, (session) =>
|
|
41
|
+
isProcessAlive(session.pid),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const deadIds = dead.map((session) => String(session.pid))
|
|
46
45
|
|
|
47
46
|
if (deadIds.length === 0) return alive
|
|
48
47
|
|
|
49
48
|
await updateState((s) => {
|
|
50
|
-
const sessions: typeof s.sessions =
|
|
51
|
-
for (const key of Object.keys(s.sessions)) {
|
|
52
|
-
if (!deadIds.includes(key)) {
|
|
53
|
-
sessions[key] = s.sessions[key]
|
|
54
|
-
}
|
|
55
|
-
}
|
|
49
|
+
const sessions: typeof s.sessions = omit(s.sessions, deadIds)
|
|
56
50
|
return { ...s, sessions }
|
|
57
51
|
})
|
|
58
52
|
|
|
59
53
|
return alive
|
|
60
54
|
}
|
|
61
55
|
|
|
56
|
+
export type SessionState =
|
|
57
|
+
'running' | 'idle' | `tool_call:${string}` | `event:${string}`
|
|
58
|
+
|
|
62
59
|
export interface SessionRecord {
|
|
63
60
|
pid: number
|
|
64
61
|
sessionId: string
|
|
65
62
|
cwd: string
|
|
66
63
|
projectName: string
|
|
67
64
|
startedAt: number
|
|
68
|
-
state:
|
|
69
|
-
|
|
65
|
+
state: SessionState
|
|
66
|
+
startedRunningAt?: number
|
|
70
67
|
}
|
|
71
68
|
|
|
72
69
|
export interface DashboardState {
|
|
73
|
-
version:
|
|
70
|
+
version: 2
|
|
74
71
|
sessions: Record<string, SessionRecord | undefined>
|
|
75
72
|
}
|
|
76
73
|
|
|
77
|
-
const STATE_FILE = join(getAgentDir(), 'pi-notify', 'state.json')
|
|
78
74
|
const LOCK_RETRY_INTERVAL_MS = 50
|
|
79
75
|
const LOCK_MAX_RETRIES = 20
|
|
80
76
|
|
|
@@ -85,24 +81,85 @@ function ensureStateDir(): void {
|
|
|
85
81
|
}
|
|
86
82
|
}
|
|
87
83
|
|
|
84
|
+
ensureStateDir()
|
|
85
|
+
|
|
86
|
+
function parseSessionRecord(value: unknown): SessionRecord | undefined {
|
|
87
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
88
|
+
const record = value as Record<string, unknown>
|
|
89
|
+
if (
|
|
90
|
+
typeof record.pid !== 'number' ||
|
|
91
|
+
typeof record.sessionId !== 'string' ||
|
|
92
|
+
typeof record.cwd !== 'string' ||
|
|
93
|
+
typeof record.projectName !== 'string' ||
|
|
94
|
+
typeof record.startedAt !== 'number' ||
|
|
95
|
+
!isSessionState(record.state)
|
|
96
|
+
) {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
pid: record.pid,
|
|
101
|
+
sessionId: record.sessionId,
|
|
102
|
+
cwd: record.cwd,
|
|
103
|
+
projectName: record.projectName,
|
|
104
|
+
startedAt: record.startedAt,
|
|
105
|
+
state: record.state,
|
|
106
|
+
startedRunningAt:
|
|
107
|
+
typeof record.startedRunningAt === 'number'
|
|
108
|
+
? record.startedRunningAt
|
|
109
|
+
: undefined,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isActivityState(
|
|
114
|
+
value: string,
|
|
115
|
+
): value is `tool_call:${string}` | `event:${string}` {
|
|
116
|
+
return value.startsWith('tool_call:') || value.startsWith('event:')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isSessionState(value: unknown): value is SessionState {
|
|
120
|
+
return (
|
|
121
|
+
value === 'running' ||
|
|
122
|
+
value === 'idle' ||
|
|
123
|
+
(typeof value === 'string' && isActivityState(value))
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseState(data: string): DashboardState | undefined {
|
|
128
|
+
let parsed: unknown
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(data)
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
135
|
+
const raw = parsed as Record<string, unknown>
|
|
136
|
+
if (typeof raw.sessions !== 'object' || raw.sessions === null)
|
|
137
|
+
return undefined
|
|
138
|
+
|
|
139
|
+
const sessions: Record<string, SessionRecord | undefined> = {}
|
|
140
|
+
for (const [key, value] of Object.entries(raw.sessions)) {
|
|
141
|
+
sessions[key] = parseSessionRecord(value)
|
|
142
|
+
}
|
|
143
|
+
// Records from earlier versions without optional fields parse as-is.
|
|
144
|
+
return { version: 2, sessions }
|
|
145
|
+
}
|
|
146
|
+
|
|
88
147
|
export function readState(): DashboardState {
|
|
89
148
|
if (!existsSync(STATE_FILE)) {
|
|
90
|
-
return { version:
|
|
149
|
+
return { version: 2, sessions: {} }
|
|
91
150
|
}
|
|
92
151
|
|
|
93
152
|
try {
|
|
94
|
-
const
|
|
95
|
-
return
|
|
153
|
+
const state = parseState(readFileSync(STATE_FILE, 'utf8'))
|
|
154
|
+
return state ?? { version: 2, sessions: {} }
|
|
96
155
|
} catch {
|
|
97
|
-
return { version:
|
|
156
|
+
return { version: 2, sessions: {} }
|
|
98
157
|
}
|
|
99
158
|
}
|
|
100
159
|
|
|
101
160
|
export async function updateState(
|
|
102
161
|
mutator: (state: DashboardState) => DashboardState,
|
|
103
162
|
): Promise<void> {
|
|
104
|
-
ensureStateDir()
|
|
105
|
-
|
|
106
163
|
const release = await lockfile.lock(STATE_FILE, {
|
|
107
164
|
realpath: false,
|
|
108
165
|
stale: 30000,
|
|
@@ -116,9 +173,8 @@ export async function updateState(
|
|
|
116
173
|
try {
|
|
117
174
|
const state = readState()
|
|
118
175
|
const newState = mutator(state)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
renameSync(tmpFile, STATE_FILE)
|
|
176
|
+
writeFileSync(STATE_TMP_FILE, JSON.stringify(newState, null, 2), 'utf8')
|
|
177
|
+
renameSync(STATE_TMP_FILE, STATE_FILE)
|
|
122
178
|
} finally {
|
|
123
179
|
await release()
|
|
124
180
|
}
|
|
@@ -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/index.ts
CHANGED
|
@@ -5,9 +5,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
5
5
|
import { loadConfig } from './config.js'
|
|
6
6
|
import { DashboardCommand } from './dashboard/command.js'
|
|
7
7
|
import { SessionStore } from './dashboard/session-store.js'
|
|
8
|
-
import { EventsNotifier } from './events.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'
|
|
@@ -15,10 +13,9 @@ import type { Registerable } from './shared/types.js'
|
|
|
15
13
|
import { StateTracker } from './state-tracker.js'
|
|
16
14
|
import { SessionState } from './states.js'
|
|
17
15
|
import { TmuxTitleTracker } from './tmux-title.js'
|
|
18
|
-
import { ToolCallNotifier } from './tool.js'
|
|
19
16
|
|
|
20
|
-
export { PI_NOTIFY_EVENT } from './events.js'
|
|
21
17
|
export { JOB_END_EVENT, JOB_START_EVENT } from './jobs.js'
|
|
18
|
+
export { PI_NOTIFY_EVENT } from './state-tracker.js'
|
|
22
19
|
|
|
23
20
|
export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
24
21
|
const config = loadConfig()
|
|
@@ -27,11 +24,8 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
|
27
24
|
|
|
28
25
|
const tmuxTitleTracker = new TmuxTitleTracker(pi, config)
|
|
29
26
|
const focusTracker = new FocusTracker(pi, tmuxTitleTracker, config)
|
|
30
|
-
const eventsNotifier = new EventsNotifier(pi, config)
|
|
31
|
-
const toolNotifier = new ToolCallNotifier(pi, config)
|
|
32
27
|
const jobTracker = new JobTracker(pi)
|
|
33
|
-
const stateTracker = new StateTracker(pi, jobTracker)
|
|
34
|
-
const idleNotifier = new IdleNotifier(pi, config, stateTracker)
|
|
28
|
+
const stateTracker = new StateTracker(pi, jobTracker, config)
|
|
35
29
|
const notifyTest = new NotifyTest(pi, title, tmuxTitleTracker)
|
|
36
30
|
const sessionState = new SessionState(pi)
|
|
37
31
|
const sessionStore = new SessionStore(pi, stateTracker)
|
|
@@ -56,9 +50,6 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
|
56
50
|
focusTracker,
|
|
57
51
|
jobTracker,
|
|
58
52
|
stateTracker,
|
|
59
|
-
eventsNotifier,
|
|
60
|
-
toolNotifier,
|
|
61
|
-
idleNotifier,
|
|
62
53
|
sessionStore,
|
|
63
54
|
notifyTest,
|
|
64
55
|
dashboardCommand,
|