SLOPSHOPPER

break-time

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

newpanecommandtoastprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · break-time
│ ┃ coffee-break ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./scene.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /break │ ⎿ break-time: Coffee break started. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · coffee-break
▣ client module ./scene.tsx
README

break-time

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.

Install

/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

Use it

  • Automatic reminders.
  • /break starts a break now, /break-skip snoozes 15 minutes, /break-done resets the timer.

What you'll see

Illustrative output.

Toast

Time for a coffee break. Run /break (or /break-skip to snooze).

Options

OptionDefaultWhat it does
intervalMinutes150Active minutes between reminders
breakMinutes5Countdown length

Set options when installing, or later in /plugin → the mod → configure.

Source 3 files
hooks/register.tsx 80 lines
1import 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}
80
hooks/timing.ts 122 lines
1// 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}
122
hooks/scene.tsx 29 lines
1import { 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