Coffee-break reminder: tracks active time and opens an animated ASCII pane with a countdown and stretch tips.

A coffee-break reminder with an animated pane.
Tracks active time and, after a while, toasts and opens an animated ASCII coffee-break pane with a countdown and stretch tips.
/plugin marketplace add cloudcruncher/claude-mod-garage
/plugin install break-time@mod-garage
Run /reload-plugins (or restart Claude Code) afterwards. To try it from a local clone without installing: claude --plugin-dir ./dev-mods/break-time
/break starts a break now, /break-skip snoozes 15 minutes, /break-done resets the timer.Illustrative output.
Toast
Time for a coffee break. Run /break (or /break-skip to snooze).
| Option | Default | What it does |
|---|---|---|
intervalMinutes | 150 | Active minutes between reminders |
breakMinutes | 5 | Countdown length |
Set options when installing, or later in /plugin → the mod → configure.
hooks/register.tsx 80 lines1import type { Register } from 'claude-code'
2
3import { advance, initialState, isDue, reset, snooze } from './timing'
4import type { BreakState, Config } from './timing'
5
6const PANE = 'coffee-break'
7const TICK_MS = 60_000
8
9export const register: Register = (on, options) => {
10 const cfg: Config = {
11 intervalMin: Number(options?.intervalMinutes ?? 150),
12 breakMin: Number(options?.breakMinutes ?? 5),
13 snoozeMin: 15,
14 }
15 let state: BreakState = initialState()
16 let lastActivity = 0
17 let lastBreak = 0
18 let shown = 0 // bumps on each open, resets the animation
19
20 on('session.start', async ($, e, next) => {
21 const now = await $.clock.now()
22 lastActivity = now
23 const saved: any = await $.store.get('break')
24 lastBreak = saved?.lastBreak ?? now
25 // Carry active time over a quick restart only (under 30 min gap).
26 if (saved && now - saved.savedAt < 30 * 60_000) state = { ...state, activeMs: saved.activeMs ?? 0 }
27 for (const [name, description] of [
28 ['break', 'Start a coffee break now'],
29 ['break-skip', 'Snooze the break reminder for 15 minutes'],
30 ['break-done', 'Reset the break timer'],
31 ]) {
32 await $.command.register({ name, description })
33 }
34 $.clock.every(TICK_MS, async () => {
35 const now2 = await $.clock.now()
36 state = advance(state, TICK_MS, now2 - lastActivity)
37 if (isDue(state, cfg)) {
38 state = snooze(state, cfg) // re-arm so we do not nag every minute
39 $.ui.toast('Time for a coffee break. Run /break (or /break-skip to snooze).')
40 shown += 1
41 await $.ui.open({ id: PANE, title: 'Coffee break', closeOnEscape: true })
42 }
43 await $.store.set('break', { lastBreak, activeMs: state.activeMs, savedAt: now2 })
44 })
45 return next(e)
46 })
47
48 on('prompt.submit', async ($, e, next) => {
49 lastActivity = await $.clock.now()
50 return next(e)
51 })
52
53 on('command.run', { command: 'break' }, async $ => {
54 shown += 1
55 await $.ui.open({ id: PANE, title: 'Coffee break', closeOnEscape: true })
56 return { text: 'Coffee break started.' }
57 })
58 on('command.run', { command: 'break-skip' }, async $ => {
59 state = snooze(state, cfg)
60 await $.ui.close({ id: PANE })
61 return { text: `Snoozed for ${cfg.snoozeMin} minutes of active time.` }
62 })
63 on('command.run', { command: 'break-done' }, async $ => {
64 state = reset()
65 lastBreak = await $.clock.now()
66 await $.store.set('break', { lastBreak, activeMs: 0, savedAt: lastBreak })
67 await $.ui.close({ id: PANE })
68 return { text: 'Break timer reset. Welcome back.' }
69 })
70
71 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
72 const { Box, Client } = $.ui.resolve(e)
73 return (
74 <Box flexDirection="column">
75 <Client key="scene" module="./scene.tsx" props={{ breakMin: cfg.breakMin, n: shown }} />
76 </Box>
77 )
78 })
79}
80hooks/timing.ts 122 lines1// Pure timing and frame logic for break-time. No engine APIs here.
2
3export type BreakState = {
4 activeMs: number // active time accumulated since the last break
5 snoozeUntilMs: number // active-ms threshold; 0 = none
6}
7
8export type Config = { intervalMin: number; breakMin: number; snoozeMin: number }
9
10export const IDLE_CUTOFF_MS = 5 * 60_000
11export const TIP_SECONDS = 20
12
13export const TIPS = [
14 'Neck: slowly tilt your ear toward each shoulder, 15 seconds a side.',
15 'Wrists: extend an arm, palm up, and gently pull the fingers back. Switch.',
16 'Eyes (20-20-20): look at something 20 feet away for 20 seconds.',
17 'Stand up and walk around. Refill your water while you are at it.',
18]
19
20export function initialState(): BreakState {
21 return { activeMs: 0, snoozeUntilMs: 0 }
22}
23
24/** Adds dtMs of active time if the last activity was within the idle cutoff. */
25export function advance(s: BreakState, dtMs: number, sinceActivityMs: number): BreakState {
26 if (sinceActivityMs > IDLE_CUTOFF_MS) return s
27 return { ...s, activeMs: s.activeMs + dtMs }
28}
29
30export function isDue(s: BreakState, cfg: Config): boolean {
31 const threshold = Math.max(cfg.intervalMin * 60_000, s.snoozeUntilMs)
32 return s.activeMs >= threshold
33}
34
35/** Snooze: next reminder after snoozeMin more active minutes. */
36export function snooze(s: BreakState, cfg: Config): BreakState {
37 return { ...s, snoozeUntilMs: s.activeMs + cfg.snoozeMin * 60_000 }
38}
39
40export function reset(): BreakState {
41 return initialState()
42}
43
44export function tipIndex(elapsedSec: number): number {
45 return Math.floor(Math.max(0, elapsedSec) / TIP_SECONDS) % TIPS.length
46}
47
48export function tipFor(elapsedSec: number): string {
49 return TIPS[tipIndex(elapsedSec)]
50}
51
52export function remainingSec(elapsedSec: number, breakMin: number): number {
53 return Math.max(0, Math.round(breakMin * 60 - elapsedSec))
54}
55
56export function formatCountdown(sec: number): string {
57 const s = Math.max(0, Math.floor(sec))
58 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
59}
60
61// Four-frame walk cycle, each figure 3 rows x 3 cols.
62export const WALK: string[][] = [
63 [' o ', '/|\\', '/ >'],
64 [' o ', ' |\\', ' |\\'],
65 [' o ', '\\|/', '< \\'],
66 [' o ', '/| ', '/| '],
67]
68
69/** Column of the figure's left edge for a tick, moving right and looping. */
70export function walkX(tick: number, width: number, step = 1): number {
71 const span = Math.max(1, width - 3)
72 return (Math.max(0, tick) * step) % span
73}
74
75export function walkFrame(tick: number): string[] {
76 return WALK[Math.max(0, tick) % WALK.length]
77}
78
79const STEAM: string[][] = [
80 [' ) ( ', ' ( ) '],
81 [' ( ) ', ' ) ( '],
82 [' ) ) ', ' ( ( '],
83]
84const CUP = [' .-----.', ' | |]', ' \\_____/']
85
86export function cupLines(tick: number): string[] {
87 return [...STEAM[Math.floor(Math.max(0, tick) / 2) % STEAM.length], ...CUP]
88}
89
90function place(row: string, x: number, piece: string, width: number): string {
91 const padded = row.padEnd(width, ' ')
92 return (padded.slice(0, x) + piece + padded.slice(x + piece.length)).slice(0, width)
93}
94
95export function wrap(text: string, width: number): string[] {
96 const out: string[] = []
97 let line = ''
98 for (const word of text.split(' ')) {
99 if (line && line.length + 1 + word.length > width) {
100 out.push(line)
101 line = word
102 } else line = line ? `${line} ${word}` : word
103 }
104 if (line) out.push(line)
105 return out
106}
107
108/** The whole scene as lines: walker, cup, countdown, tip. tick advances 4/s. */
109export function renderScene(tick: number, width: number, elapsedSec: number, breakMin: number): string[] {
110 const w = Math.max(24, width)
111 const fig = walkFrame(tick)
112 const x = walkX(tick, w, 1)
113 const walker = fig.map(r => place(''.padEnd(w, ' '), x, r, w))
114 const ground = '_'.repeat(w)
115 const cup = cupLines(tick).map(r => r.padEnd(w, ' ').slice(0, w))
116 const left = remainingSec(elapsedSec, breakMin)
117 const done = left === 0
118 const status = done ? 'Break over. Back to it when you are ready.' : `Break ends in ${formatCountdown(left)}`
119 const tip = done ? 'Run /break-done to reset the timer.' : tipFor(elapsedSec)
120 return [...walker, ground, '', ...cup, '', ...wrap(status, w), '', ...wrap(tip, w)]
121}
122hooks/scene.tsx 29 lines1import { renderScene } from './timing'
2
3type Props = { breakMin: number; n: number }
4type St = { tick: number; n: number }
5
6// Surface module: runs on the drawing thread. tick advances every 250 ms.
7export default function Scene(props: Props, surface: any) {
8 const { Box, Text } = surface.elements
9 const st: St | undefined = surface.state
10 if (st === undefined || st.n !== props.n) {
11 surface.setState({ tick: 0, n: props.n })
12 if (st === undefined) {
13 surface.every(250, () => {
14 const cur: St = surface.state ?? { tick: 0, n: props.n }
15 surface.setState({ ...cur, tick: cur.tick + 1 })
16 })
17 }
18 }
19 const tick = st?.tick ?? 0
20 const lines = renderScene(tick, surface.columns || 60, Math.floor(tick / 4), props.breakMin)
21 return (
22 <Box flexDirection="column">
23 {lines.map(l => (
24 <Text>{l === '' ? ' ' : l}</Text>
25 ))}
26 </Box>
27 )
28}
29