SLOPSHOPPER

Terminal Gym

A daily bodyweight rep goal inside Claude Code: log with /fit, quick-log band, long-turn nudges, streaks and a score grid, optional strict mode.

newpanebandspinnercommandtoast
★ 1v0.1.3MITupdated 2026-10-03DrumAndCode/terminal-gym
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · terminal-gym
│ ┃ gym-week ✕ › fix the failing auth test and add an audit log call │ ┃ ▐█▌▐█▌ ▐█▌▐█▌ │ ┃ ━▐█▌▐█▌━━━ TERMINAL GYM ━━━▐█▌▐█▌━ ⏺ Read(src/auth.ts) │ ┃ ▐█▌▐█▌ ▐█▌▐█▌ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 🔥 0-day streak ⎿ Added 2 lines, removed 1 line │ ┃ 💪 ░░░░░░░░░░ 0/100 pushups ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ last 28 days · ▒ today │ ┃ ██ ██ ██ ██ ██ ██ ██ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ██ ██ ██ ██ ██ ██ ██ │ ┃ ██ ██ ██ ██ ██ ██ ██ ✻ Worked for 42s · done 4:20 PM │ ┃ ██ ██ ██ ██ ██ ██ ▒▒ │ ┃ › /fit │ ┃ · 10-03 0/100 dips ⎿ terminal-gym: 💪 ░░░░░░░░░░ 0/100 pushups │ ┃ · 10-04 0/300 squats │ ┃ · 10-05 0/100 pushups │ ┃ · 10-06 0/100 pushups │ ┃ · 10-07 0/100 dips │ ┃ · 10-08 0/300 squats │ ┃ · 10-09 0/100 pushups ◀ today │ ┃ │ ┃ Esc or /fit score to close │ ┃ [ Close ] │ ❚█═TERMINAL-GYM═█❚ Your agent put in the reps. You next. [ Pick your training ] [ House rules ] [ Just train ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
❚█═TERMINAL-GYM═█❚ Your agent put in the reps. You next. [ Pick your training ] [ House rules ] [ Just train ]
Pane · gym-week
▐█▌▐█▌ ▐█▌▐█▌ ━▐█▌▐█▌━━━ TERMINAL GYM ━━━▐█▌▐█▌━ ▐█▌▐█▌ ▐█▌▐█▌ 🔥 0-day streak 💪 ░░░░░░░░░░ 0/100 pushups last 28 days · ▒ today ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ▒▒ · 10-03 0/100 dips · 10-04 0/300 squats · 10-05 0/100 pushups · 10-06 0/100 pushups · 10-07 0/100 dips · 10-08 0/300 squats · 10-09 0/100 pushups ◀ today Esc or /fit score to close [ Close ]
Pane · gym-help
House rules Your agent put in the reps. You next. When a turn runs long, drop and do a set. Log a set /fit 20 adds 20 reps or press +5 / +10 / +25 above the prompt Commands | Command | Does | |---|---| | /fit | today's progress | | /fit set 80 | fix today's count | | /fit reset | today back to 0 | | /fit swap | switch today's exercise | | /fit rest | rest day (breaks streak) | | /fit rest off | undo today's rest day | | /fit score | streak + grid | | /fit program | pick your training | | /fit strict / /fit easy | strict mode on / off | | /fit rules | these rules | | /fit start | replay the welcome | | /fit hide | close panels | Strict mode Off by default. When it's on: • today's goal is split into sets, spread over 8 hours (by default) from your first prompt • prompts before 5am count as the night before • the first set is due right away • fall behind and your prompts wait until you catch up • a toast tells you when each set comes due • /fit rest bails, but costs your streak Turn it on: type /fit strict Turn it off: type /fit easy Your data Stays on this machine, in ~/.claude/fitness. [ Pick your training ] [ Close ]
Pane · gym-onboard
Step 1 of 3 ● ○ ○ Your agent works the code. You work the reps. Every day has a rep goal. When Claude runs a long turn, that's your cue for a set. Log reps two ways: type /fit 20 or press +5 +10 +25 above your prompt [ Next ]
README

❚█═TERMINAL-GYM═█❚

Your agent put in the reps. You next.

A Claude Code mod for a daily bodyweight rep goal. While Claude works, you train: log sets from a band above your prompt, keep a streak, and let strict mode spread the reps through your day.

claude plugin marketplace add DrumAndCode/terminal-gym && claude plugin install terminal-gym@terminal-gym

Start a new Claude Code session and the welcome band appears above your prompt.

The Terminal Gym band above the Claude Code prompt: 0/100 pushups, behind 10, with +5, +10 and +25 buttons

Demo (40 seconds)

https://github.com/user-attachments/assets/14373c7e-ad8a-4f92-812b-c93c3201b1b3

What you get

  • Tracker band above the prompt: 💪 ▓▓▓▓▓▓░░░░ 60/100 pushups next set 10:53 log reps: [ +5 ] [ +10 ] [ +25 ] 🔥3d. It's grey at 0, then red, yellow and green as you close in on the goal.
  • Walkthrough: three steps on first run: what the mod does, pick your program, how streaks and the scoreboard work.
  • Set reminders: a toast when each set comes due.
  • Long-turn nudges (easy mode): when Claude works past 30 seconds, a toast and the spinner suggest a set.
  • Strict mode: today's goal is split into sets spread over your day. Fall behind and your prompts wait until you catch up.
  • Scoreboard (/fit score): an ASCII barbell (in the terminal), your streak, a 28-day grid and the last week by exercise, with today marked.
  • Streaks: count every day you hit your goal, for as long as you keep it going.

How a strict day works

Strict mode spreads today's goal into evenly spaced sets over a training window (8 hours by default) that starts with your first prompt of the day. With 100 pushups:

WhenWhat happens
First prompt (say 9:00)The day starts and the opening set is due. Your prompt waits: "Behind pace: 10 pushups to catch up (10/100 due by now). /fit 10 to log."
After /fit 10You're through. The band reads next set 09:53
9:53Toast: "⏱ Set due: 10 pushups. 20/100 by now." Your next prompt waits until you log it
…about every 53 minutes…Ten sets in all, the last due 8 hours after your first prompt
Goal done, or a rest dayNothing waits

Only prompts you type can be held. Background tasks, /loop runs, messages from other sessions and slash commands like /fit 10 always go through. Prompts before 5am count as the night before, so a late night never leaves you a wall of overdue sets in the morning.

Easy mode (the default) shows the same schedule on the band and the same reminders, but never holds a prompt.

Commands

CommandDoes
/fittoday's progress, pace and streak
/fit 20log 20 reps
/fit set 80fix today's count
/fit resettoday back to 0
/fit swap [exercise]switch today's exercise: the next in your program, or the one you name
/fit restrest day: no sets due, breaks your streak
/fit rest offundo today's rest day (or press Train today on the band)
/fit scorescoreboard: streak, grid and the last week (run again or press Esc to close)
/fit programpick your program (the walkthrough)
/fit strict / /fit easystrict mode on / off
/fit ruleshouse rules
/fit startreplay the welcome
/fit hideclose panels

Settings

In /config, under terminal-gym:

SettingDefaultWhat it does
Strict modeoffHold typed prompts while you're behind pace
Training window8 hoursHow long after your first prompt the day's sets are spread over
Set sizeautoReps per set; auto is a tenth of the goal, rounded to fives
Long-turn nudgesonEasy mode: suggest a set when a turn runs long
Nudge after30 secondsHow long a turn runs before the nudge
Reps per nudge10The set the nudge suggests
Spinner takeoveronEasy mode: show the suggested set in the spinner
Quick-log bandonShow the tracker band above the prompt

Privacy and data

The mod makes no network calls and adds no hidden context for the model. /fit replies appear in your conversation like any slash command's output, so Claude can see them. Everything else stays on your machine: a few values in the mod's own store (see below), and your data in ~/.claude/fitness:

  • routine.json: the plan per weekday, for example { "mon": { "exercise": "pushups", "goal": 100 } }, with an optional "unit": "s" for timed exercises. Edit it for a custom program.
  • log/YYYY-MM-DD: that day's count.
  • log/YYYY-MM-DD.skip: a rest day. If it contains off, the rest day was undone.
  • log/YYYY-MM-DD.swap: that day's exercise after /fit swap, in the same shape as a routine day.

What it does inside Claude Code

A mod runs inside Claude Code, so here is everything Terminal Gym touches:

  • Files it writes: only its own data in ~/.claude/fitness: the routine, the daily logs listed above, and routine.json.bak (a copy of your custom routine, made before the walkthrough replaces it). It never writes settings files, instructions, build or start-up files directly.
  • Its own store: a few values in the mod's per-plugin store inside Claude Code: today's start time, the streak cache, and whether you've seen the welcome.
  • Settings it changes: /fit strict and /fit easy turn its own Strict mode setting (terminal-gym.strict) on and off. That's the only setting it changes. It sets no environment variables; it reads HOME to find ~/.claude/fitness.
  • Permissions: it never approves or denies a tool call, never changes your permission mode, and never runs a model or a process.
  • Events it hooks:
  • prompt.submit: in strict mode, holds a prompt you typed while you're behind pace and puts its text back in the prompt box. Easy mode lets everything through. Prompts from other sources are never touched.
  • command.run: answers its own /fit command, and nothing else.
  • session.start and the SessionStart event after /clear or /resume: load today's state and register /fit. The event passes through unchanged.
  • turn.start and turn.complete: time the easy-mode nudge and refresh the band.
  • ui.render: draws the band, the scoreboard, the house rules and the walkthrough, and (easy mode) the suggested set in the spinner.

Requirements

Claude Code 2.1.288 or newer. The band and panes draw in the terminal and the Desktop app's Code tab. In the VS Code chat panel and claude -p, /fit still works but nothing is drawn. Mods are early access, so the API can change between Claude Code releases.

Update with claude plugin update terminal-gym@terminal-gym. From inside a chat, you can also install with /plugin install terminal-gym --marketplace DrumAndCode/terminal-gym.

Contributing

Issues and PRs welcome. Keep changes small and include a test.

claude --plugin-dir .          # load your checkout for one session
claude plugin validate --strict .
claude plugin test .

claude plugin test runs tests/*.test.tsx against the engine itself. Once the mod has loaded, tsc -p . type-checks it against the engine's types in .claude-plugin/types/.

License

MIT

Source 3 files
hooks/register.tsx 811 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Day, OnboardPick, Pace, Today } from '../types'
5import {
6  DEFAULT_ROUTINE,
7  BARBELL,
8  HELP,
9  MINI_BARBELL,
10  ROUTINES,
11  SIZES,
12  bar,
13  clockTime,
14  paceAt,
15  setSizeFor,
16  buttonsWidth,
17  cells,
18  dayKey,
19  fullStreak,
20  isDone,
21  describeRoutine,
22  heatCells,
23  lastDays,
24  parseFit,
25  pickSwap,
26  progressColor,
27  scale,
28  streak,
29  weekdayKey,
30} from './lib'
31import type { Routine } from './lib'
32
33type Engine = EngineInterface
34
35const PANE = 'gym-week'
36const HELP_PANE = 'gym-help'
37const ONBOARD_PANE = 'gym-onboard'
38const HISTORY_DAYS = 28
39const TOAST_MS = 10_000
40const HOUR_MS = 60 * 60_000
41
42// Toasts stay up long enough to read mid-set.
43const toast = ($: Engine, text: string) => $.ui.toast(text, { timeoutMs: TOAST_MS })
44
45// The pace settings, set from the options each time the module registers.
46const paceConfig = { setSize: 0, windowMs: 8 * HOUR_MS }
47let dueTimer: { cancel: () => void } | undefined
48
49const today = atom({ plugin: 'terminal-gym', key: 'today' } as const, null)
50const olderStreak = atom({ plugin: 'terminal-gym', key: 'olderStreak' } as const, 0)
51const pace = atom({ plugin: 'terminal-gym', key: 'pace' } as const, null)
52const isWaiting = atom({ plugin: 'terminal-gym', key: 'isWaiting' } as const, false)
53const history = atom({ plugin: 'terminal-gym', key: 'history' } as const, [])
54const isIntroduced = atom({ plugin: 'terminal-gym', key: 'isIntroduced' } as const, false)
55const onboardStep = atom({ plugin: 'terminal-gym', key: 'onboardStep' } as const, 0)
56const onboardPick = atom({ plugin: 'terminal-gym', key: 'onboardPick' } as const, {
57  program: 'Push · dip · squat rotation',
58  size: 'Standard',
59  hasRoutine: false,
60})
61
62// Same files as the `fit` shell script, so both agree.
63const files = async ($: Engine) => {
64  const dir = `${(await $.env.get('HOME')) ?? ''}/.claude/fitness`
65  return { routine: `${dir}/routine.json`, log: `${dir}/log` }
66}
67
68const loadRoutine = async ($: Engine, routinePath: string): Promise<Routine> => {
69  if (!(await $.fs.exists(routinePath))) {
70    await $.fs.write(routinePath, `${JSON.stringify(DEFAULT_ROUTINE, null, 2)}\n`)
71    return DEFAULT_ROUTINE
72  }
73  return JSON.parse(await $.fs.read(routinePath)) as Routine
74}
75
76const readCount = async ($: Engine, path: string) => {
77  if (!(await $.fs.exists(path))) return 0
78  const n = parseInt(await $.fs.read(path), 10)
79  return Number.isFinite(n) ? n : 0
80}
81
82// `/fit swap` leaves the day's plan beside its count as `<date>.swap`.
83const readSwap = async ($: Engine, logDir: string, date: string): Promise<Routine[string] | undefined> => {
84  const path = `${logDir}/${date}.swap`
85  if (!(await $.fs.exists(path))) return undefined
86  // A damaged or odd file falls back to the routine instead of breaking every refresh.
87  try {
88    const plan = JSON.parse(await $.fs.read(path)) as Partial<Routine[string]>
89    return typeof plan.exercise === 'string' && typeof plan.goal === 'number'
90      ? { exercise: plan.exercise, goal: plan.goal, unit: plan.unit }
91      : undefined
92  } catch {
93    return undefined
94  }
95}
96
97// `/fit rest` leaves `<date>.skip`; `/fit rest off` writes "off" into it to undo.
98const readRest = async ($: Engine, path: string) =>
99  (await $.fs.exists(path)) && (await $.fs.read(path)).trim() !== 'off'
100
101const refreshToday = async ($: Engine): Promise<Today> => {
102  const { routine, log: logDir } = await files($)
103  const date = dayKey(await $.clock.now())
104  const plan =
105    (await readSwap($, logDir, date)) ?? (await loadRoutine($, routine))[weekdayKey(await $.clock.now())]
106  const next: Today = {
107    date,
108    exercise: plan?.exercise ?? 'reps',
109    goal: plan?.goal ?? 0,
110    unit: plan?.unit ?? '',
111    count: await readCount($, `${logDir}/${date}`),
112    isRest: await readRest($, `${logDir}/${date}.skip`),
113  }
114  await update($, today, () => next)
115  return next
116}
117
118const DAY_MS = 86_400_000
119
120const readDay = async ($: Engine, routine: Routine, logDir: string, ms: number): Promise<Day> => {
121  const date = dayKey(ms)
122  const plan = (await readSwap($, logDir, date)) ?? routine[weekdayKey(ms)]
123  return {
124    date,
125    count: await readCount($, `${logDir}/${date}`),
126    goal: plan?.goal ?? 0,
127    exercise: plan?.exercise ?? 'reps',
128    isSkipped: await readRest($, `${logDir}/${date}.skip`),
129  }
130}
131
132// Finished days in a row just before the window, so a streak can outlast it.
133// Cached per window: a full walk once, then one day's step as the window slides.
134const olderRun = async ($: Engine, days: readonly Day[], routine: Routine, logDir: string) => {
135  const first = days[0]
136  if (first === undefined || !isDone(first)) {
137    await $.store.delete('streakCache')
138    return 0
139  }
140  const oldest = lastDays(await $.clock.now(), HISTORY_DAYS)[0] ?? 0
141  const asOf = dayKey(oldest)
142  const cache = (await $.store.get('streakCache')) as { asOf?: string; older?: number } | undefined
143  let older: number
144  if (cache?.asOf === asOf && typeof cache.older === 'number') {
145    older = cache.older
146  } else if (cache?.asOf === dayKey(oldest - DAY_MS) && typeof cache.older === 'number') {
147    // The window moved on a day: the day that left it joins the older run.
148    older = isDone(await readDay($, routine, logDir, oldest - DAY_MS)) ? cache.older + 1 : 0
149  } else {
150    older = 0
151    for (let back = 1; back <= 366; back++) {
152      if (!isDone(await readDay($, routine, logDir, oldest - back * DAY_MS))) break
153      older++
154    }
155  }
156  await $.store.set('streakCache', { asOf, older })
157  return older
158}
159
160const refreshHistory = async ($: Engine) => {
161  const { routine: routinePath, log: logDir } = await files($)
162  const routine = await loadRoutine($, routinePath)
163  const days: Day[] = await Promise.all(
164    lastDays(await $.clock.now(), HISTORY_DAYS).map(async ms => {
165      const date = dayKey(ms)
166      const plan = (await readSwap($, logDir, date)) ?? routine[weekdayKey(ms)]
167      return {
168        date,
169        count: await readCount($, `${logDir}/${date}`),
170        goal: plan?.goal ?? 0,
171        exercise: plan?.exercise ?? 'reps',
172        isSkipped: await readRest($, `${logDir}/${date}.skip`),
173      }
174    }),
175  )
176  await update($, history, () => days)
177  const older = await olderRun($, days, routine, logDir)
178  await update($, olderStreak, () => older)
179  return days
180}
181
182// The day starts at the person's first prompt; sets are spread from there.
183const dayStart = async ($: Engine, date: string) => {
184  const stored = (await $.store.get('dayStart')) as { date?: string; at?: number } | undefined
185  return stored?.date === date && typeof stored.at === 'number' ? stored.at : undefined
186}
187
188// Prompts before this hour belong to the night before: they never start the
189// day, or a 00:30 prompt would leave every set overdue by morning.
190const DAY_BEGINS_HOUR = 5
191
192const startDay = async ($: Engine) => {
193  const now = await $.clock.now()
194  if (new Date(now).getHours() < DAY_BEGINS_HOUR) return
195  const date = dayKey(now)
196  if ((await dayStart($, date)) === undefined) await $.store.set('dayStart', { date, at: now })
197}
198
199// Recomputes the pace and arms a timer for the next set coming due.
200const refreshPace = async ($: Engine): Promise<Pace | null> => {
201  dueTimer?.cancel()
202  dueTimer = undefined
203  // Re-read today: the date may have rolled over, or the shell script logged reps.
204  const t = await refreshToday($)
205  const at = t === null ? undefined : await dayStart($, t.date)
206  const now = await $.clock.now()
207  const next: Pace | null =
208    t === null || t.isRest || at === undefined
209      ? null
210      : paceAt(t.goal, t.count, setSizeFor(t.goal, paceConfig.setSize), at, now, paceConfig.windowMs)
211  await update($, pace, () => next)
212  if (next?.nextDueAt != null && t !== null && t.count < t.goal) {
213    dueTimer = $.clock.after(Math.max(1, next.nextDueAt - now), () => void setDue($))
214  }
215  return next
216}
217
218const setDue = async ($: Engine) => {
219  const p = await refreshPace($)
220  const t = await read($, today)
221  if (p === null || t === null || p.behind <= 0) return
222  toast($, `⏱ Set due: ${p.behind} ${t.exercise}. ${p.required}/${t.goal} by now.`)
223}
224
225// A Close button's press: a refused close says why instead of doing nothing.
226const closePane = async ($: Engine, id: string) => {
227  try {
228    await $.ui.close({ id })
229  } catch (err) {
230    toast($, `Couldn't close the panel (${err instanceof Error ? err.message : String(err)}). Try Esc or /fit hide.`)
231  }
232}
233
234const paceText = (p: Pace | null, isStrict: boolean) => {
235  if (p === null) return ''
236  if (p.behind > 0) return `  behind ${p.behind}${isStrict ? ' · prompts wait' : ''}`
237  return p.nextDueAt === null ? '' : `  next set ${clockTime(p.nextDueAt)}`
238}
239
240const line = (t: Today) => {
241  const isDone = t.goal > 0 && t.count >= t.goal
242  return `${isDone ? '✅' : '💪'} ${bar(t.count, t.goal)} ${t.count}/${t.goal}${t.unit} ${t.exercise}`
243}
244
245const withStreak = (t: Today, days: readonly Day[], older = 0) => {
246  const run = fullStreak(days, older)
247  return run > 0 ? `${line(t)}  🔥${run}d` : line(t)
248}
249
250const logReps = async ($: Engine, change: (count: number) => number) => {
251  const before = await refreshToday($)
252  const count = Math.max(0, change(before.count))
253  await $.fs.write(`${(await files($)).log}/${before.date}`, `${count}\n`)
254
255  const after = { ...before, count }
256  await update($, today, () => after)
257  await update($, history, days =>
258    days.map(day => (day.date === after.date ? { ...day, count } : day)),
259  )
260
261  await refreshPace($)
262  if (after.goal > 0 && before.count < after.goal && count >= after.goal) {
263    toast($, `✅ Done. ${count}/${after.goal}${after.unit} ${after.exercise}. That's the work.`)
264  }
265  return after
266}
267
268// Easy mode: a suggestion once a turn runs long.
269const nudge = async ($: Engine, reps: number) => {
270  const t = await read($, today)
271  if (t === null || t.isRest || (t.goal > 0 && t.count >= t.goal)) return
272  await update($, isWaiting, () => true)
273  toast($, `Your agent's mid-set. You next: ${reps} ${t.exercise}.`)
274}
275
276const endRest = async ($: Engine) => {
277  const t = await refreshToday($)
278  if (!t.isRest) return { text: `Today isn't a rest day. ${line(t)}` }
279  await $.fs.write(`${(await files($)).log}/${t.date}.skip`, 'off\n')
280  const back = await refreshToday($)
281  await refreshHistory($)
282  await refreshPace($)
283  return { text: `Rest day undone. Back to work. ${line(back)}` }
284}
285
286const markIntroduced = async ($: Engine) => {
287  await $.store.set('introduced', true)
288  await update($, isIntroduced, () => true)
289}
290
291const KEEP = 'Keep my current routine'
292const DEFAULT_PROGRAM = 'Push · dip · squat rotation'
293
294const openOnboarding = async ($: Engine) => {
295  // Open first: a pane counts as asked for only while the press or command that
296  // asked is still being answered. Opened after other awaits, the engine treats
297  // it as unasked and leaves it undrawn below 144 columns.
298  await update($, onboardStep, () => 0)
299  const opened = await $.ui.open({ id: ONBOARD_PANE, title: 'TERMINAL GYM', focus: true, closeOnEscape: true })
300  if (!opened.isPlaced) toast($, 'Widen the terminal to see the walkthrough, or type /fit program.')
301  // A seeded default counts as no routine of their own; only custom ones get "keep".
302  const { routine: routinePath } = await files($)
303  const hasRoutine =
304    (await $.fs.exists(routinePath)) &&
305    JSON.stringify(await loadRoutine($, routinePath)) !== JSON.stringify(DEFAULT_ROUTINE)
306  await update($, onboardPick, () => ({
307    program: hasRoutine ? KEEP : DEFAULT_PROGRAM,
308    size: 'Standard',
309    hasRoutine,
310  }))
311}
312
313const pickedRoutine = (p: OnboardPick): Routine | undefined => {
314  const preset = ROUTINES[p.program as keyof typeof ROUTINES] as Routine | undefined
315  return preset === undefined ? undefined : scale(preset, SIZES[p.size as keyof typeof SIZES] ?? 1)
316}
317
318const finishOnboarding = async ($: Engine) => {
319  const p = await read($, onboardPick)
320  const { routine: routinePath } = await files($)
321  const routine = pickedRoutine(p)
322  if (routine !== undefined) {
323    if (p.hasRoutine) await $.fs.write(`${routinePath}.bak`, await $.fs.read(routinePath))
324    await $.fs.write(routinePath, `${JSON.stringify(routine, null, 2)}\n`)
325  }
326  await markIntroduced($)
327  await refreshToday($)
328  await refreshHistory($)
329  await refreshPace($)
330  await $.ui.close({ id: ONBOARD_PANE })
331  const plan = routine ?? (JSON.parse(await $.fs.read(routinePath)) as Routine)
332  toast($, `Program set: ${describeRoutine(plan)}. Get to work.`)
333}
334
335// A made-up fortnight for the walkthrough's example grid.
336const SAMPLE_DAYS: Day[] = [1, 1, 0.4, 1, 1, 0, 1, 1, 1, 0.6, 1, 1, 1, 0.3].map((share, i) => ({
337  date: `sample-${i}`,
338  count: Math.round(share * 100),
339  goal: 100,
340  exercise: 'pushups',
341  isSkipped: false,
342}))
343
344// Everything a conversation needs: the /fit command and today's state.
345const boot = async ($: Engine) => {
346  const introduced = (await $.store.get('introduced')) === true
347  await update($, isIntroduced, () => introduced)
348  await refreshToday($)
349  await refreshHistory($)
350  // Re-arms the due timer, which a restart or reload drops.
351  await refreshPace($)
352  // Last, so a refused registration can't leave the band without its state.
353  await $.command.register({
354    name: 'fit',
355    description: "Log reps toward today's goal",
356    argumentHint: '[n | set n | reset | swap [exercise] | rest [off] | score | program | rules | strict | easy | start | hide]',
357    immediate: true,
358  })
359}
360
361export const register: Register = (on, options) => {
362  const isStrict = options.strict === true
363  const nudgeMs = Number(options.nudgeSeconds ?? 30) * 1000
364  const nudgeReps = Number(options.nudgeReps ?? 10)
365  paceConfig.setSize = Number(options.setSize ?? 0)
366  paceConfig.windowMs = Math.max(1, Number(options.windowHours ?? 8)) * HOUR_MS
367  let timer: { cancel: () => void } | undefined
368
369  on('session.start', async ($, e, next) => {
370    await boot($)
371    return next(e)
372  })
373
374  // /clear and an in-session /resume switch to another conversation in the
375  // same process without another session.start, so the setup runs again here.
376  on('classic.SessionStart', async ($, e, next) => {
377    if (e.source === 'clear' || e.source === 'resume') await boot($)
378    return next(e)
379  })
380
381  on('command.run', { command: 'fit' }, async ($, e) => {
382    const cmd = parseFit(e.args)
383    switch (cmd.kind) {
384      case 'error':
385        return { text: cmd.text }
386      case 'add':
387      case 'set': {
388        if ((await refreshToday($)).isRest) return { text: "Today's a rest day. /fit rest off to train." }
389        const n = cmd.n
390        const t = await logReps($, count => (cmd.kind === 'add' ? count + n : n))
391        return { text: `${line(t)}${paceText(await read($, pace), isStrict)}` }
392      }
393      case 'skip': {
394        const t = await refreshToday($)
395        if (t.isRest) return { text: 'Already a rest day. /fit rest off to train.' }
396        await $.fs.write(`${(await files($)).log}/${t.date}.skip`, '')
397        await refreshToday($)
398        await refreshHistory($)
399        await refreshPace($)
400        return { text: `Rest day logged. Streak resets, no sets due today. Back at it tomorrow. (/fit rest off to undo)` }
401      }
402      case 'unskip':
403        return endRest($)
404      case 'week': {
405        if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
406          await $.ui.close({ id: PANE })
407          return { text: 'Scoreboard closed.' }
408        }
409        const days = await refreshHistory($)
410        await $.ui.open({ id: PANE, title: 'TERMINAL GYM', focus: true, closeOnEscape: true })
411        return { text: `🔥 ${fullStreak(days, await read($, olderStreak))}-day streak · Esc or /fit score to close` }
412      }
413      case 'swap': {
414        const before = await refreshToday($)
415        const { routine: routinePath, log: logDir } = await files($)
416        const routine = await loadRoutine($, routinePath)
417        const names = [...new Set(Object.values(routine).map(p => p.exercise))]
418        if (names.length === 0) return { text: 'Your program has no exercises. /fit program to pick one.' }
419        const plan = pickSwap(routine, before.exercise, cmd.exercise)
420        if (plan === undefined) {
421          return { text: `No ${cmd.exercise} in your program. Pick one of: ${names.join(', ')}.` }
422        }
423        if (plan.exercise === before.exercise) return { text: `Already on ${plan.exercise} today.` }
424        await $.fs.write(`${logDir}/${before.date}.swap`, `${JSON.stringify(plan)}\n`)
425        await refreshHistory($)
426        const amount = before.unit === '' ? `${before.count} reps` : `${before.count}${before.unit}`
427        const carried = before.count > 0 ? ` Your ${amount} carry over.` : ''
428        const swapped = await refreshToday($)
429        await refreshPace($)
430        return { text: `Swapped to ${plan.exercise} today.${carried} ${line(swapped)}` }
431      }
432      case 'program':
433        await openOnboarding($)
434        return { text: 'Pick your program.' }
435      case 'intro':
436        await $.store.delete('introduced')
437        await update($, isIntroduced, () => false)
438        return { text: 'Welcome is back above your prompt.' }
439      case 'reset':
440        return { text: `Reset. ${line(await logReps($, () => 0))}${paceText(await read($, pace), isStrict)}` }
441      case 'help':
442        await markIntroduced($)
443        await $.ui.open({ id: HELP_PANE, title: 'HOUSE RULES' })
444        return { text: 'House rules opened · /fit hide to close' }
445      case 'strict': {
446        const { deny } = await $.config.set({ key: 'terminal-gym.strict', value: cmd.isOn })
447        if (deny !== undefined) return { text: `Couldn't change strict mode: ${deny}` }
448        return {
449          text: cmd.isOn
450            ? `Coach is strict. Today's goal is spread into sets over ${paceConfig.windowMs / HOUR_MS}h from your first prompt; fall behind and prompts wait until you catch up.`
451            : 'Coach is easy. Same schedule on the band, nudges only, nothing held.',
452        }
453      }
454      case 'hide':
455        await $.ui.close({ id: PANE })
456        await $.ui.close({ id: HELP_PANE })
457        return { text: 'Terminal Gym panels closed.' }
458      case 'status': {
459        const t = await refreshToday($)
460        const shown = withStreak(t, await refreshHistory($), await read($, olderStreak))
461        return { text: `${shown}${paceText(await refreshPace($), isStrict)}` }
462      }
463    }
464  })
465
466  on('prompt.submit', async ($, e, next) => {
467    // Only prompts the person typed count: background tasks, loops and other
468    // sessions start turns too. Slash commands (like /fit 10) always pass.
469    const isTyped = e.origin.kind === 'composer' && !e.text.trimStart().startsWith('/')
470    if (!isTyped) return next(e)
471    await startDay($)
472    const p = await refreshPace($)
473    const t = await read($, today)
474    if (isStrict && p !== null && p.behind > 0) {
475      void $.prompt.fill({ text: e.text })
476      return {
477        drop: `Behind pace: ${p.behind} ${t?.exercise ?? 'reps'} to catch up (${p.required}/${t?.goal ?? 0} due by now). /fit ${p.behind} to log. /fit rest skips the day (breaks streak).`,
478      }
479    }
480    return next(e)
481  })
482
483  on('turn.start', async ($, e, next) => {
484    timer?.cancel()
485    if (!isStrict && options.nudges !== false) timer = $.clock.after(nudgeMs, () => void nudge($, nudgeReps))
486    return next(e)
487  })
488
489  on('turn.complete', async ($, e, next) => {
490    if (e.agentId === undefined) {
491      timer?.cancel()
492      timer = undefined
493      await update($, isWaiting, () => false)
494      await refreshToday($)
495      await refreshPace($)
496    }
497    return next(e)
498  })
499
500  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
501    const t = await read($, today)
502    if (options.spinner === false || t === null || !(await read($, isWaiting))) return next(e)
503    return next({ ...e, props: { ...e.props, message: `${nudgeReps} ${t.exercise} while Claude works` } })
504  })
505
506  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
507    const t = await read($, today)
508    if (options.band === false || e.props.hasSurvey || t === null) return next(e)
509
510    const { Box, Button, Text } = $.ui.resolve(e)
511
512    // Lay the band out in rows that fit, widest first: one line, then the
513    // header over the buttons, then every piece on its own line.
514    const cols = e.props.bodyColumns
515    const WORDMARK = MINI_BARBELL.small + MINI_BARBELL.plate + MINI_BARBELL.name + MINI_BARBELL.plateRight + MINI_BARBELL.small
516    const TAGLINE = 'Your agent put in the reps. You next.'
517    const BUTTONS_WIDTH = buttonsWidth(['Pick your training', 'House rules', 'Just train'])
518    const HEADER_WIDTH = cells(WORDMARK) + 2 + cells(TAGLINE)
519
520    if (!(await read($, isIntroduced))) {
521      const buttons = (
522        <Box key="buttons" flexDirection={cols >= BUTTONS_WIDTH ? 'row' : 'column'}>
523          <Button key="setup" label="Pick your training" variant="primary" onPress={() => openOnboarding($)} />
524          {cols >= BUTTONS_WIDTH && <Text> </Text>}
525          <Button key="help" label="House rules" onPress={() => $.ui.open({ id: HELP_PANE, title: 'HOUSE RULES' })} />
526          {cols >= BUTTONS_WIDTH && <Text> </Text>}
527          <Button key="dismiss" label="Just train" onPress={() => markIntroduced($)} />
528        </Box>
529      )
530      const header = (
531        <Box key="header" flexDirection={cols >= HEADER_WIDTH ? 'row' : 'column'}>
532          {cols >= cells(WORDMARK) ? (
533            <Box>
534              <Text dimColor>{MINI_BARBELL.small}</Text>
535              <Text color="#d7af5f">{MINI_BARBELL.plate}</Text>
536              <Text bold>{MINI_BARBELL.name}</Text>
537              <Text color="#d7af5f">{MINI_BARBELL.plateRight}</Text>
538              <Text dimColor>{MINI_BARBELL.small}</Text>
539              {cols >= HEADER_WIDTH && <Text>  </Text>}
540            </Box>
541          ) : (
542            <Text bold wrap="truncate-end">
543              {MINI_BARBELL.name}
544            </Text>
545          )}
546          <Text dimColor wrap="truncate-end">{TAGLINE}</Text>
547        </Box>
548      )
549      const isOneLine = cols >= HEADER_WIDTH + 1 + BUTTONS_WIDTH
550      return (
551        <Box key="band" flexDirection={isOneLine ? 'row' : 'column'}>
552          {header}
553          {isOneLine && <Text> </Text>}
554          {buttons}
555        </Box>
556      )
557    }
558
559    // A rest day is deliberate, not a miss: no rep buttons, just a way back.
560    if (t.isRest) {
561      const REST = `😴 Rest day · ${t.exercise} back tomorrow`
562      const TRAIN = 'Train today'
563      return (
564        <Box key="rest" flexDirection={cols >= cells(REST) + 2 + buttonsWidth([TRAIN]) ? 'row' : 'column'}>
565          <Text dimColor wrap="truncate-end">
566            {REST}
567            {'  '}
568          </Text>
569          <Button key="train" label={TRAIN} onPress={() => endRest($)} />
570        </Box>
571      )
572    }
573
574    const days = await read($, history)
575    const color = progressColor(t.count, t.goal)
576    const add = (n: number) => () => void logReps($, count => count + n)
577    const progress = `${line(t)}${paceText(await read($, pace), isStrict)}`
578    const run = fullStreak(days, await read($, olderStreak))
579    const streakText = run > 0 ? `  🔥${run}d` : ''
580    const LOG_LABEL = 'log reps: '
581    const logWidth = cells(LOG_LABEL) + buttonsWidth(['+5', '+10', '+25']) + cells(streakText)
582
583    return (
584      <Box key="tracker" flexDirection={cols >= cells(progress) + 2 + logWidth ? 'row' : 'column'}>
585        <Text color={color} dimColor={color === undefined} wrap="truncate-end">
586          {progress}
587          {'  '}
588        </Text>
589        <Box>
590          <Text dimColor>{LOG_LABEL}</Text>
591          <Button key="add5" label="+5" onPress={add(5)} />
592          <Text> </Text>
593          <Button key="add10" label="+10" onPress={add(10)} />
594          <Text> </Text>
595          <Button key="add25" label="+25" onPress={add(25)} />
596          {streakText !== '' && <Text>{streakText}</Text>}
597        </Box>
598      </Box>
599    )
600  })
601
602  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
603    const days = await read($, history)
604    const t = await read($, today)
605    const run = fullStreak(days, await read($, olderStreak))
606    const heat = heatCells(days)
607    const week = days.slice(-7)
608
609    const { Box, Text } = $.ui.resolve(e)
610    // The bar needs a monospace grid of BARBELL.width cells: the terminal's.
611    // Elsewhere, or too narrow, the name alone.
612    const barbell =
613      e.surface === 'terminal' && e.props.bodyColumns >= BARBELL.width ? (
614        <Box flexDirection="column">
615          <Text color="#d7af5f">{BARBELL.plates}</Text>
616          <Box>
617            <Text color="#d7af5f">{BARBELL.left}</Text>
618            <Text bold>{BARBELL.name}</Text>
619            <Text color="#d7af5f">{BARBELL.right}</Text>
620          </Box>
621          <Text color="#d7af5f">{BARBELL.plates}</Text>
622          <Text> </Text>
623        </Box>
624      ) : (
625        <Text bold>{BARBELL.name}</Text>
626      )
627
628    // Counts padded to one width so the exercise names line up.
629    const counts = week.map(day => `${day.count}/${day.goal}`)
630    const countWidth = Math.max(0, ...counts.map(count => count.length))
631    const summary = week.map((day, i) => {
632      const mark = day.isSkipped ? '–' : day.goal > 0 && day.count >= day.goal ? '✓' : '·'
633      const isToday = i === week.length - 1
634      return `${mark} ${day.date.slice(5)}  ${(counts[i] ?? '').padEnd(countWidth)}  ${day.exercise}${isToday ? '  ◀ today' : ''}`
635    })
636
637    if (e.surface === 'terminal') {
638      const { Button, Raster } = $.ui.resolve(e)
639      return (
640        <Box flexDirection="column">
641          {barbell}
642          <Text bold>🔥 {run}-day streak</Text>
643          {t !== null && <Text dimColor>{line(t)}</Text>}
644          <Text> </Text>
645          <Text dimColor>last {HISTORY_DAYS} days · ▒ today</Text>
646          <Raster key="heat" {...heat} />
647          <Text> </Text>
648          {summary.map((row, i) => (
649            <Text dimColor={i !== summary.length - 1} bold={i === summary.length - 1}>
650              {row}
651            </Text>
652          ))}
653          <Text> </Text>
654          <Text dimColor>Esc or /fit score to close</Text>
655          <Button key="close" label="Close" onPress={() => closePane($, PANE)} />
656        </Box>
657      )
658    }
659
660    const { Button } = $.ui.resolve(e)
661    return (
662      <Box flexDirection="column">
663        {barbell}
664        <Text bold>🔥 {run}-day streak</Text>
665        {t !== null && <Text dimColor>{line(t)}</Text>}
666        {summary.map(row => <Text dimColor>{row}</Text>)}
667        <Button key="close" label="Close" onPress={() => closePane($, PANE)} />
668      </Box>
669    )
670  })
671
672  on('ui.render', { component: 'Pane', requestId: HELP_PANE }, async ($, e) => {
673    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
674    return (
675      <Box flexDirection="column">
676        <Markdown text={HELP} />
677        <Text> </Text>
678        <Box>
679          <Button key="setup" label="Pick your training" variant="primary" onPress={() => openOnboarding($)} />
680          <Text> </Text>
681          <Button key="close" label="Close" onPress={() => closePane($, HELP_PANE)} />
682        </Box>
683      </Box>
684    )
685  })
686
687  on('ui.render', { component: 'Pane', requestId: ONBOARD_PANE }, async ($, e) => {
688    const { Box, Button, Text } = $.ui.resolve(e)
689    const step = await read($, onboardStep)
690    const p = await read($, onboardPick)
691    const go = (to: number) => () => void update($, onboardStep, () => to)
692    const choose = (patch: Partial<OnboardPick>) => () => void update($, onboardPick, now => ({ ...now, ...patch }))
693    const dots = [0, 1, 2].map(i => (i === step ? '●' : '○')).join(' ')
694
695    const header = (title: string) => (
696      <Box flexDirection="column">
697        <Text dimColor>
698          Step {step + 1} of 3 {dots}
699        </Text>
700        <Text bold>{title}</Text>
701        <Text> </Text>
702      </Box>
703    )
704
705    const nav = (
706      <Box>
707        {step > 0 && <Button key="back" label="Back" onPress={go(step - 1)} />}
708        {step > 0 && <Text> </Text>}
709        {step < 2 ? (
710          <Button key="next" label="Next" variant="primary" onPress={go(step + 1)} />
711        ) : (
712          <Button key="finish" label="Let's go" variant="primary" onPress={() => finishOnboarding($)} />
713        )}
714      </Box>
715    )
716
717    if (step === 0) {
718      return (
719        <Box flexDirection="column">
720          {header('Your agent works the code. You work the reps.')}
721          <Text>Every day has a rep goal.</Text>
722          <Text>When Claude runs a long turn, that's your cue for a set.</Text>
723          <Text> </Text>
724          <Text>Log reps two ways:</Text>
725          <Text dimColor>  type /fit 20</Text>
726          <Text dimColor>  or press +5 +10 +25 above your prompt</Text>
727          <Text> </Text>
728          {nav}
729        </Box>
730      )
731    }
732
733    if (step === 1) {
734      const programs = [...(p.hasRoutine ? [KEEP] : []), ...Object.keys(ROUTINES)]
735      const routine = pickedRoutine(p)
736      return (
737        <Box flexDirection="column">
738          {header('Pick your program')}
739          {programs.map((name, i) => (
740            <Button
741              key={`program-${i}`}
742              label={`${name === p.program ? '●' : '○'} ${name}`}
743              plain
744              onPress={choose({ program: name })}
745            />
746          ))}
747          {routine !== undefined && (
748            <Box flexDirection="column">
749              <Text> </Text>
750              <Text dimColor>Goal size</Text>
751              <Box>
752                {Object.keys(SIZES).map(size => (
753                  <Box>
754                    <Button
755                      key={`size-${size}`}
756                      label={`${size === p.size ? '●' : '○'} ${size}`}
757                      plain
758                      onPress={choose({ size })}
759                    />
760                    <Text>  </Text>
761                  </Box>
762                ))}
763              </Box>
764            </Box>
765          )}
766          <Text> </Text>
767          <Text dimColor>{routine === undefined ? 'Your routine stays as it is.' : describeRoutine(routine)}</Text>
768          <Text> </Text>
769          {nav}
770        </Box>
771      )
772    }
773
774    const routine = pickedRoutine(p)
775    const exercise = routine?.mon?.exercise ?? (await read($, today))?.exercise ?? 'pushups'
776    const sample = withStreak(
777      { date: '', exercise, goal: 100, unit: '', count: 60, isRest: false },
778      // three finished days, today still in progress: 🔥3d
779      [
780        ...Array.from({ length: 3 }, (_, i) => ({ date: `d${i}`, count: 100, goal: 100, exercise, isSkipped: false })),
781        { date: 'today', count: 60, goal: 100, exercise, isSkipped: false },
782      ],
783    )
784    const preview =
785      e.surface === 'terminal' ? (
786        (() => {
787          const { Raster } = $.ui.resolve(e)
788          return <Raster key="sample-grid" {...heatCells(SAMPLE_DAYS)} />
789        })()
790      ) : (
791        <Text dimColor>■ ■ ■ ■ ■ □ ■</Text>
792      )
793
794    return (
795      <Box flexDirection="column">
796        {header('Keep the streak alive')}
797        <Text>Hit your goal and the streak grows.</Text>
798        <Text>It sits at the end of your tracker:</Text>
799        <Text color="#d7af5f">  {sample}</Text>
800        <Text dimColor>Miss a day and it resets to zero.</Text>
801        <Text> </Text>
802        <Text>See every day as a grid:</Text>
803        <Text dimColor>  type /fit score</Text>
804        {preview}
805        <Text> </Text>
806        {nav}
807      </Box>
808    )
809  })
810}
811
hooks/lib.ts 282 lines
1import type { Day, Pace } from '../types'
2
3export type Routine = Record<string, { exercise: string; goal: number; unit?: string }>
4
5export const DEFAULT_ROUTINE: Routine = {
6  mon: { exercise: 'pushups', goal: 100 },
7  tue: { exercise: 'dips', goal: 100 },
8  wed: { exercise: 'squats', goal: 300 },
9  thu: { exercise: 'pushups', goal: 100 },
10  fri: { exercise: 'dips', goal: 100 },
11  sat: { exercise: 'squats', goal: 300 },
12  sun: { exercise: 'pushups', goal: 100 },
13}
14
15const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
16const DAY_MS = 86_400_000
17
18const pad = (n: number) => String(n).padStart(2, '0')
19
20export const dayKey = (ms: number) => {
21  const d = new Date(ms)
22  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
23}
24
25export const weekdayKey = (ms: number) => WEEKDAYS[new Date(ms).getDay()] ?? 'sun'
26
27// Noon anchors keep day arithmetic clear of DST shifts.
28export const lastDays = (ms: number, n: number) => {
29  const d = new Date(ms)
30  const noon = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12).getTime()
31  return Array.from({ length: n }, (_, i) => noon - (n - 1 - i) * DAY_MS)
32}
33
34export const bar = (count: number, goal: number, width = 10) => {
35  const filled = goal > 0 ? Math.min(width, Math.floor((count / goal) * width)) : 0
36  return '▓'.repeat(filled) + '░'.repeat(width - filled)
37}
38
39export type FitCommand =
40  | { kind: 'status' }
41  | { kind: 'week' }
42  | { kind: 'skip' }
43  | { kind: 'unskip' }
44  | { kind: 'help' }
45  | { kind: 'hide' }
46  | { kind: 'reset' }
47  | { kind: 'intro' }
48  | { kind: 'program' }
49  | { kind: 'swap'; exercise?: string }
50  | { kind: 'strict'; isOn: boolean }
51  | { kind: 'add'; n: number }
52  | { kind: 'set'; n: number }
53  | { kind: 'error'; text: string }
54
55const USAGE = 'usage: /fit [<n> | set <n> | reset | swap [exercise] | rest [off] | score | program | rules | strict | easy | start | hide]'
56
57export const parseFit = (args: string): FitCommand => {
58  const [head = '', value] = args.trim().split(/\s+/)
59  if (head === '' || head === 'status') return { kind: 'status' }
60  if (head === 'score') return { kind: 'week' }
61  if (head === 'rest') return value === 'off' ? { kind: 'unskip' } : { kind: 'skip' }
62  if (head === 'program') return { kind: 'program' }
63  if (head === 'swap') {
64    // Names may be several words ("jumping jacks"): keep everything after "swap".
65    const name = args.trim().replace(/^swap\s*/, '').replace(/\s+/g, ' ').toLowerCase()
66    return name === '' ? { kind: 'swap' } : { kind: 'swap', exercise: name }
67  }
68  if (head === 'rules') return { kind: 'help' }
69  if (head === 'hide') return { kind: 'hide' }
70  if (head === 'reset') return { kind: 'reset' }
71  if (head === 'start') return { kind: 'intro' }
72  if (head === 'strict') return { kind: 'strict', isOn: true }
73  if (head === 'easy') return { kind: 'strict', isOn: false }
74  if (head === 'set') {
75    return value !== undefined && /^\d+$/.test(value)
76      ? { kind: 'set', n: Number(value) }
77      : { kind: 'error', text: 'fit: set needs a number' }
78  }
79  if (/^-?\d+$/.test(head)) return { kind: 'add', n: Number(head) }
80  return { kind: 'error', text: USAGE }
81}
82
83export const isDone = (day: Day) => !day.isSkipped && day.goal > 0 && day.count >= day.goal
84
85// Today counts once done; an unfinished today does not break the run yet.
86export const streak = (history: readonly Day[]) => {
87  let run = 0
88  for (let i = history.length - 1; i >= 0; i--) {
89    const day = history[i]
90    if (day === undefined) break
91    if (isDone(day)) run++
92    else if (i !== history.length - 1) break
93  }
94  return run
95}
96
97// The engine environment has Uint8Array#toBase64; es2023 typings do not.
98type Base64Bytes = Uint8Array & { toBase64: () => string }
99
100const ACCENT = 0xc8a86b
101const PARTIAL = 0x6b5d3f
102const EMPTY = 0x343434
103const DEFAULT = 0x01000000
104
105// The streak, counting days older than the window when the window is all done.
106export const fullStreak = (history: readonly Day[], older: number) => {
107  const run = streak(history)
108  const last = history[history.length - 1]
109  const coversWindow = run === history.length || (run === history.length - 1 && last !== undefined && !isDone(last))
110  return coversWindow ? run + older : run
111}
112
113// One row per week, seven 2-cell days with a gap: 20 columns.
114export const heatCells = (history: readonly Day[]) => {
115  const weeks = Math.ceil(history.length / 7)
116  const columns = 20
117  const words = new Uint32Array(columns * weeks * 3)
118  for (let row = 0; row < weeks; row++) {
119    for (let col = 0; col < columns; col++) {
120      const day = history[row * 7 + Math.floor(col / 3)]
121      const isGap = col % 3 === 2 || day === undefined
122      const color = isGap
123        ? DEFAULT
124        : isDone(day)
125          ? ACCENT
126          : day.count > 0
127            ? PARTIAL
128            : EMPTY
129      const at = (row * columns + col) * 3
130      // Today, the last day, is drawn shaded so it stands out from finished days.
131      const isToday = row * 7 + Math.floor(col / 3) === history.length - 1
132      words[at] = isGap ? 0x20 : isToday ? 0x2592 : 0x2588
133      words[at + 1] = color
134      words[at + 2] = DEFAULT
135    }
136  }
137  return { columns, rows: weeks, cells: (new Uint8Array(words.buffer) as Base64Bytes).toBase64() }
138}
139
140// Grey until the first rep, then a muted traffic light: red under a third of the
141// goal, yellow on the way, green for the last fifth. No goal: undefined draws dim.
142export const progressColor = (count: number, goal: number) => {
143  if (goal <= 0 || count <= 0) return undefined
144  const share = count / goal
145  return share >= 0.8 ? '#87af87' : share >= 1 / 3 ? '#d7af5f' : '#d75f5f'
146}
147
148export const daily = (exercise: string, goal: number): Routine =>
149  Object.fromEntries(WEEKDAYS.map(day => [day, { exercise, goal }]))
150
151export const ROUTINES = {
152  'Push · dip · squat rotation': DEFAULT_ROUTINE,
153  'Daily pushups': daily('pushups', 100),
154  'Daily squats': daily('squats', 200),
155} as const satisfies Record<string, Routine>
156
157export const SIZES = { Light: 0.5, Standard: 1, Heavy: 2 } as const satisfies Record<string, number>
158
159export const scale = (routine: Routine, factor: number): Routine =>
160  Object.fromEntries(
161    Object.entries(routine).map(([day, plan]) => [
162      day,
163      { ...plan, goal: Math.max(5, Math.round((plan.goal * factor) / 5) * 5) },
164    ]),
165  )
166
167// "30 burpees" typed under Other: one exercise every day.
168export const parseCustom = (text: string): Routine | undefined => {
169  const match = /^\s*(\d+)\s+([a-z][a-z -]*?)\s*$/i.exec(text)
170  if (match === null) return undefined
171  const [, goal = '0', exercise = ''] = match
172  return Number(goal) > 0 ? daily(exercise.toLowerCase(), Number(goal)) : undefined
173}
174
175// Today's exercise, swapped: the next one in the routine, or the one named.
176export const pickSwap = (routine: Routine, current: string, wanted?: string) => {
177  const plans = [...new Map(Object.values(routine).map(plan => [plan.exercise, plan])).values()]
178  if (wanted !== undefined) return plans.find(plan => plan.exercise.toLowerCase() === wanted)
179  if (plans.length === 0) return undefined
180  const at = plans.findIndex(plan => plan.exercise === current)
181  return plans[(at + 1) % plans.length]
182}
183
184// The scoreboard's barbell, the name across the bar. 34 columns wide.
185export const BARBELL = {
186  plates: ' ▐█▌▐█▌                    ▐█▌▐█▌ ',
187  left: '━▐█▌▐█▌━━━ ',
188  name: 'TERMINAL GYM',
189  right: ' ━━━▐█▌▐█▌━',
190  width: 34,
191} as const
192
193// Terminal cells a string takes: emoji draw two cells wide.
194export const cells = (text: string) =>
195  [...text].reduce((w, ch) => w + ((ch.codePointAt(0) ?? 0) >= 0x1f000 || ch === '✅' ? 2 : 1), 0)
196
197// The terminal draws a Button as `[ label ]`, and a row puts one cell between buttons.
198export const buttonsWidth = (labels: readonly string[]) =>
199  labels.reduce((w, label) => w + cells(label) + 4, 0) + labels.length - 1
200
201// The band's one-line barbell wordmark.
202// Small plate, big plate, bar: ❚█═TERMINAL-GYM═█❚
203export const MINI_BARBELL = { small: '❚', plate: '█═', name: 'TERMINAL-GYM', plateRight: '═█' } as const
204
205// A set is a tenth of the goal, rounded to fives, unless the person picked a size.
206export const setSizeFor = (goal: number, setting: number) => {
207  if (goal <= 0) return 0
208  if (setting > 0) return Math.min(setting, goal)
209  return Math.min(goal, Math.max(5, Math.round(goal / 10 / 5) * 5))
210}
211
212// The goal as evenly spaced sets: the first due at the start, the last at the
213// end of the window. `required` is what should be done by `now`.
214export const paceAt = (
215  goal: number,
216  count: number,
217  setSize: number,
218  startedAt: number,
219  now: number,
220  windowMs: number,
221): Pace => {
222  if (goal <= 0 || setSize <= 0) return { required: 0, behind: 0, setSize, nextDueAt: null }
223  const sets = Math.ceil(goal / setSize)
224  const gap = sets > 1 ? windowMs / (sets - 1) : windowMs
225  const due = Math.min(sets, 1 + Math.floor(Math.max(0, now - startedAt) / gap))
226  const required = Math.min(goal, due * setSize)
227  return {
228    required,
229    behind: Math.max(0, required - count),
230    setSize,
231    nextDueAt: due < sets ? startedAt + due * gap : null,
232  }
233}
234
235export const clockTime = (ms: number) => {
236  const d = new Date(ms)
237  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
238}
239
240export const describeRoutine = (routine: Routine) =>
241  [...new Set(Object.values(routine).map(p => `${p.goal}${p.unit ?? ''} ${p.exercise}`))].join(' / ')
242
243export const HELP = `## House rules
244
245**Your agent put in the reps. You next.**
246When a turn runs long, drop and do a set.
247
248### Log a set
249\`/fit 20\` adds 20 reps
250or press **+5 / +10 / +25** above the prompt
251
252### Commands
253| Command | Does |
254|---|---|
255| \`/fit\` | today's progress |
256| \`/fit set 80\` | fix today's count |
257| \`/fit reset\` | today back to 0 |
258| \`/fit swap\` | switch today's exercise |
259| \`/fit rest\` | rest day (breaks streak) |
260| \`/fit rest off\` | undo today's rest day |
261| \`/fit score\` | streak + grid |
262| \`/fit program\` | pick your training |
263| \`/fit strict\` / \`/fit easy\` | strict mode on / off |
264| \`/fit rules\` | these rules |
265| \`/fit start\` | replay the welcome |
266| \`/fit hide\` | close panels |
267
268### Strict mode
269Off by default. When it's on:
270- today's goal is split into sets, spread over 8 hours (by default) from your first prompt
271- prompts before 5am count as the night before
272- the first set is due right away
273- fall behind and your prompts wait until you catch up
274- a toast tells you when each set comes due
275- \`/fit rest\` bails, but costs your streak
276
277**Turn it on:** type \`/fit strict\`
278**Turn it off:** type \`/fit easy\`
279
280### Your data
281Stays on this machine, in \`~/.claude/fitness\`.`
282
types/index.d.ts 46 lines
1export type Today = {
2  date: string
3  exercise: string
4  goal: number
5  unit: string
6  count: number
7  isRest: boolean
8}
9
10export type Day = {
11  date: string
12  count: number
13  goal: number
14  exercise: string
15  isSkipped: boolean
16}
17
18export type OnboardPick = {
19  program: string
20  size: string
21  hasRoutine: boolean
22}
23
24// Where today stands against the schedule of sets.
25export type Pace = {
26  required: number
27  behind: number
28  setSize: number
29  nextDueAt: number | null
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'terminal-gym': {
35      today: Today | null
36      pace: Pace | null
37      isWaiting: boolean
38      isIntroduced: boolean
39      onboardStep: number
40      onboardPick: OnboardPick
41      history: Day[]
42      olderStreak: number
43    }
44  }
45}
46