vibe-pomo 0.1.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,100 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { STATE } from '../shared/protocol.mjs'
3
+
4
+ /**
5
+ * Timer state machine:
6
+ * idle → running → overtime → ended
7
+ *
8
+ * Transitions:
9
+ * start() : idle → running
10
+ * (tick) : running → overtime when remaining hits 0
11
+ * end() : running|overtime → ended (user-initiated)
12
+ * break() : running|overtime → ended (user pressed Break)
13
+ *
14
+ * Events emitted:
15
+ * 'tick' { state, remaining, overtime } — every second
16
+ * 'overtime' {} — first tick at 0
17
+ * 'ended' { status } — final state
18
+ */
19
+ export class Timer extends EventEmitter {
20
+ #state = STATE.IDLE
21
+ #plannedMs
22
+ #startedAt = null
23
+ #overtimeAt = null
24
+ #interval = null
25
+
26
+ constructor(plannedMs) {
27
+ super()
28
+ this.#plannedMs = plannedMs
29
+ }
30
+
31
+ get state() { return this.#state }
32
+ get startedAt() { return this.#startedAt }
33
+
34
+ get remaining() {
35
+ if (!this.#startedAt) return this.#plannedMs
36
+ if (this.#state === STATE.OVERTIME || this.#state === STATE.ENDED) return 0
37
+ const elapsed = Date.now() - this.#startedAt
38
+ return Math.max(0, this.#plannedMs - elapsed)
39
+ }
40
+
41
+ get overtime() {
42
+ if (!this.#overtimeAt) return 0
43
+ return Date.now() - this.#overtimeAt
44
+ }
45
+
46
+ get actualMs() {
47
+ if (!this.#startedAt) return 0
48
+ return Date.now() - this.#startedAt
49
+ }
50
+
51
+ snapshot() {
52
+ return {
53
+ state: this.#state,
54
+ remaining: this.remaining,
55
+ overtime: this.overtime,
56
+ plannedMs: this.#plannedMs,
57
+ startedAt: this.#startedAt,
58
+ }
59
+ }
60
+
61
+ start() {
62
+ if (this.#state !== STATE.IDLE) return
63
+ this.#state = STATE.RUNNING
64
+ this.#startedAt = Date.now()
65
+ this.#interval = setInterval(() => this.#tick(), 1000)
66
+ this.#tick() // immediate first tick
67
+ }
68
+
69
+ /** User-initiated end (End Session button) */
70
+ end() {
71
+ this.#finish('completed')
72
+ }
73
+
74
+ /** User-initiated break (Break button) */
75
+ break() {
76
+ this.#finish('broken')
77
+ }
78
+
79
+ #tick() {
80
+ if (this.#state === STATE.RUNNING && this.remaining === 0) {
81
+ this.#state = STATE.OVERTIME
82
+ this.#overtimeAt = Date.now()
83
+ this.emit('overtime')
84
+ }
85
+
86
+ this.emit('tick', {
87
+ state: this.#state,
88
+ remaining: this.remaining,
89
+ overtime: this.overtime,
90
+ })
91
+ }
92
+
93
+ #finish(status) {
94
+ if (this.#state === STATE.ENDED) return
95
+ clearInterval(this.#interval)
96
+ this.#interval = null
97
+ this.#state = STATE.ENDED
98
+ this.emit('ended', { status, actualMs: this.actualMs })
99
+ }
100
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Notification hook — queue notifications during an active Pomodoro session.
3
+ */
4
+ import { readLock, projectHash } from '../shared/lockfile.mjs'
5
+ import { sendAndReceive } from '../shared/ipcClient.mjs'
6
+ import { STATE, MSG } from '../shared/protocol.mjs'
7
+
8
+ async function readStdin() {
9
+ return new Promise((resolve) => {
10
+ if (process.stdin.isTTY) return resolve('')
11
+ let data = ''
12
+ process.stdin.setEncoding('utf8')
13
+ process.stdin.on('data', (c) => { data += c })
14
+ process.stdin.on('end', () => resolve(data))
15
+ })
16
+ }
17
+
18
+ async function main() {
19
+ const lock = readLock()
20
+ if (!lock) process.exit(0)
21
+
22
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
23
+ const hash = projectHash(projectDir)
24
+
25
+ let state
26
+ try {
27
+ state = await sendAndReceive({ type: MSG.QUERY })
28
+ } catch {
29
+ process.exit(0)
30
+ }
31
+
32
+ const active = state.sessions?.find(
33
+ (s) => s.projectHash === hash &&
34
+ (s.state === STATE.RUNNING || s.state === STATE.OVERTIME)
35
+ )
36
+ if (!active) process.exit(0)
37
+
38
+ const raw = await readStdin()
39
+ let notification = {}
40
+ try { notification = JSON.parse(raw) } catch {}
41
+
42
+ try {
43
+ await sendAndReceive({
44
+ type: MSG.QUEUE_NOTIFICATION,
45
+ sessionId: active.sessionId,
46
+ notification,
47
+ })
48
+ } catch {}
49
+
50
+ process.exit(0)
51
+ }
52
+
53
+ main().catch(() => process.exit(0))
@@ -0,0 +1,38 @@
1
+ /**
2
+ * PreToolUse hook — auto-approve tool calls during an active Pomodoro session.
3
+ * Transparency: if no daemon running or no active session for this project, exits 0.
4
+ */
5
+ import { readLock, projectHash } from '../shared/lockfile.mjs'
6
+ import { sendAndReceive } from '../shared/ipcClient.mjs'
7
+ import { STATE, MSG } from '../shared/protocol.mjs'
8
+
9
+ async function main() {
10
+ const lock = readLock()
11
+ if (!lock) process.exit(0)
12
+
13
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
14
+
15
+ let state
16
+ try {
17
+ state = await sendAndReceive({ type: MSG.QUERY })
18
+ } catch {
19
+ process.exit(0)
20
+ }
21
+
22
+ // Find an active session for this project
23
+ const hash = projectHash(projectDir)
24
+ const active = state.sessions?.find(
25
+ (s) => s.projectHash === hash &&
26
+ (s.state === STATE.RUNNING || s.state === STATE.OVERTIME)
27
+ )
28
+
29
+ if (active) {
30
+ process.stdout.write(JSON.stringify({
31
+ hookSpecificOutput: { permissionDecision: 'allow' }
32
+ }) + '\n')
33
+ }
34
+
35
+ process.exit(0)
36
+ }
37
+
38
+ main().catch(() => process.exit(0))
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Stop hook — called when Claude Code agent finishes.
3
+ * Blocks the agent if a Pomodoro is active for this project.
4
+ */
5
+ import { readLock, projectHash } from '../shared/lockfile.mjs'
6
+ import { sendAndReceive } from '../shared/ipcClient.mjs'
7
+ import { STATE, MSG } from '../shared/protocol.mjs'
8
+
9
+ async function readStdin() {
10
+ return new Promise((resolve) => {
11
+ if (process.stdin.isTTY) return resolve('')
12
+ let data = ''
13
+ process.stdin.setEncoding('utf8')
14
+ process.stdin.on('data', (c) => { data += c })
15
+ process.stdin.on('end', () => resolve(data))
16
+ })
17
+ }
18
+
19
+ async function main() {
20
+ const lock = readLock()
21
+ if (!lock) process.exit(0)
22
+
23
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
24
+ const hash = projectHash(projectDir)
25
+
26
+ let state
27
+ try {
28
+ state = await sendAndReceive({ type: MSG.QUERY })
29
+ } catch {
30
+ process.exit(0)
31
+ }
32
+
33
+ const active = state.sessions?.find(
34
+ (s) => s.projectHash === hash &&
35
+ (s.state === STATE.RUNNING || s.state === STATE.OVERTIME)
36
+ )
37
+ if (!active) process.exit(0)
38
+
39
+ const raw = await readStdin()
40
+ let input = {}
41
+ try { input = JSON.parse(raw) } catch {}
42
+
43
+ let resp
44
+ try {
45
+ resp = await sendAndReceive({
46
+ type: MSG.AGENT_STOPPING,
47
+ sessionId: active.sessionId,
48
+ stopReason: input.stop_reason ?? input.reason,
49
+ })
50
+ } catch {
51
+ process.exit(0)
52
+ }
53
+
54
+ if (resp.action === 'block') {
55
+ process.stdout.write(JSON.stringify({
56
+ decision: 'block',
57
+ reason: `Pomodoro active (${resp.remainingFormatted} remaining). Waiting for user to end session.`,
58
+ systemMessage: [
59
+ 'The Pomodoro focus session is still running.',
60
+ `Time remaining: ${resp.remainingFormatted}.`,
61
+ 'The user is not available. Do not send messages or notifications.',
62
+ 'Record any pending decisions in `.claude/pomodoro-pending.md` and wait quietly.',
63
+ ].join(' '),
64
+ }) + '\n')
65
+ }
66
+
67
+ process.exit(0)
68
+ }
69
+
70
+ main().catch(() => process.exit(0))
@@ -0,0 +1,58 @@
1
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join, dirname } from 'node:path'
4
+ import { DECISION } from './protocol.mjs'
5
+
6
+ const CONFIG_PATH = join(homedir(), '.claude', 'pomodoro.json')
7
+
8
+ const DEFAULTS = {
9
+ defaultDurationMs: 25 * 60 * 1000, // 25 minutes
10
+ decisionStrategy: DECISION.WAIT,
11
+ terminalEmulator: 'auto',
12
+ soundOnOvertime: true,
13
+ }
14
+
15
+ export function loadConfig() {
16
+ if (!existsSync(CONFIG_PATH)) {
17
+ return { ...DEFAULTS }
18
+ }
19
+ try {
20
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
21
+ return { ...DEFAULTS, ...raw }
22
+ } catch {
23
+ return { ...DEFAULTS }
24
+ }
25
+ }
26
+
27
+ export function ensureConfigFile() {
28
+ if (!existsSync(CONFIG_PATH)) {
29
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true })
30
+ writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2) + '\n', 'utf8')
31
+ return true
32
+ }
33
+ return false
34
+ }
35
+
36
+ export function formatDuration(ms) {
37
+ const totalSec = Math.floor(ms / 1000)
38
+ const m = Math.floor(totalSec / 60)
39
+ const s = totalSec % 60
40
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
41
+ }
42
+
43
+ /**
44
+ * Parse duration string like "25m", "1h", "90s", or plain number (minutes).
45
+ * Returns milliseconds.
46
+ */
47
+ export function parseDuration(str) {
48
+ if (!str) return null
49
+ const s = String(str).trim()
50
+ const match = s.match(/^(\d+)(m|h|s)?$/)
51
+ if (!match) return null
52
+ const n = parseInt(match[1], 10)
53
+ const unit = match[2] || 'm'
54
+ if (unit === 'h') return n * 60 * 60 * 1000
55
+ if (unit === 'm') return n * 60 * 1000
56
+ if (unit === 's') return n * 1000
57
+ return null
58
+ }
@@ -0,0 +1,73 @@
1
+ import net from 'node:net'
2
+ import { encode } from './protocol.mjs'
3
+ import { getSocketPath } from './lockfile.mjs'
4
+
5
+ const DEFAULT_TIMEOUT_MS = 500
6
+
7
+ /**
8
+ * Send one message, receive one JSON response.
9
+ * Used by hook scripts and CLI commands.
10
+ */
11
+ export function sendAndReceive(msgObj, timeoutMs = DEFAULT_TIMEOUT_MS) {
12
+ const socketPath = getSocketPath()
13
+ return new Promise((resolve, reject) => {
14
+ const client = net.createConnection(socketPath)
15
+ let buffer = ''
16
+ let settled = false
17
+
18
+ const timer = setTimeout(() => {
19
+ if (!settled) { settled = true; client.destroy(); reject(new Error('IPC timeout')) }
20
+ }, timeoutMs)
21
+
22
+ client.once('connect', () => client.write(encode(msgObj)))
23
+
24
+ client.on('data', (chunk) => {
25
+ buffer += chunk.toString()
26
+ const newline = buffer.indexOf('\n')
27
+ if (newline !== -1 && !settled) {
28
+ settled = true
29
+ clearTimeout(timer)
30
+ client.destroy()
31
+ try { resolve(JSON.parse(buffer.slice(0, newline))) }
32
+ catch { reject(new Error('Invalid JSON response')) }
33
+ }
34
+ })
35
+
36
+ client.on('error', (err) => {
37
+ if (!settled) { settled = true; clearTimeout(timer); reject(err) }
38
+ })
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Subscribe to daemon push events (for TUI clients).
44
+ * Returns { send(msg), close() }.
45
+ */
46
+ export function subscribe(onEvent, onError) {
47
+ const socketPath = getSocketPath()
48
+ const client = net.createConnection(socketPath)
49
+ let buffer = ''
50
+
51
+ client.once('connect', () => {
52
+ client.write(encode({ type: 'subscribe' }))
53
+ })
54
+
55
+ client.on('data', (chunk) => {
56
+ buffer += chunk.toString()
57
+ let newline
58
+ while ((newline = buffer.indexOf('\n')) !== -1) {
59
+ const line = buffer.slice(0, newline).trim()
60
+ buffer = buffer.slice(newline + 1)
61
+ if (!line) continue
62
+ try { onEvent(JSON.parse(line)) } catch {}
63
+ }
64
+ })
65
+
66
+ client.on('error', (err) => { if (onError) onError(err) })
67
+ client.on('close', () => { if (onError) onError(new Error('Connection closed')) })
68
+
69
+ return {
70
+ send(msgObj) { try { client.write(encode(msgObj)) } catch {} },
71
+ close() { client.destroy() },
72
+ }
73
+ }
@@ -0,0 +1,51 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ const CLAUDE_DIR = join(homedir(), '.claude')
7
+ const LOCK_PATH = join(CLAUDE_DIR, 'pomodoro.lock')
8
+ const SOCKET_PATH = join(CLAUDE_DIR, 'pomodoro.sock')
9
+
10
+ export function getSocketPath() {
11
+ return SOCKET_PATH
12
+ }
13
+
14
+ export function getLockPath() {
15
+ return LOCK_PATH
16
+ }
17
+
18
+ /**
19
+ * Read global lock. Returns null if file missing, malformed, or PID dead.
20
+ */
21
+ export function readLock() {
22
+ if (!existsSync(LOCK_PATH)) return null
23
+ let data
24
+ try {
25
+ data = JSON.parse(readFileSync(LOCK_PATH, 'utf8'))
26
+ } catch {
27
+ return null
28
+ }
29
+ try {
30
+ process.kill(data.pid, 0)
31
+ } catch {
32
+ try { unlinkSync(LOCK_PATH) } catch {}
33
+ try { unlinkSync(SOCKET_PATH) } catch {}
34
+ return null
35
+ }
36
+ return data
37
+ }
38
+
39
+ export function writeLock(data) {
40
+ writeFileSync(LOCK_PATH, JSON.stringify(data), 'utf8')
41
+ }
42
+
43
+ export function removeLock() {
44
+ try { unlinkSync(LOCK_PATH) } catch {}
45
+ try { unlinkSync(SOCKET_PATH) } catch {}
46
+ }
47
+
48
+ /** Stable short ID for a project directory */
49
+ export function projectHash(dir) {
50
+ return createHash('sha1').update(dir).digest('hex').slice(0, 12)
51
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * IPC message type constants and builders.
3
+ * All messages are newline-delimited JSON over Unix domain socket.
4
+ */
5
+
6
+ // Client → Daemon
7
+ export const MSG = {
8
+ QUERY: 'query', // get daemon state + all sessions
9
+ SESSION_QUERY: 'sessionQuery', // get single session state
10
+ SESSION_CREATE: 'sessionCreate', // start a new session
11
+ SESSION_END: 'sessionEnd', // user ends session (End button)
12
+ SESSION_BREAK: 'sessionBreak', // user breaks session (Break button)
13
+ AGENT_STOPPING: 'agentStopping', // stop hook: agent finished
14
+ QUEUE_NOTIFICATION: 'queueNotification',
15
+ SUBSCRIBE: 'subscribe',
16
+ SUBSCRIBE_SESSION: 'subscribeSession',
17
+ SESSION_UPDATE_ACTIVITY: 'sessionUpdateActivity', // save user's own activity note
18
+ }
19
+
20
+ // Daemon → Client (push)
21
+ export const EVT = {
22
+ TICK: 'tick', // { sessionId, state, remaining, overtime }
23
+ NOTIFICATION: 'notification', // { sessionId, notification, receivedAt, releasedAt }
24
+ SESSION_ENDED:'sessionEnded', // { sessionId, status, actualMs }
25
+ SNAPSHOT: 'snapshot', // full daemon state (on subscribe)
26
+ }
27
+
28
+ // Timer states
29
+ export const STATE = {
30
+ IDLE: 'idle',
31
+ RUNNING: 'running',
32
+ OVERTIME: 'overtime',
33
+ ENDED: 'ended',
34
+ }
35
+
36
+ // Decision strategies
37
+ export const DECISION = {
38
+ WAIT: 'wait', // block agent silently until user ends session
39
+ BREAK: 'break', // immediately end session
40
+ }
41
+
42
+ export function encode(obj) {
43
+ return JSON.stringify(obj) + '\n'
44
+ }
@@ -0,0 +1,149 @@
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { Box, Text, useInput, useStdout } from 'ink'
3
+ import { Timer } from './components/Timer.js'
4
+ import { TaskBar } from './components/TaskBar.js'
5
+ import { NotificationLog } from './components/NotificationLog.js'
6
+ import { TuiIpcClient } from './ipcClient.mjs'
7
+ import { STATE } from '../shared/protocol.mjs'
8
+
9
+ interface AppProps {
10
+ socketPath: string
11
+ task: string
12
+ plannedMs: number
13
+ }
14
+
15
+ interface TickState {
16
+ state: string
17
+ remaining: number
18
+ overtime: number
19
+ queuedNotifications?: number
20
+ }
21
+
22
+ interface NotificationEntry {
23
+ notification: Record<string, unknown>
24
+ receivedAt: number
25
+ releasedAt: number
26
+ }
27
+
28
+ export function App({ socketPath, task: initialTask, plannedMs }: AppProps) {
29
+ const { stdout } = useStdout()
30
+ const width = stdout?.columns ?? 60
31
+
32
+ const [tick, setTick] = useState<TickState>({
33
+ state: STATE.RUNNING,
34
+ remaining: plannedMs,
35
+ overtime: 0,
36
+ queuedNotifications: 0,
37
+ })
38
+ const [notifications, setNotifications] = useState<NotificationEntry[]>([])
39
+ const [task, setTask] = useState(initialTask)
40
+ const [connectionError, setConnectionError] = useState<string | null>(null)
41
+ const [ipc] = useState(() => new TuiIpcClient(socketPath))
42
+
43
+ useEffect(() => {
44
+ ipc.connect({
45
+ onTick: (event) => {
46
+ setTick({
47
+ state: event.state,
48
+ remaining: event.remaining ?? 0,
49
+ overtime: event.overtime ?? 0,
50
+ queuedNotifications: event.queuedNotifications ?? 0,
51
+ })
52
+ if (event.task) setTask(event.task)
53
+ },
54
+ onNotification: (event) => {
55
+ setNotifications((prev) => [...prev, {
56
+ notification: event.notification,
57
+ receivedAt: event.receivedAt,
58
+ releasedAt: event.releasedAt,
59
+ }])
60
+ },
61
+ onEnded: () => {
62
+ setTick((prev) => ({ ...prev, state: STATE.ENDED }))
63
+ // Give time for final notifications to render, then exit
64
+ setTimeout(() => process.exit(0), 3000)
65
+ },
66
+ onError: (err) => {
67
+ if (tick.state !== STATE.ENDED) {
68
+ setConnectionError(err.message)
69
+ }
70
+ },
71
+ })
72
+
73
+ return () => ipc.close()
74
+ }, [])
75
+
76
+ const handleEnd = useCallback(async () => {
77
+ await ipc.sendEnd()
78
+ }, [ipc])
79
+
80
+ const handleBreak = useCallback(async () => {
81
+ await ipc.sendBreak()
82
+ }, [ipc])
83
+
84
+ useInput((input, key) => {
85
+ if (tick.state === STATE.ENDED) {
86
+ if (input === 'q' || key.escape) process.exit(0)
87
+ return
88
+ }
89
+ if (input === 'e' || input === 'E') handleEnd()
90
+ if (input === 'b' || input === 'B') handleBreak()
91
+ if (input === 'q' || key.escape) process.exit(0)
92
+ })
93
+
94
+ const isOvertime = tick.state === STATE.OVERTIME
95
+ const isEnded = tick.state === STATE.ENDED
96
+
97
+ const keybindings = isEnded
98
+ ? '[Q] Quit'
99
+ : isOvertime
100
+ ? '[E] End Session [B] Break [Q] Quit'
101
+ : '[B] Break [Q] Quit'
102
+
103
+ return (
104
+ <Box flexDirection="column" width={width} paddingX={2} paddingY={1}>
105
+ {/* Header */}
106
+ <Box marginBottom={1}>
107
+ <Text bold color={isOvertime ? 'yellow' : isEnded ? 'gray' : 'red'}>
108
+ 🍅 Pomodoro
109
+ </Text>
110
+ </Box>
111
+
112
+ {/* Timer */}
113
+ <Timer
114
+ state={tick.state}
115
+ remaining={tick.remaining}
116
+ overtime={tick.overtime}
117
+ />
118
+
119
+ {/* Task */}
120
+ <TaskBar task={task} width={width} />
121
+
122
+ {/* Notification log */}
123
+ <NotificationLog
124
+ notifications={notifications}
125
+ queued={tick.queuedNotifications ?? 0}
126
+ width={width - 4}
127
+ />
128
+
129
+ {/* Error */}
130
+ {connectionError && (
131
+ <Box marginTop={1}>
132
+ <Text color="red">Connection lost: {connectionError}</Text>
133
+ </Box>
134
+ )}
135
+
136
+ {/* Ended message */}
137
+ {isEnded && (
138
+ <Box marginTop={1}>
139
+ <Text color="green" bold>Session complete. Press Q to exit.</Text>
140
+ </Box>
141
+ )}
142
+
143
+ {/* Keybindings */}
144
+ <Box marginTop={1}>
145
+ <Text color="gray" dimColor>{keybindings}</Text>
146
+ </Box>
147
+ </Box>
148
+ )
149
+ }
@@ -0,0 +1,60 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ interface NotificationEntry {
5
+ notification: Record<string, unknown>
6
+ receivedAt: number
7
+ releasedAt: number
8
+ }
9
+
10
+ interface NotificationLogProps {
11
+ notifications: NotificationEntry[]
12
+ queued: number
13
+ width: number
14
+ }
15
+
16
+ function formatTime(ms: number): string {
17
+ return new Date(ms).toLocaleTimeString()
18
+ }
19
+
20
+ export function NotificationLog({ notifications, queued, width }: NotificationLogProps) {
21
+ const maxVisible = 8
22
+
23
+ return (
24
+ <Box flexDirection="column" marginTop={1}>
25
+ <Box marginBottom={0}>
26
+ <Text bold color="white"> Notifications </Text>
27
+ {queued > 0 && (
28
+ <Text color="yellow"> ({queued} queued, releasing at overtime…)</Text>
29
+ )}
30
+ {queued === 0 && notifications.length === 0 && (
31
+ <Text color="gray"> (will appear when timer ends)</Text>
32
+ )}
33
+ </Box>
34
+
35
+ <Box
36
+ flexDirection="column"
37
+ borderStyle="single"
38
+ borderColor="gray"
39
+ paddingX={1}
40
+ width={width - 2}
41
+ >
42
+ {notifications.length === 0 ? (
43
+ <Text color="gray" dimColor> —</Text>
44
+ ) : (
45
+ notifications.slice(-maxVisible).map((entry, i) => {
46
+ const n = entry.notification as any
47
+ const title = n?.title ?? n?.message ?? JSON.stringify(n).slice(0, 60)
48
+ const time = formatTime(entry.releasedAt)
49
+ return (
50
+ <Box key={i}>
51
+ <Text color="gray">{time} </Text>
52
+ <Text>{title}</Text>
53
+ </Box>
54
+ )
55
+ })
56
+ )}
57
+ </Box>
58
+ </Box>
59
+ )
60
+ }