冥想呼吸面板:/meditate 开始一段定时的引导呼吸(方箱 / 4-7-8 / 平静),记录累计次数与连续天数

中文 | English
Claude Code 里的冥想呼吸面板。输入 /meditate,终端侧边弹出一尊 ASCII 佛祖, 跟着节奏敲木鱼带你呼吸;结束后记录累计次数与连续天数。

1 分钟示范:方箱呼吸,按 m 切到 4-7-8,按 p 暂停,按 q 结束。
🧘 方箱呼吸 4-4-4-4 剩余 04:32
_ooOoo_
o8888888o
88" . "88
(| ^_^ |) ((( 咚
O\ = /O o
____/`---'\____ ,--.
.' \\| |// `. (____)
/ \\||| : |||// \
/ _||||| -:- |||||_ \
| | \\\ - /// | |
| \_| ''\---/'' | |
\ .-\__ `-` ___/-. /
`-.________________.-'
`=---='
吸气 · 3 敲了 7 下
[p 暂停] [m 换模式] [q 结束]
累计 12 次 · 58 分钟 · 连续 4 天
p 暂停 / 继续、m 切换模式、q 结束;关掉面板即结束claude plugin marketplace add mindthink/zen-breath
claude plugin install zen-breath@zen-breath
git clone https://github.com/mindthink/zen-breath.git ~/.claude/mods/zen-breath
claude --plugin-dir ~/.claude/mods/zen-breath
想每次启动都自动加载(包括桌面端),在 ~/.claude/settings.json 里加:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/zen-breath"
}
}
/meditate # 默认 5 分钟,沿用上次的模式
/meditate 10 # 10 分钟
/meditate 3 relax # 3 分钟 4-7-8 放松呼吸
/meditate calm # 5 分钟平静呼吸
/meditate stats # 查看累计记录
/meditate stop # 结束当前冥想
模式参数:box(方箱 4-4-4-4)、relax(放松 4-7-8)、calm(平静 5-5)。时长 1 到 120 分钟。
这是一个 Claude Code hooks mod(function hooks 插件),不依赖 Node,整个逻辑在 hooks/register.tsx 一个文件里。
claude plugin validate . # 校验清单和 hooks 模块
claude plugin test . # 跑 tests/ 里的测试
claude --plugin-dir . # 在交互会话里加载,保存文件即热重载
.claude-plugin/types/ 下的类型声明由 Claude Code 在加载 mod 时自动生成, 不入库;加载过一次后 tsc -p . 即可做类型检查。
目录结构:
.claude-plugin/plugin.json 插件清单
.claude-plugin/marketplace.json 让本仓库可直接作为 marketplace 安装
hooks/hooks.json 指向 hooks 模块
hooks/register.tsx 命令、计时器、面板渲染
types/index.d.ts $.state 的类型契约(Session / Stats)
tests/zen-breath.test.ts 倒计时、暂停、切模式、参数校验的测试
hooks/register.tsx 352 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pattern, Phase, Session, Stats } from '../types'
5
6const PANE = 'zen-breath'
7const DEFAULT_MINUTES = 5
8
9const PATTERNS: Record<Pattern, { name: string; phases: Phase[] }> = {
10 box: {
11 name: '方箱呼吸 4-4-4-4',
12 phases: [
13 { label: '吸气', seconds: 4, kind: 'in' },
14 { label: '屏息', seconds: 4, kind: 'hold' },
15 { label: '呼气', seconds: 4, kind: 'out' },
16 { label: '屏息', seconds: 4, kind: 'hold' },
17 ],
18 },
19 relax: {
20 name: '放松呼吸 4-7-8',
21 phases: [
22 { label: '吸气', seconds: 4, kind: 'in' },
23 { label: '屏息', seconds: 7, kind: 'hold' },
24 { label: '呼气', seconds: 8, kind: 'out' },
25 ],
26 },
27 calm: {
28 name: '平静呼吸 5-5',
29 phases: [
30 { label: '吸气', seconds: 5, kind: 'in' },
31 { label: '呼气', seconds: 5, kind: 'out' },
32 ],
33 },
34}
35const PATTERN_ORDER: Pattern[] = ['box', 'relax', 'calm']
36
37const IDLE: Session = {
38 status: 'idle',
39 pattern: 'box',
40 totalSeconds: DEFAULT_MINUTES * 60,
41 elapsedSeconds: 0,
42 phaseIndex: 0,
43 phaseElapsed: 0,
44 knocks: 0,
45}
46const NO_STATS: Stats = { sessions: 0, minutes: 0, lastDay: '', streak: 0 }
47
48const session = atom({ plugin: 'zen-breath', key: 'session' } as const, IDLE)
49const stats = atom({ plugin: 'zen-breath', key: 'stats' } as const, NO_STATS)
50
51// ---------- pure helpers ----------
52
53function isPattern(word: string): word is Pattern {
54 return (PATTERN_ORDER as string[]).includes(word)
55}
56
57function isStats(value: unknown): value is Stats {
58 if (typeof value !== 'object' || value === null) return false
59 const v = value as Record<string, unknown>
60 return (
61 typeof v.sessions === 'number' &&
62 typeof v.minutes === 'number' &&
63 typeof v.lastDay === 'string' &&
64 typeof v.streak === 'number'
65 )
66}
67
68function phaseOf(s: Session): Phase {
69 const phases = PATTERNS[s.pattern].phases
70 return phases[s.phaseIndex] ?? phases[0]!
71}
72
73/** One second of a running session; anything else is left as it is. */
74function step(s: Session): Session {
75 if (s.status !== 'running') return s
76 const elapsed = s.elapsedSeconds + 1
77 if (elapsed >= s.totalSeconds) {
78 return { ...s, status: 'done', elapsedSeconds: s.totalSeconds }
79 }
80 const phases = PATTERNS[s.pattern].phases
81 let phaseElapsed = s.phaseElapsed + 1
82 let phaseIndex = s.phaseIndex
83 let knocks = s.knocks
84 if (phaseElapsed >= phaseOf(s).seconds) {
85 phaseElapsed = 0
86 phaseIndex = (phaseIndex + 1) % phases.length
87 knocks += 1
88 }
89 return { ...s, elapsedSeconds: elapsed, phaseElapsed, phaseIndex, knocks }
90}
91
92/** The mallet lands on the first second of every phase: one knock per breath step. */
93function isStriking(s: Session): boolean {
94 return s.status === 'running' && s.phaseElapsed === 0
95}
96
97const BUDDHA: readonly string[] = [
98 ' _ooOoo_',
99 ' o8888888o',
100 ' 88" . "88',
101 ' (| -_- |)',
102 ' O\\ = /O',
103 " ____/`---'\\____",
104 ' .\' \\\\| |// `.',
105 ' / \\\\||| : |||// \\',
106 '/ _||||| -:- |||||_ \\',
107 '| | \\\\\\ - /// | |',
108 "| \\_| ''\\---/'' | |",
109 '\\ .-\\__ `-` ___/-. /',
110 " `-.________________.-'",
111 " `=---='",
112]
113const EYES_ROW = 3
114
115/** Mallet up, and mallet down on the wooden fish. */
116const MALLET_UP: readonly string[] = [' \\', ' o', '', ' ,--.', ' (____)']
117const MALLET_DOWN: readonly string[] = ['', ' ((( 咚', ' o', ' ,--.', ' (____)']
118
119function fmt(seconds: number): string {
120 const m = Math.floor(seconds / 60)
121 const sec = seconds % 60
122 return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
123}
124
125function dayOf(ms: number): string {
126 const d = new Date(ms)
127 const pad = (n: number) => String(n).padStart(2, '0')
128 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
129}
130
131function daysBetween(a: string, b: string): number {
132 if (!a || !b) return Number.NaN
133 return Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000)
134}
135
136function bump(st: Stats, minutes: number, today: string): Stats {
137 const gap = daysBetween(st.lastDay, today)
138 const streak = gap === 0 ? Math.max(1, st.streak) : gap === 1 ? st.streak + 1 : 1
139 return {
140 sessions: st.sessions + 1,
141 minutes: st.minutes + minutes,
142 lastDay: today,
143 streak,
144 }
145}
146
147function statusLine(s: Session): string | undefined {
148 if (s.status === 'running') {
149 return `🧘 ${phaseOf(s).label} · 剩余 ${fmt(s.totalSeconds - s.elapsedSeconds)}`
150 }
151 if (s.status === 'paused') return `🧘 已暂停 · 剩余 ${fmt(s.totalSeconds - s.elapsedSeconds)}`
152 return undefined
153}
154
155function usage(): string {
156 return '用法:/meditate [分钟] [box|relax|calm],或 /meditate stop | stats'
157}
158
159// ---------- the ticker ----------
160
161async function tick($: EngineInterface): Promise<void> {
162 const before = await read($, session)
163 if (before.status !== 'running') return
164 const after = await update($, session, s => (s.status === 'running' ? step(s) : s))
165 $.ui.status(statusLine(after))
166 if (after.status === 'done') await finish($, after)
167}
168
169async function finish($: EngineInterface, s: Session): Promise<void> {
170 const minutes = Math.max(1, Math.round(s.totalSeconds / 60))
171 const today = dayOf(await $.clock.now())
172 const st = await update($, stats, old => bump(old, minutes, today))
173 await $.store.set('stats', st)
174 $.ui.toast(`🧘 冥想完成,${minutes} 分钟。累计 ${st.sessions} 次 · 连续 ${st.streak} 天`, {
175 timeoutMs: 8000,
176 })
177}
178
179async function start($: EngineInterface, pattern: Pattern, minutes: number): Promise<void> {
180 const fresh = await update($, session, () => ({
181 ...IDLE,
182 pattern,
183 totalSeconds: minutes * 60,
184 status: 'running' as const,
185 }))
186 $.ui.status(statusLine(fresh))
187}
188
189async function stop($: EngineInterface): Promise<void> {
190 await update($, session, s => ({ ...s, status: 'idle' as const, elapsedSeconds: 0, phaseIndex: 0, phaseElapsed: 0, knocks: 0 }))
191 $.ui.status(undefined)
192}
193
194// ---------- hooks ----------
195
196export const register: Register = on => {
197 on('session.start', async ($, e, next) => {
198 await $.command.register({
199 name: 'meditate',
200 description: '冥想呼吸:开一段定时的引导呼吸面板(方箱 / 4-7-8 / 平静)',
201 argumentHint: '[分钟] [box|relax|calm|stop|stats]',
202 })
203 const saved = await $.store.get('stats')
204 if (isStats(saved)) await update($, stats, () => saved)
205 $.clock.every(1000, () => {
206 void tick($)
207 })
208 return next(e)
209 })
210
211 on('command.run', { command: 'meditate' }, async ($, e) => {
212 const words = e.args.trim().split(/\s+/).filter(Boolean)
213
214 if (words[0] === 'stop') {
215 await stop($)
216 await $.ui.close({ id: PANE })
217 return { text: '冥想已结束。' }
218 }
219 if (words[0] === 'stats') {
220 const st = await read($, stats)
221 return {
222 text:
223 st.sessions === 0
224 ? '还没有记录。用 /meditate 5 开始第一次。'
225 : `累计 ${st.sessions} 次 · ${st.minutes} 分钟 · 连续 ${st.streak} 天(上次 ${st.lastDay})`,
226 }
227 }
228
229 let minutes = DEFAULT_MINUTES
230 let pattern: Pattern = (await read($, session)).pattern
231 for (const word of words) {
232 const n = Number(word)
233 if (Number.isFinite(n) && n > 0) minutes = Math.min(120, Math.max(1, Math.round(n)))
234 else if (isPattern(word)) pattern = word
235 else return { text: `不认识的参数「${word}」。${usage()}` }
236 }
237
238 await start($, pattern, minutes)
239 const opened = await $.ui.open({ id: PANE, title: '冥想', focus: true })
240 const head = `开始 ${minutes} 分钟 · ${PATTERNS[pattern].name}。`
241 return {
242 text: opened.isPlaced
243 ? `${head} 面板里 p 暂停、m 换模式、q 结束。`
244 : `${head} 终端太窄,面板暂不显示;进度见下方状态栏,/meditate stop 结束。`,
245 }
246 })
247
248 // The person closing the pane (close mark or Escape) ends the session too.
249 on('ui.close', async ($, e, next) => {
250 if (e.id === PANE && e.origin.kind !== 'unload') await stop($)
251 return next(e)
252 })
253
254 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255 const { Box, Text, Button } = $.ui.resolve(e)
256 const s = await read($, session)
257 const st = await read($, stats)
258 const pattern = PATTERNS[s.pattern]
259 const phase = phaseOf(s)
260 const remaining = fmt(s.totalSeconds - s.elapsedSeconds)
261 const phaseLeft = Math.max(0, phase.seconds - s.phaseElapsed)
262 const isLive = s.status === 'running' || s.status === 'paused'
263 const striking = isStriking(s)
264 const eyes = striking || s.status === 'done' ? '^_^' : s.status === 'paused' ? '-.-' : '-_-'
265 const buddha = BUDDHA.map((line, i) => (i === EYES_ROW ? line.replace('-_-', eyes) : line))
266 const mallet = striking ? MALLET_DOWN : MALLET_UP
267
268 const toggle = () =>
269 update($, session, cur =>
270 cur.status === 'running'
271 ? { ...cur, status: 'paused' as const }
272 : cur.status === 'paused'
273 ? { ...cur, status: 'running' as const }
274 : { ...cur, status: 'running' as const, elapsedSeconds: 0, phaseIndex: 0, phaseElapsed: 0, knocks: 0 },
275 ).then(next => $.ui.status(statusLine(next)))
276
277 const cycle = () =>
278 update($, session, cur => {
279 const i = PATTERN_ORDER.indexOf(cur.pattern)
280 const nextPattern = PATTERN_ORDER[(i + 1) % PATTERN_ORDER.length] ?? 'box'
281 return { ...cur, pattern: nextPattern, phaseIndex: 0, phaseElapsed: 0, knocks: 0 }
282 }).then(next => $.ui.status(statusLine(next)))
283
284 // Our own ui.close call does not pass through our ui.close hook, so stop first.
285 const quit = () => stop($).then(() => $.ui.close({ id: PANE }))
286
287 return (
288 <Box flexDirection="column" paddingX={1}>
289 <Box justifyContent="space-between">
290 <Text bold>🧘 {pattern.name}</Text>
291 <Text dimColor={!isLive}>
292 {s.status === 'done' ? '完成' : `剩余 ${remaining}`}
293 </Text>
294 </Box>
295 <Text> </Text>
296
297 <Box>
298 <Box flexDirection="column">
299 {buddha.map((line, i) => (
300 <Text key={`b${i}`} color={i === EYES_ROW && striking ? 'yellow' : undefined}>
301 {line}
302 </Text>
303 ))}
304 </Box>
305 <Box flexDirection="column" marginLeft={2} justifyContent="flex-end">
306 {mallet.map((line, i) => (
307 <Text key={`m${i}`} color={striking && i === 1 ? 'yellow' : undefined} bold={striking && i === 1}>
308 {line || ' '}
309 </Text>
310 ))}
311 </Box>
312 </Box>
313
314 <Text> </Text>
315 <Box justifyContent="space-between">
316 <Text bold={s.status === 'running'} dimColor={!isLive}>
317 {s.status === 'done'
318 ? `✓ 完成 ${Math.max(1, Math.round(s.totalSeconds / 60))} 分钟`
319 : s.status === 'paused'
320 ? '已暂停'
321 : s.status === 'idle'
322 ? '按「开始」或输入 /meditate [分钟]'
323 : `${phase.label} · ${phaseLeft}`}
324 </Text>
325 <Text dimColor>敲了 {s.knocks} 下</Text>
326 </Box>
327 <Text> </Text>
328
329 <Box>
330 <Button
331 key="toggle"
332 hotkey="p"
333 label={s.status === 'running' ? '暂停' : s.status === 'paused' ? '继续' : '开始'}
334 onPress={toggle}
335 />
336 <Text> </Text>
337 <Button key="pattern" hotkey="m" label="换模式" onPress={cycle} />
338 <Text> </Text>
339 <Button key="stop" hotkey="q" label="结束" role="dismiss" onPress={quit} />
340 </Box>
341
342 <Text> </Text>
343 <Text dimColor>
344 {st.sessions === 0
345 ? '第一次冥想,欢迎。'
346 : `累计 ${st.sessions} 次 · ${st.minutes} 分钟 · 连续 ${st.streak} 天`}
347 </Text>
348 </Box>
349 )
350 })
351}
352types/index.d.ts 31 lines1export type Pattern = 'box' | 'relax' | 'calm'
2export type PhaseKind = 'in' | 'hold' | 'out'
3export type Phase = { label: string; seconds: number; kind: PhaseKind }
4export type Status = 'idle' | 'running' | 'paused' | 'done'
5
6/** The meditation in progress: what the pane draws and the ticker advances. */
7export type Session = {
8 status: Status
9 pattern: Pattern
10 totalSeconds: number
11 elapsedSeconds: number
12 phaseIndex: number
13 phaseElapsed: number
14 /** Wooden-fish strikes so far: one per breath phase. */
15 knocks: number
16}
17
18/** Lifetime totals, mirrored into `$.store` under `stats`. */
19export type Stats = {
20 sessions: number
21 minutes: number
22 lastDay: string
23 streak: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'zen-breath': { session: Session; stats: Stats }
29 }
30}
31