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,23 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ interface TaskBarProps {
5
+ task: string
6
+ width: number
7
+ }
8
+
9
+ export function TaskBar({ task, width }: TaskBarProps) {
10
+ if (!task) return null
11
+
12
+ const maxLen = Math.max(width - 10, 20)
13
+ const display = task.length > maxLen
14
+ ? task.slice(0, maxLen - 1) + '…'
15
+ : task
16
+
17
+ return (
18
+ <Box marginBottom={1}>
19
+ <Text color="cyan" bold>Task </Text>
20
+ <Text>{display}</Text>
21
+ </Box>
22
+ )
23
+ }
@@ -0,0 +1,47 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { STATE } from '../../shared/protocol.mjs'
4
+
5
+ interface TimerProps {
6
+ state: string
7
+ remaining: number // ms
8
+ overtime: number // ms
9
+ }
10
+
11
+ function formatMs(ms: number): string {
12
+ const totalSec = Math.floor(Math.abs(ms) / 1000)
13
+ const m = Math.floor(totalSec / 60)
14
+ const s = totalSec % 60
15
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
16
+ }
17
+
18
+ export function Timer({ state, remaining, overtime }: TimerProps) {
19
+ const isOvertime = state === STATE.OVERTIME
20
+ const isEnded = state === STATE.ENDED
21
+
22
+ const timeDisplay = isOvertime
23
+ ? `+${formatMs(overtime)}`
24
+ : formatMs(remaining)
25
+
26
+ const statusLabel = isEnded
27
+ ? 'ENDED'
28
+ : isOvertime
29
+ ? 'OVERTIME'
30
+ : 'RUNNING'
31
+
32
+ const timeColor = isEnded ? 'gray' : isOvertime ? 'yellow' : 'green'
33
+ const statusColor = isEnded ? 'gray' : isOvertime ? 'yellow' : 'greenBright'
34
+
35
+ return (
36
+ <Box flexDirection="column" alignItems="center" marginY={1}>
37
+ <Text color={timeColor} bold>
38
+ {timeDisplay.split('').map((ch, i) => (
39
+ <Text key={i} color={timeColor}>{ch}</Text>
40
+ ))}
41
+ </Text>
42
+ <Text color={statusColor} dimColor={isEnded}>
43
+ {statusLabel}
44
+ </Text>
45
+ </Box>
46
+ )
47
+ }
@@ -0,0 +1,67 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { STATE } from '../../shared/protocol.mjs'
4
+
5
+ interface Session {
6
+ sessionId: string
7
+ projectDir: string
8
+ task: string
9
+ state: string
10
+ remaining: number
11
+ overtime: number
12
+ queuedNotifications: number
13
+ plannedMs: number
14
+ }
15
+
16
+ function fmtMs(ms: number): string {
17
+ const s = Math.floor(Math.abs(ms) / 1000)
18
+ const m = Math.floor(s / 60)
19
+ const sec = s % 60
20
+ return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
21
+ }
22
+
23
+ function projectName(dir: string): string {
24
+ return dir.split('/').filter(Boolean).pop() ?? dir
25
+ }
26
+
27
+ interface ActiveSessionsProps {
28
+ sessions: Session[]
29
+ width: number
30
+ }
31
+
32
+ export function ActiveSessions({ sessions, width }: ActiveSessionsProps) {
33
+ if (sessions.length === 0) {
34
+ return (
35
+ <Box marginBottom={1}>
36
+ <Text color="gray" dimColor> No active sessions</Text>
37
+ </Box>
38
+ )
39
+ }
40
+
41
+ return (
42
+ <Box flexDirection="column" marginBottom={1}>
43
+ <Text bold color="white"> Active Sessions</Text>
44
+ {sessions.map((s) => {
45
+ const isOvertime = s.state === STATE.OVERTIME
46
+ const timeDisplay = isOvertime ? `+${fmtMs(s.overtime)}` : fmtMs(s.remaining)
47
+ const timeColor = isOvertime ? 'yellow' : 'green'
48
+ const proj = projectName(s.projectDir)
49
+ const taskTrunc = s.task
50
+ ? s.task.slice(0, width - 28)
51
+ : '(no task)'
52
+ const queued = s.queuedNotifications > 0
53
+ ? ` [${s.queuedNotifications} queued]`
54
+ : ''
55
+
56
+ return (
57
+ <Box key={s.sessionId} paddingLeft={1} marginTop={0}>
58
+ <Text color={timeColor} bold>{timeDisplay} </Text>
59
+ <Text color="cyan">{proj.slice(0, 14).padEnd(14)} </Text>
60
+ <Text>{taskTrunc}</Text>
61
+ {queued ? <Text color="yellow">{queued}</Text> : null}
62
+ </Box>
63
+ )
64
+ })}
65
+ </Box>
66
+ )
67
+ }
@@ -0,0 +1,110 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { Box, Text, useInput, useStdout } from 'ink'
3
+ import { subscribe } from '../../shared/ipcClient.mjs'
4
+ import { EVT, STATE } from '../../shared/protocol.mjs'
5
+ import { getStats } from '../../daemon/db.mjs'
6
+ import { ActiveSessions } from './ActiveSessions.js'
7
+ import { ProjectChart } from './ProjectChart.js'
8
+ import { RecentSessions } from './RecentSessions.js'
9
+
10
+ interface SessionSnapshot {
11
+ sessionId: string
12
+ projectDir: string
13
+ task: string
14
+ state: string
15
+ remaining: number
16
+ overtime: number
17
+ queuedNotifications: number
18
+ startedAt: number
19
+ plannedMs: number
20
+ }
21
+
22
+ export function DashboardApp() {
23
+ const { stdout } = useStdout()
24
+ const width = stdout?.columns ?? 80
25
+
26
+ const [sessions, setSessions] = useState<SessionSnapshot[]>([])
27
+ const [stats, setStats] = useState<any[]>([])
28
+ const [recent, setRecent] = useState<any[]>([])
29
+ const [tick, setTick] = useState(0)
30
+
31
+ // Load stats from DB on mount and after session ends
32
+ const refreshStats = () => {
33
+ try {
34
+ const s = getStats()
35
+ setStats(s.projects)
36
+ setRecent(s.recent)
37
+ } catch {}
38
+ }
39
+
40
+ useEffect(() => {
41
+ refreshStats()
42
+
43
+ const conn = subscribe(
44
+ (event) => {
45
+ if (event.type === EVT.SNAPSHOT) {
46
+ setSessions(event.sessions ?? [])
47
+ }
48
+ if (event.type === EVT.TICK) {
49
+ // Update the specific session
50
+ setSessions((prev) => prev.map((s) =>
51
+ s.sessionId === event.sessionId
52
+ ? { ...s, state: event.state, remaining: event.remaining, overtime: event.overtime, queuedNotifications: event.queuedNotifications }
53
+ : s
54
+ ))
55
+ setTick((t) => t + 1)
56
+ }
57
+ if (event.type === EVT.SESSION_ENDED) {
58
+ setSessions((prev) => prev.filter((s) => s.sessionId !== event.sessionId))
59
+ refreshStats()
60
+ }
61
+ },
62
+ () => {} // ignore disconnect errors — daemon is in-process
63
+ )
64
+
65
+ return () => conn.close()
66
+ }, [])
67
+
68
+ useInput((input, key) => {
69
+ if (input === 'q' || key.escape) {
70
+ process.exit(0)
71
+ }
72
+ })
73
+
74
+ const activeSessions = sessions.filter(
75
+ (s) => s.state === STATE.RUNNING || s.state === STATE.OVERTIME
76
+ )
77
+
78
+ return (
79
+ <Box flexDirection="column" width={width} paddingX={1} paddingY={0}>
80
+ {/* Header */}
81
+ <Box marginBottom={1}>
82
+ <Text bold color="red">🍅 Pomodoro</Text>
83
+ <Text color="gray"> daemon running · </Text>
84
+ <Text color="green">{activeSessions.length} active session{activeSessions.length !== 1 ? 's' : ''}</Text>
85
+ <Text color="gray"> [Q] Quit daemon</Text>
86
+ </Box>
87
+
88
+ {/* Active sessions */}
89
+ <ActiveSessions sessions={activeSessions} width={width} />
90
+
91
+ {/* Project stats bar chart */}
92
+ {stats.length > 0 && (
93
+ <ProjectChart projects={stats} width={width} />
94
+ )}
95
+
96
+ {/* Recent sessions */}
97
+ {recent.length > 0 && (
98
+ <RecentSessions sessions={recent} width={width} />
99
+ )}
100
+
101
+ {activeSessions.length === 0 && stats.length === 0 && (
102
+ <Box marginTop={1}>
103
+ <Text color="gray" dimColor>
104
+ No sessions yet. Start one with: /pomodoro &lt;task&gt;
105
+ </Text>
106
+ </Box>
107
+ )}
108
+ </Box>
109
+ )
110
+ }
@@ -0,0 +1,60 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ interface Project {
5
+ project_path: string
6
+ total_sessions: number
7
+ total_minutes: number
8
+ total_cycles: number
9
+ last_active: number
10
+ }
11
+
12
+ interface ProjectChartProps {
13
+ projects: Project[]
14
+ width: number
15
+ }
16
+
17
+ const BAR_CHARS = '▏▎▍▌▋▊▉█'
18
+
19
+ function bar(value: number, max: number, width: number): string {
20
+ if (max === 0) return '░'.repeat(width)
21
+ const filled = Math.round((value / max) * width)
22
+ const empty = width - filled
23
+ return '█'.repeat(filled) + '░'.repeat(empty)
24
+ }
25
+
26
+ function projectName(dir: string): string {
27
+ return dir.split('/').filter(Boolean).pop() ?? dir
28
+ }
29
+
30
+ function fmtMinutes(min: number): string {
31
+ if (min < 60) return `${min}m`
32
+ const h = Math.floor(min / 60)
33
+ const m = min % 60
34
+ return m > 0 ? `${h}h ${m}m` : `${h}h`
35
+ }
36
+
37
+ export function ProjectChart({ projects, width }: ProjectChartProps) {
38
+ const BAR_WIDTH = Math.min(30, width - 36)
39
+ const maxMinutes = Math.max(...projects.map((p) => p.total_minutes ?? 0), 1)
40
+
41
+ return (
42
+ <Box flexDirection="column" marginBottom={1}>
43
+ <Text bold color="white"> Project Focus Time</Text>
44
+ {projects.slice(0, 8).map((p) => {
45
+ const name = projectName(p.project_path).slice(0, 16).padEnd(16)
46
+ const mins = p.total_minutes ?? 0
47
+ const b = bar(mins, maxMinutes, BAR_WIDTH)
48
+ const label = fmtMinutes(mins).padStart(7)
49
+
50
+ return (
51
+ <Box key={p.project_path} paddingLeft={1}>
52
+ <Text color="cyan">{name} </Text>
53
+ <Text color="green">{b}</Text>
54
+ <Text color="white">{label}</Text>
55
+ </Box>
56
+ )
57
+ })}
58
+ </Box>
59
+ )
60
+ }
@@ -0,0 +1,79 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ interface RecentSession {
5
+ task: string
6
+ actual_ms: number
7
+ status: string
8
+ started_at: number
9
+ project_path: string
10
+ agent_summary?: string
11
+ user_activity?: string
12
+ }
13
+
14
+ function fmtMins(ms: number): string {
15
+ if (!ms) return '—'
16
+ const m = Math.round(ms / 60000)
17
+ return `${m}m`
18
+ }
19
+
20
+ function projectName(dir: string): string {
21
+ return (dir.split('/').filter(Boolean).pop() ?? dir).slice(0, 12)
22
+ }
23
+
24
+ function fmtDate(ts: number): string {
25
+ return new Date(ts).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
26
+ }
27
+
28
+ function statusColor(s: string): string {
29
+ return s === 'completed' ? 'green' : s === 'broken' ? 'red' : 'yellow'
30
+ }
31
+
32
+ interface RecentSessionsProps {
33
+ sessions: RecentSession[]
34
+ width: number
35
+ }
36
+
37
+ export function RecentSessions({ sessions, width }: RecentSessionsProps) {
38
+ return (
39
+ <Box flexDirection="column">
40
+ <Text bold color="white"> Recent Sessions</Text>
41
+
42
+ {sessions.map((s, i) => {
43
+ const proj = projectName(s.project_path)
44
+ const task = s.task ?? '—'
45
+ const time = fmtMins(s.actual_ms)
46
+ const date = fmtDate(s.started_at)
47
+
48
+ return (
49
+ <Box key={i} flexDirection="column" paddingLeft={1} marginBottom={0}>
50
+ {/* Session header line */}
51
+ <Box>
52
+ <Text color="gray">{date} </Text>
53
+ <Text color="cyan">{proj.padEnd(12)} </Text>
54
+ <Text color="white" bold>{task.slice(0, width - 38)}</Text>
55
+ <Text color="gray"> {time} </Text>
56
+ <Text color={statusColor(s.status)}>{s.status}</Text>
57
+ </Box>
58
+
59
+ {/* Agent summary */}
60
+ {s.agent_summary && (
61
+ <Box paddingLeft={8}>
62
+ <Text color="gray">🤖 </Text>
63
+ <Text color="gray" dimColor>{s.agent_summary.split('\n')[0].slice(0, width - 16)}</Text>
64
+ </Box>
65
+ )}
66
+
67
+ {/* User activity */}
68
+ {s.user_activity && (
69
+ <Box paddingLeft={8}>
70
+ <Text color="blue">👤 </Text>
71
+ <Text color="blueBright">{s.user_activity.slice(0, width - 16)}</Text>
72
+ </Box>
73
+ )}
74
+ </Box>
75
+ )
76
+ })}
77
+ </Box>
78
+ )
79
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Dashboard TUI entry point.
4
+ * Starts the global daemon then renders the live dashboard in current terminal.
5
+ *
6
+ * Usage: pomodoro daemon
7
+ */
8
+ import React from 'react'
9
+ import { render } from 'ink'
10
+ import { startDaemon } from '../../daemon/index.mjs'
11
+ import { DashboardApp } from './DashboardApp.js'
12
+ import { readLock } from '../../shared/lockfile.mjs'
13
+
14
+ async function main() {
15
+ const existing = readLock()
16
+ if (existing) {
17
+ console.error('Pomodoro daemon is already running (PID ' + existing.pid + ').')
18
+ console.error('Run `pomodoro stop-daemon` to stop it first.')
19
+ process.exit(1)
20
+ }
21
+
22
+ // Start the daemon in-process (not detached — this terminal IS the daemon)
23
+ await startDaemon()
24
+
25
+ render(<DashboardApp />, { exitOnCtrlC: false })
26
+ }
27
+
28
+ main().catch((err) => {
29
+ console.error('Dashboard error:', err)
30
+ process.exit(1)
31
+ })
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * TUI entry point — launched in a separate terminal window by `pomodoro start`.
4
+ *
5
+ * Usage: tsx src/tui/index.tsx <projectDir>
6
+ */
7
+ import React from 'react'
8
+ import { render } from 'ink'
9
+ import { App } from './App.js'
10
+ import { readLock } from '../shared/lockfile.mjs'
11
+ import { sendAndReceive } from '../shared/ipcClient.mjs'
12
+
13
+ const projectDir = process.argv[2] ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
14
+
15
+ async function main() {
16
+ // Wait for daemon to be ready (in case TUI starts slightly before daemon writes lock)
17
+ const lock = await waitForLock(projectDir, 5000)
18
+ if (!lock) {
19
+ console.error('Could not connect to Pomodoro daemon. Is it running?')
20
+ console.error(`Project: ${projectDir}`)
21
+ process.exit(1)
22
+ }
23
+
24
+ // Fetch initial state
25
+ let initialState: any = {}
26
+ try {
27
+ initialState = await sendAndReceive(lock.socketPath, { type: 'query' })
28
+ } catch {}
29
+
30
+ render(
31
+ <App
32
+ socketPath={lock.socketPath}
33
+ task={initialState.task ?? lock.task ?? ''}
34
+ plannedMs={initialState.plannedMs ?? lock.durationMs ?? 25 * 60 * 1000}
35
+ />,
36
+ { exitOnCtrlC: false }
37
+ )
38
+ }
39
+
40
+ function waitForLock(projectDir: string, timeoutMs: number): Promise<any> {
41
+ return new Promise((resolve) => {
42
+ const start = Date.now()
43
+ const check = () => {
44
+ const lock = readLock(projectDir)
45
+ if (lock) return resolve(lock)
46
+ if (Date.now() - start > timeoutMs) return resolve(null)
47
+ setTimeout(check, 100)
48
+ }
49
+ check()
50
+ })
51
+ }
52
+
53
+ main().catch((err) => {
54
+ console.error('TUI error:', err)
55
+ process.exit(1)
56
+ })
@@ -0,0 +1,52 @@
1
+ import { subscribe, sendAndReceive } from '../shared/ipcClient.mjs'
2
+ import { EVT } from '../shared/protocol.mjs'
3
+
4
+ /**
5
+ * TUI-specific IPC client.
6
+ * Connects to daemon, subscribes to push events, and emits them via callbacks.
7
+ */
8
+ export class TuiIpcClient {
9
+ #conn = null
10
+ #socketPath
11
+
12
+ constructor(socketPath) {
13
+ this.#socketPath = socketPath
14
+ }
15
+
16
+ connect({ onTick, onNotification, onEnded, onError }) {
17
+ this.#conn = subscribe(
18
+ this.#socketPath,
19
+ (event) => {
20
+ switch (event.type) {
21
+ case EVT.TICK:
22
+ case EVT.STATE:
23
+ onTick?.(event)
24
+ break
25
+ case EVT.NOTIFICATION:
26
+ onNotification?.(event)
27
+ break
28
+ case EVT.ENDED:
29
+ onEnded?.(event)
30
+ break
31
+ }
32
+ },
33
+ onError,
34
+ )
35
+ }
36
+
37
+ async sendEnd() {
38
+ try {
39
+ await sendAndReceive(this.#socketPath, { type: 'end' })
40
+ } catch {}
41
+ }
42
+
43
+ async sendBreak() {
44
+ try {
45
+ await sendAndReceive(this.#socketPath, { type: 'break' })
46
+ } catch {}
47
+ }
48
+
49
+ close() {
50
+ this.#conn?.close()
51
+ }
52
+ }
@@ -0,0 +1,164 @@
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { Box, Text, useInput, useStdout } from 'ink'
3
+ import TextInput from 'ink-text-input'
4
+ import { subscribe, sendAndReceive } from '../../shared/ipcClient.mjs'
5
+ import { EVT, MSG, STATE } from '../../shared/protocol.mjs'
6
+ import { Timer } from '../components/Timer.js'
7
+ import { TaskBar } from '../components/TaskBar.js'
8
+ import { NotificationLog } from '../components/NotificationLog.js'
9
+
10
+ interface TimerAppProps {
11
+ sessionId: string
12
+ task: string
13
+ plannedMs: number
14
+ initialState: string
15
+ initialRemaining: number
16
+ initialOvertime: number
17
+ }
18
+
19
+ interface NotificationEntry {
20
+ notification: Record<string, unknown>
21
+ receivedAt: number
22
+ releasedAt: number
23
+ }
24
+
25
+ type Phase = 'timer' | 'activity-input' | 'done'
26
+
27
+ export function TimerApp({
28
+ sessionId,
29
+ task,
30
+ plannedMs,
31
+ initialState,
32
+ initialRemaining,
33
+ initialOvertime,
34
+ }: TimerAppProps) {
35
+ const { stdout } = useStdout()
36
+ const width = stdout?.columns ?? 60
37
+
38
+ const [timerState, setTimerState] = useState(initialState)
39
+ const [remaining, setRemaining] = useState(initialRemaining)
40
+ const [overtime, setOvertime] = useState(initialOvertime)
41
+ const [queued, setQueued] = useState(0)
42
+ const [notifications, setNotifications] = useState<NotificationEntry[]>([])
43
+ const [phase, setPhase] = useState<Phase>('timer')
44
+ const [userActivity, setUserActivity] = useState('')
45
+ const [activitySaved, setActivitySaved] = useState(false)
46
+
47
+ useEffect(() => {
48
+ const conn = subscribe(
49
+ (event) => {
50
+ if (event.sessionId !== sessionId) return
51
+
52
+ if (event.type === EVT.TICK) {
53
+ setTimerState(event.state)
54
+ setRemaining(event.remaining ?? 0)
55
+ setOvertime(event.overtime ?? 0)
56
+ setQueued(event.queuedNotifications ?? 0)
57
+ }
58
+ if (event.type === EVT.NOTIFICATION) {
59
+ setNotifications((prev) => [...prev, {
60
+ notification: event.notification,
61
+ receivedAt: event.receivedAt,
62
+ releasedAt: event.releasedAt,
63
+ }])
64
+ }
65
+ if (event.type === EVT.SESSION_ENDED) {
66
+ setTimerState(STATE.ENDED)
67
+ setPhase('activity-input')
68
+ }
69
+ },
70
+ () => {}
71
+ )
72
+ return () => conn.close()
73
+ }, [sessionId])
74
+
75
+ const saveAndExit = useCallback(async (activity: string) => {
76
+ if (activity.trim()) {
77
+ try {
78
+ await sendAndReceive({
79
+ type: MSG.SESSION_UPDATE_ACTIVITY,
80
+ sessionId,
81
+ userActivity: activity.trim(),
82
+ })
83
+ } catch {}
84
+ }
85
+ setActivitySaved(true)
86
+ setTimeout(() => process.exit(0), 600)
87
+ }, [sessionId])
88
+
89
+ const sendEnd = useCallback(async () => {
90
+ try { await sendAndReceive({ type: MSG.SESSION_END, sessionId }) } catch {}
91
+ }, [sessionId])
92
+
93
+ const sendBreak = useCallback(async () => {
94
+ try { await sendAndReceive({ type: MSG.SESSION_BREAK, sessionId }) } catch {}
95
+ }, [sessionId])
96
+
97
+ useInput((input, key) => {
98
+ if (phase === 'activity-input') return // TextInput handles keys in this phase
99
+ if (phase === 'done') {
100
+ if (input === 'q' || key.escape) process.exit(0)
101
+ return
102
+ }
103
+ if (timerState === STATE.ENDED) return
104
+ if (input === 'e' || input === 'E') sendEnd()
105
+ if (input === 'b' || input === 'B') sendBreak()
106
+ if (input === 'q' || key.escape) process.exit(0)
107
+ })
108
+
109
+ const isOvertime = timerState === STATE.OVERTIME
110
+ const isEnded = timerState === STATE.ENDED
111
+
112
+ // ── Activity input phase ──────────────────────────────────────────────────
113
+ if (phase === 'activity-input') {
114
+ return (
115
+ <Box flexDirection="column" width={width} paddingX={2} paddingY={1}>
116
+ <Box marginBottom={1}>
117
+ <Text bold color="green">🍅 Session ended</Text>
118
+ {task ? <Text color="gray"> {task.slice(0, width - 24)}</Text> : null}
119
+ </Box>
120
+
121
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={2} paddingY={1} marginBottom={1}>
122
+ <Text bold color="cyan">你在这个番茄钟里做了什么?</Text>
123
+ <Text color="gray" dimColor>(可不填,直接 Enter 跳过)</Text>
124
+ <Box marginTop={1}>
125
+ <Text color="white">› </Text>
126
+ <TextInput
127
+ value={userActivity}
128
+ onChange={setUserActivity}
129
+ onSubmit={saveAndExit}
130
+ placeholder="简要描述你的专注内容..."
131
+ />
132
+ </Box>
133
+ </Box>
134
+
135
+ {activitySaved && (
136
+ <Text color="green">已保存,正在退出…</Text>
137
+ )}
138
+ </Box>
139
+ )
140
+ }
141
+
142
+ // ── Timer phase ───────────────────────────────────────────────────────────
143
+ const keybindings = isEnded
144
+ ? '[Q] Quit'
145
+ : isOvertime
146
+ ? '[E] End Session [B] Break [Q] Quit'
147
+ : '[B] Break [Q] Quit'
148
+
149
+ return (
150
+ <Box flexDirection="column" width={width} paddingX={2} paddingY={1}>
151
+ <Box marginBottom={1}>
152
+ <Text bold color={isOvertime ? 'yellow' : isEnded ? 'gray' : 'red'}>🍅 Pomodoro</Text>
153
+ </Box>
154
+
155
+ <Timer state={timerState} remaining={remaining} overtime={overtime} />
156
+ <TaskBar task={task} width={width} />
157
+ <NotificationLog notifications={notifications} queued={queued} width={width - 4} />
158
+
159
+ <Box marginTop={1}>
160
+ <Text color="gray" dimColor>{keybindings}</Text>
161
+ </Box>
162
+ </Box>
163
+ )
164
+ }