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.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/bin/pomodoro.mjs +249 -0
- package/install.mjs +170 -0
- package/package.json +49 -0
- package/src/daemon/db.mjs +209 -0
- package/src/daemon/index.mjs +212 -0
- package/src/daemon/ipc.mjs +96 -0
- package/src/daemon/notificationQueue.mjs +39 -0
- package/src/daemon/session.mjs +109 -0
- package/src/daemon/timer.mjs +100 -0
- package/src/hooks/notification.mjs +53 -0
- package/src/hooks/preToolUse.mjs +38 -0
- package/src/hooks/stop.mjs +70 -0
- package/src/shared/config.mjs +58 -0
- package/src/shared/ipcClient.mjs +73 -0
- package/src/shared/lockfile.mjs +51 -0
- package/src/shared/protocol.mjs +44 -0
- package/src/tui/App.tsx +149 -0
- package/src/tui/components/NotificationLog.tsx +60 -0
- package/src/tui/components/TaskBar.tsx +23 -0
- package/src/tui/components/Timer.tsx +47 -0
- package/src/tui/dashboard/ActiveSessions.tsx +67 -0
- package/src/tui/dashboard/DashboardApp.tsx +110 -0
- package/src/tui/dashboard/ProjectChart.tsx +60 -0
- package/src/tui/dashboard/RecentSessions.tsx +79 -0
- package/src/tui/dashboard/index.tsx +31 -0
- package/src/tui/index.tsx +56 -0
- package/src/tui/ipcClient.mjs +52 -0
- package/src/tui/timer/TimerApp.tsx +164 -0
- package/src/tui/timer/index.tsx +51 -0
- package/src/watcher.mjs +51 -0
|
@@ -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 <task>
|
|
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
|
+
}
|