@raidou/pi-notify 0.2.0 → 0.3.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/package.json +2 -1
- package/src/dashboard/command.ts +37 -0
- package/src/dashboard/state-store.ts +156 -0
- package/src/dashboard/state-tracker.ts +174 -0
- package/src/dashboard/ui.ts +198 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raidou/pi-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Desktop notification extension for the pi coding agent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"test:lint": "eslint --fix ."
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@earendil-works/pi-tui": "0.80.7",
|
|
36
37
|
"node-notifier": "^10.0.1"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import { readSessions } from './state-store.js'
|
|
4
|
+
import { createDashboard } from './ui.js'
|
|
5
|
+
|
|
6
|
+
export class DashboardCommand {
|
|
7
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
8
|
+
|
|
9
|
+
register(): void {
|
|
10
|
+
this.pi.registerCommand('notify-dashboard', {
|
|
11
|
+
description: 'Show all pi sessions notify dashboard',
|
|
12
|
+
handler: async (_args, ctx) => {
|
|
13
|
+
const initialSessions = readSessions()
|
|
14
|
+
|
|
15
|
+
await ctx.ui.custom<unknown>((tui, theme, _keybindings, done) => {
|
|
16
|
+
const dashboard = createDashboard({
|
|
17
|
+
tui,
|
|
18
|
+
theme,
|
|
19
|
+
initialSessions,
|
|
20
|
+
onRefresh: async () => readSessions(),
|
|
21
|
+
onClose: () => {
|
|
22
|
+
done(undefined)
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
render: dashboard.render.bind(dashboard),
|
|
28
|
+
handleInput: dashboard.handleInput.bind(dashboard),
|
|
29
|
+
invalidate: dashboard.invalidate.bind(dashboard),
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return undefined
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
12
|
+
|
|
13
|
+
export interface SessionRecord {
|
|
14
|
+
pid: number
|
|
15
|
+
cwd: string
|
|
16
|
+
projectName: string
|
|
17
|
+
startedAt: number
|
|
18
|
+
lastHeartbeatAt: number
|
|
19
|
+
state: 'running' | 'idle'
|
|
20
|
+
stateChangedAt: number
|
|
21
|
+
model: string | undefined
|
|
22
|
+
lastEvent: { type: string; summary: string; at: number } | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DashboardState {
|
|
26
|
+
version: 1
|
|
27
|
+
sessions: Record<string, SessionRecord | undefined>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STATE_FILE = join(getAgentDir(), 'pi-notify', 'state.json')
|
|
31
|
+
const LOCK_FILE = `${STATE_FILE}.lock`
|
|
32
|
+
const LOCK_RETRY_DELAY_MS = 50
|
|
33
|
+
const LOCK_MAX_RETRIES = 20
|
|
34
|
+
const STALE_THRESHOLD_MS = 30000
|
|
35
|
+
|
|
36
|
+
function ensureStateDir(): void {
|
|
37
|
+
const dir = dirname(STATE_FILE)
|
|
38
|
+
if (!existsSync(dir)) {
|
|
39
|
+
mkdirSync(dir, { recursive: true })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function acquireLock(retries = LOCK_MAX_RETRIES): Promise<boolean> {
|
|
44
|
+
for (let i = 0; i < retries; i++) {
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(LOCK_FILE, { recursive: false })
|
|
47
|
+
return true
|
|
48
|
+
} catch (e) {
|
|
49
|
+
const code =
|
|
50
|
+
e instanceof Error && 'code' in e
|
|
51
|
+
? (e as NodeJS.ErrnoException).code
|
|
52
|
+
: undefined
|
|
53
|
+
if (code === 'EEXIST') {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS))
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (code === 'ENOENT') {
|
|
58
|
+
ensureStateDir()
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS))
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
throw e
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function releaseLock(): void {
|
|
69
|
+
try {
|
|
70
|
+
rmdirSync(LOCK_FILE)
|
|
71
|
+
} catch {
|
|
72
|
+
// Lock file may not exist
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPidAlive(pid: number): boolean {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0)
|
|
79
|
+
return true
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readState(options?: { filterStale?: boolean }): DashboardState {
|
|
86
|
+
ensureStateDir()
|
|
87
|
+
if (!existsSync(STATE_FILE)) {
|
|
88
|
+
return { version: 1, sessions: {} }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let state: DashboardState
|
|
92
|
+
try {
|
|
93
|
+
const data = readFileSync(STATE_FILE, 'utf8')
|
|
94
|
+
state = JSON.parse(data) as DashboardState
|
|
95
|
+
} catch {
|
|
96
|
+
return { version: 1, sessions: {} }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options?.filterStale === false) {
|
|
100
|
+
return state
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return cleanupStale(state).cleaned
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readSessions(): SessionRecord[] {
|
|
107
|
+
const state = readState()
|
|
108
|
+
return Object.values(state.sessions).filter(
|
|
109
|
+
(s): s is SessionRecord => s !== undefined,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function updateState(
|
|
114
|
+
mutator: (state: DashboardState) => DashboardState,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
ensureStateDir()
|
|
117
|
+
|
|
118
|
+
const locked = await acquireLock()
|
|
119
|
+
if (!locked) {
|
|
120
|
+
throw new Error('Failed to acquire lock for state update')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const state = readState()
|
|
125
|
+
const newState = mutator(state)
|
|
126
|
+
const tmpFile = `${STATE_FILE}.tmp`
|
|
127
|
+
writeFileSync(tmpFile, JSON.stringify(newState, null, 2), 'utf8')
|
|
128
|
+
renameSync(tmpFile, STATE_FILE)
|
|
129
|
+
} finally {
|
|
130
|
+
releaseLock()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cleanupStale(
|
|
135
|
+
state: DashboardState,
|
|
136
|
+
staleThresholdMs = STALE_THRESHOLD_MS,
|
|
137
|
+
): { cleaned: DashboardState; removedIds: string[] } {
|
|
138
|
+
const now = Date.now()
|
|
139
|
+
const removedIds: string[] = []
|
|
140
|
+
const sessions: Record<string, SessionRecord | undefined> = {}
|
|
141
|
+
|
|
142
|
+
for (const [id, record] of Object.entries(state.sessions)) {
|
|
143
|
+
if (!record) continue
|
|
144
|
+
const heartbeatAge = now - record.lastHeartbeatAt
|
|
145
|
+
const isStale = heartbeatAge > staleThresholdMs
|
|
146
|
+
const isPidAliveValue = isPidAlive(record.pid)
|
|
147
|
+
|
|
148
|
+
if (!isStale && isPidAliveValue) {
|
|
149
|
+
sessions[id] = record
|
|
150
|
+
} else {
|
|
151
|
+
removedIds.push(id)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { cleaned: { version: 1, sessions }, removedIds }
|
|
156
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
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/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { basename } from 'node:path'
|
|
|
3
3
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
4
4
|
|
|
5
5
|
import { loadConfig } from './config.js'
|
|
6
|
+
import { DashboardCommand } from './dashboard/command.js'
|
|
7
|
+
import { StateTracker } from './dashboard/state-tracker.js'
|
|
6
8
|
import { EventsNotifier } from './events.js'
|
|
7
9
|
import { FocusTracker } from './focus.js'
|
|
8
10
|
import { IdleNotifier } from './idle.js'
|
|
@@ -29,6 +31,8 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
|
29
31
|
const idleNotifier = new IdleNotifier(pi, config, jobTracker)
|
|
30
32
|
const notifyTest = new NotifyTest(pi, title, tmuxTitleTracker)
|
|
31
33
|
const sessionState = new SessionState(pi)
|
|
34
|
+
const stateTracker = new StateTracker(pi)
|
|
35
|
+
const dashboardCommand = new DashboardCommand(pi)
|
|
32
36
|
|
|
33
37
|
function notifyReal(body: string): void {
|
|
34
38
|
if (!config.enabled || !sessionState.hasUI) return
|
|
@@ -51,4 +55,6 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
|
51
55
|
toolNotifier.register(notifyReal)
|
|
52
56
|
idleNotifier.register(notifyReal)
|
|
53
57
|
notifyTest.register()
|
|
58
|
+
stateTracker.register()
|
|
59
|
+
dashboardCommand.register()
|
|
54
60
|
}
|