@raidou/pi-notify 0.5.0 → 0.5.2

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 CHANGED
@@ -76,7 +76,7 @@ Columns: `SESSION_ID` (last 6 chars), `PID`, `STATE` (running/idle, color-coded)
76
76
 
77
77
  Keybindings:
78
78
 
79
- - `↑` / `↓` or `j` / `k` — scroll
79
+ - `o` — show/hide the SESSION_ID and PID columns
80
80
  - `r` — refresh
81
81
  - `q` or `esc` — close
82
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidou/pi-notify",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Desktop notification extension for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi",
@@ -47,7 +47,7 @@ const COLUMNS: Column[] = [
47
47
  width: 10,
48
48
  render: (session, theme, width) => {
49
49
  const startedRunningAt = session.startedRunningAt
50
- if (!startedRunningAt) return ''
50
+ if (!startedRunningAt) return ''.padEnd(width)
51
51
  const duration = Date.now() - startedRunningAt
52
52
  return theme.fg('dim', formatDuration(duration).padEnd(width))
53
53
  },
@@ -1,6 +1,6 @@
1
1
  import type { Theme, ThemeColor } from '@earendil-works/pi-coding-agent'
2
2
  import { visibleWidth } from '@earendil-works/pi-tui'
3
- import { describe, expect, it } from 'vitest'
3
+ import { describe, expect, it, vi } from 'vitest'
4
4
 
5
5
  import type { SessionRecord } from '../state-store.js'
6
6
  import { Dashboard } from './dashboard.js'
@@ -27,16 +27,21 @@ function makeSession(overrides: Partial<SessionRecord>): SessionRecord {
27
27
  }
28
28
  }
29
29
 
