@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.
@@ -5,11 +5,13 @@ import {
5
5
  renameSync,
6
6
  writeFileSync,
7
7
  } from 'node:fs'
8
- import { dirname, join } from 'node:path'
8
+ import { dirname } from 'node:path'
9
9
 
10
- import { getAgentDir } from '@earendil-works/pi-coding-agent'
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 deadIds: string[] = []
36
- const alive: SessionRecord[] = []
37
-
38
- for (const session of Object.values(state.sessions)) {
39
- if (!session) continue
40
- if (isProcessAlive(session.pid)) {
41
- alive.push(session)
42
- } else {
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: 'running' | 'idle'
69
- stateChangedAt: number
65
+ state: SessionState
66
+ startedRunningAt?: number
70
67
  }
71
68
 
72
69
  export interface DashboardState {
73
- version: 1
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: 1, sessions: {} }
149
+ return { version: 2, sessions: {} }
91
150
  }
92
151
 
93
152
  try {
94
- const data = readFileSync(STATE_FILE, 'utf8')
95
- return JSON.parse(data) as DashboardState
153
+ const state = parseState(readFileSync(STATE_FILE, 'utf8'))
154
+ return state ?? { version: 2, sessions: {} }
96
155
  } catch {
97
- return { version: 1, sessions: {} }
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
- const tmpFile = `${STATE_FILE}.tmp`
120
- writeFileSync(tmpFile, JSON.stringify(newState, null, 2), 'utf8')
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,