SLOPSHOPPER

zen-breath

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

panecommandtoaststatustimer
★ 2v0.1.1MITupdated 2026-10-05mindthink/zen-breath
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zen-breath
│ ┃ 冥想 ✕ › fix the failing auth test and add an audit log call │ ┃ 🧘 方箱呼吸 4-4-4-4 剩余 05:00 │ ┃ ⏺ Read(src/auth.ts) │ ┃ _ooOoo_ ⎿ Read 6 lines │ ┃ o8888888o ((( 咚 ⏺ Update(src/auth.ts) │ ┃ 88" . "88 o ⎿ Added 2 lines, removed 1 line │ ┃ (| ^_^ |) ,--. ⏺ Bash(bun test) │ ┃ O\ = /O (____) ⎿ 3 pass, 1 fail │ ┃ ____/`---'\____ │ ┃ .' \\| |// `. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ / \\||| : |||// \ │ ┃ / _||||| -:- |||||_ \ ✻ Worked for 42s · done 4:20 PM │ ┃ | | \\\ - /// | | │ ┃ | \_| ''\---/'' | | › /meditate │ ┃ \ .-\__ `-` ___/-. / ⎿ zen-breath: 开始 5 分钟 · 方箱呼吸 4-4-4-4。 面板里 p 暂停、m 换模式、q 结束。 │ ┃ `-.________________.-' │ ┃ `=---=' │ ┃ │ ┃ 吸气 · 4 敲了 0 下 │ ┃ │ ┃ [ 暂停 ] [ 换模式 ] [ 结束 ] │ ┃ │ ┃ 第一次冥想,欢迎。 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ zen-breath: 🧘 吸气 · 剩余 05:00

Draws

Pane · 冥想
🧘 方箱呼吸 4-4-4-4 剩余 05:00 _ooOoo_ o8888888o ((( 咚 88" . "88 o (| ^_^ |) ,--. O\ = /O (____) ____/`---'\____ .' \\| |// `. / \\||| : |||// \ / _||||| -:- |||||_ \ | | \\\ - /// | | | \_| ''\---/'' | | \ .-\__ `-` ___/-. / `-.________________.-' `=---=' 吸气 · 4 敲了 0 下 [ 暂停 ] [ 换模式 ] [ 结束 ] 第一次冥想,欢迎。
README

zen-breath 🧘

中文 | English

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

zen-breath demo

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 天

功能

  • 三种呼吸模式:方箱呼吸 4-4-4-4、放松呼吸 4-7-8、平静呼吸 5-5
  • 每进入一个呼吸阶段木鱼敲一下,佛祖眯眼微笑
  • 面板里 p 暂停 / 继续、m 切换模式、q 结束;关掉面板即结束
  • 进度同步显示在状态栏,终端太窄放不下面板时也能用
  • 完成后弹出 toast,累计次数、分钟数和连续天数跨会话保存

安装

方式一:作为 marketplace 安装(推荐)

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        倒计时、暂停、切模式、参数校验的测试

License

MIT

Source 2 files
hooks/register.tsx 352 lines
1import { 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}
352
types/index.d.ts 31 lines
1export 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