30
- function makeDashboard(sessions: SessionRecord[]) {
30
+ function makeDashboard(
31
+ sessions: SessionRecord[],
32
+ overrides: Partial<{
33
+ onRefresh: () => Promise<SessionRecord[]>
34
+ }> = {},
35
+ ) {
36
+ const onRefresh = overrides.onRefresh ?? (async () => sessions)
31
37
  return new Dashboard({
32
38
  tui: { requestRender: () => {} },
33
39
  theme,
34
40
  initialSessions: sessions,
35
- onRefresh: async () => sessions,
41
+ onRefresh,
36
42
  onClose: () => {},
37
43
  })
38
44
  }
39
-
40
45
  describe('Dashboard render clipping', () => {
41
46
  const longNameSession = makeSession({
42
47
  projectName: 'a-very-long-project-name-that-exceeds-any-narrow-width',
@@ -65,12 +70,12 @@ describe('Dashboard render clipping', () => {
65
70
  const visible = lines.map(stripAnsi)
66
71
  const header = visible.find((l) => l.includes('STATE'))
67
72
  expect(header).toBeDefined()
68
- const footer = visible.find((l) => l.startsWith('o ids'))
73
+ const footer = visible.find((l) => l.startsWith('[o] show/hide ids'))
69
74
  expect(footer).toBeDefined()
70
75
  if (!header || !footer) throw new Error('unreachable')
71
76
  expect(header.startsWith('STATE')).toBe(true)
72
77
  expect(header.endsWith('…')).toBe(true)
73
- expect(footer.startsWith('o ids • r refresh •…')).toBe(true)
78
+ expect(footer.startsWith('[o] show/hide ids ·…')).toBe(true)
74
79
  for (const line of visible) {
75
80
  expect(visibleWidth(line)).toBeLessThanOrEqual(20)
76
81
  }
@@ -116,3 +121,21 @@ describe('hidden column toggle', () => {
116
121
  }
117
122
  })
118
123
  })
124
+
125
+ describe('auto-refresh', () => {
126
+ it('manual r triggers refresh', () => {
127
+ const onRefresh = vi.fn(async () => [makeSession({})])
128
+ const dashboard = makeDashboard([makeSession({})], { onRefresh })
129
+ dashboard.handleInput('r')
130
+ expect(onRefresh).toHaveBeenCalledTimes(1)
131
+ dashboard.dispose()
132
+ })
133
+
134
+ it('dispose stops further refreshes', () => {
135
+ const onRefresh = vi.fn(async () => [makeSession({})])
136
+ const dashboard = makeDashboard([makeSession({})], { onRefresh })
137
+ dashboard.dispose()
138
+ dashboard.handleInput('r')
139
+ expect(onRefresh).not.toHaveBeenCalled()
140
+ })
141
+ })
@@ -3,6 +3,7 @@ import type { Component } from '@earendil-works/pi-tui'
3
3
  import { Key, matchesKey, truncateToWidth } from '@earendil-works/pi-tui'
4
4
 
5
5
  import type { SessionRecord } from '../state-store.js'
6
+ import { watchStore } from '../watch-store.js'
6
7
  import {
7
8
  COLUMN_SEPARATOR,
8
9
  resolveColumns,
@@ -29,7 +30,7 @@ export class Dashboard implements Component {
29
30
  private cachedLines: string[] = []
30
31
  private disposed = false
31
32
  private showHidden = false
32
- private timer: NodeJS.Timeout
33
+ private readonly stopWatching: () => void
33
34
 
34
35
  constructor({
35
36
  tui,
@@ -46,10 +47,9 @@ export class Dashboard implements Component {
46
47
  this.onDispose = onDispose
47
48
  this.sessions = [...initialSessions]
48
49
 
49
- // FIXME: 改成用 fs.watch
50
- this.timer = setInterval(() => {
51
- if (!this.disposed) this.refresh()
52
- }, 10000)
50
+ this.stopWatching = watchStore(() => {
51
+ this.refresh()
52
+ })
53
53
  }
54
54
 
55
55
  private forceRender(): void {
@@ -58,6 +58,7 @@ export class Dashboard implements Component {
58
58
  }
59
59
 
60
60
  private refresh(): void {
61
+ if (this.disposed) return
61
62
  this.onRefresh()
62
63
  .then((newSessions) => {
63
64
  this.sessions = [...newSessions]
@@ -87,12 +88,30 @@ export class Dashboard implements Component {
87
88
  .join(COLUMN_SEPARATOR),
88
89
  ),
89
90
  '',
90
- this.theme.fg('dim', 'o ids • r refresh • q or esc close'),
91
+ this.footerLine(width, [
92
+ ['o', 'show/hide ids'],
93
+ ['r', 'refresh'],
94
+ ['q/esc', 'close'],
95
+ ]),
91
96
  ]
92
97
  this.cachedLines = rows.map((line) => truncateToWidth(line, width, '…'))
93
98
  return this.cachedLines
94
99
  }
95
100
 
101
+ private footerLine(width: number, keys: [string, string][]): string {
102
+ const sep = this.theme.fg('dim', ' · ')
103
+ return truncateToWidth(
104
+ keys
105
+ .map(
106
+ ([key, desc]) =>
107
+ `${this.theme.fg('syntaxKeyword', `[${key}]`)} ${this.theme.fg('success', desc)}`,
108
+ )
109
+ .join(sep),
110
+ width,
111
+ '…',
112
+ )
113
+ }
114
+
96
115
  handleInput(data: string): void {
97
116
  if (matchesKey(data, 'r')) {
98
117
  this.refresh()
@@ -116,7 +135,7 @@ export class Dashboard implements Component {
116
135
 
117
136
  dispose(): void {
118
137
  this.disposed = true
119
- clearInterval(this.timer)
138
+ this.stopWatching()
120
139
  this.onDispose?.()
121
140
  }
122
141
  }
@@ -0,0 +1,112 @@
1
+ import { renameSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { afterAll, describe, expect, it, vi } from 'vitest'
5
+
6
+ vi.mock('./consts.js', async () => {
7
+ const { mkdtempSync } = await import('node:fs')
8
+ const path = await import('node:path')
9
+ const { tmpdir } = await import('node:os')
10
+
11
+ const stateDir = mkdtempSync(path.join(tmpdir(), 'pi-notify-watch-test-'))
12
+ return {
13
+ STATE_FILE: path.join(stateDir, 'state.json'),
14
+ STATE_TMP_FILE: path.join(stateDir, 'state.json.tmp'),
15
+ }
16
+ })
17
+
18
+ afterAll(() => {
19
+ rmSync(dirname(STATE_FILE), { recursive: true, force: true })
20
+ })
21
+
22
+ import { STATE_FILE } from './consts.js'
23
+ import { watchStore } from './watch-store.js'
24
+
25
+ const DEBOUNCE_MS = 25
26
+
27
+ function waitFor(cb: () => boolean, timeoutMs = 2000): Promise<void> {
28
+ return new Promise((resolve, reject) => {
29
+ const start = Date.now()
30
+ const check = (): void => {
31
+ if (cb()) {
32
+ resolve()
33
+ return
34
+ }
35
+ if (Date.now() - start > timeoutMs) {
36
+ reject(new Error('waitFor timed out'))
37
+ return
38
+ }
39
+ setTimeout(check, 10)
40
+ }
41
+ check()
42
+ })
43
+ }
44
+
45
+ function writeState(data: string): void {
46
+ const tmp = `${STATE_FILE}.tmp`
47
+ writeFileSync(tmp, data, 'utf8')
48
+ renameSync(tmp, STATE_FILE)
49
+ }
50
+
51
+ describe('watchStore', () => {
52
+ it('fires once for a tmp+rename write', async () => {
53
+ const onChange = vi.fn()
54
+ const stop = watchStore(onChange, { debounceMs: DEBOUNCE_MS })
55
+ try {
56
+ writeState('{"version":2,"sessions":{}}')
57
+ await waitFor(() => onChange.mock.calls.length > 0)
58
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS * 3))
59
+ expect(onChange).toHaveBeenCalledTimes(1)
60
+ } finally {
61
+ stop()
62
+ }
63
+ })
64
+
65
+ it('collapses a burst of writes into one call', async () => {
66
+ const onChange = vi.fn()
67
+ const stop = watchStore(onChange, { debounceMs: DEBOUNCE_MS })
68
+ try {
69
+ writeState('a')
70
+ writeState('b')
71
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS / 2))
72
+ writeState('c')
73
+ await waitFor(() => onChange.mock.calls.length > 0)
74
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS * 3))
75
+ expect(onChange).toHaveBeenCalledTimes(1)
76
+ } finally {
77
+ stop()
78
+ }
79
+ })
80
+
81
+ it('ignores tmp and lock file writes', async () => {
82
+ const onChange = vi.fn()
83
+ const stop = watchStore(onChange, { debounceMs: DEBOUNCE_MS })
84
+ try {
85
+ writeFileSync(`${STATE_FILE}.tmp`, 'tmp', 'utf8')
86
+ writeFileSync(`${STATE_FILE}.lock`, 'lock', 'utf8')
87
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS * 5))
88
+ expect(onChange).not.toHaveBeenCalled()
89
+ } finally {
90
+ stop()
91
+ rmSync(`${STATE_FILE}.tmp`, { force: true })
92
+ rmSync(`${STATE_FILE}.lock`, { force: true })
93
+ }
94
+ })
95
+
96
+ it('stops events after dispose', async () => {
97
+ const onChange = vi.fn()
98
+ const stop = watchStore(onChange, { debounceMs: DEBOUNCE_MS })
99
+ stop()
100
+ writeState('after-dispose')
101
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS * 5))
102
+ expect(onChange).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('is idempotent when disposed twice', () => {
106
+ const stop = watchStore(() => {}, { debounceMs: DEBOUNCE_MS })
107
+ stop()
108
+ expect(() => {
109
+ stop()
110
+ }).not.toThrow()
111
+ })
112
+ })
@@ -0,0 +1,48 @@
1
+ import { type FSWatcher, watch } from 'node:fs'
2
+ import { basename, dirname } from 'node:path'
3
+
4
+ import { STATE_FILE } from './consts.js'
5
+
6
+ const DEFAULT_DEBOUNCE_MS = 150
7
+
8
+ export function watchStore(
9
+ onChange: () => void,
10
+ opts?: { debounceMs?: number },
11
+ ): () => void {
12
+ const debounceMs = opts?.debounceMs ?? DEFAULT_DEBOUNCE_MS
13
+ const stateFileName = basename(STATE_FILE)
14
+
15
+ let timer: NodeJS.Timeout | null = null
16
+ let closed = false
17
+
18
+ let watcher: FSWatcher
19
+ try {
20
+ watcher = watch(dirname(STATE_FILE), (_event, filename) => {
21
+ if (filename !== null && filename !== stateFileName) return
22
+ if (timer !== null) clearTimeout(timer)
23
+ timer = setTimeout(() => {
24
+ timer = null
25
+ onChange()
26
+ }, debounceMs)
27
+ timer.unref()
28
+ })
29
+ } catch {
30
+ return () => {}
31
+ }
32
+
33
+ watcher.on('error', () => {
34
+ if (closed) return
35
+ closed = true
36
+ watcher.close()
37
+ })
38
+
39
+ return () => {
40
+ if (timer !== null) {
41
+ clearTimeout(timer)
42
+ timer = null
43
+ }
44
+ if (closed) return
45
+ closed = true
46
+ watcher.close()
47
+ }
48
+ }