@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.
@@ -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
- private readonly pi: ExtensionAPI,
20
- private readonly titleTracker: TmuxTitleTracker,
21
- private readonly config: ResolvedNotifyConfig,
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
- register(): void {
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.unsubscribe = ctx.ui.onTerminalInput((data) => {
42
- this._lastActivityAt = Date.now()
43
- const result = this.consume(data)
44
- if (result.gainedFocus) this.titleTracker.restore()
45
- return result.data === data ? undefined : { data: result.data }
46
- })
47
- })
48
- this.pi.on('session_shutdown', () => {
49
- this.stop()
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
- if (this.unsubscribe) {
55
- process.stdout.write(DISABLE_FOCUS_REPORTING)
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 { StateTracker } from './dashboard/state-tracker.js'
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 idleNotifier = new IdleNotifier(pi, config, jobTracker)
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 stateTracker = new StateTracker(pi)
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
- tmuxTitleTracker.register()
52
- focusTracker.register()
53
- jobTracker.register()
54
- eventsNotifier.register(notifyReal)
55
- toolNotifier.register(notifyReal)
56
- idleNotifier.register(notifyReal)
57
- notifyTest.register()
58
- stateTracker.register()
59
- dashboardCommand.register()
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 type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
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
- register(): void {
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
- this.unsubscribes.forEach((unsub) => {
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
  }
@@ -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
- constructor(
11
- private readonly pi: ExtensionAPI,
12
- private readonly title: string,
13
- private readonly titleTracker: TmuxTitleTracker,
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
- notify(this.title, args.trim() || DEFAULT_BODY)
26
+ sendNotification(this.title, args.trim() || DEFAULT_BODY)
23
27
  },
24
28
  })
25
29
  }