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.

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.

https://github.com/user-attachments/assets/14373c7e-ad8a-4f92-812b-c93c3201b1b3
💪 ▓▓▓▓▓▓░░░░ 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./fit score): an ASCII barbell (in the terminal), your streak, a 28-day grid and the last week by exercise, with today marked.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:
| When | What 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 10 | You're through. The band reads next set 09:53 |
| 9:53 | Toast: "⏱ 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 day | Nothing 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.
| Command | Does |
|---|---|
/fit | today's progress, pace and streak |
/fit 20 | log 20 reps |
/fit set 80 | fix today's count |
/fit reset | today back to 0 |
/fit swap [exercise] | switch today's exercise: the next in your program, or the one you name |
/fit rest | rest day: no sets due, breaks your streak |
/fit rest off | undo today's rest day (or press Train today on the band) |
/fit score | scoreboard: streak, grid and the last week (run again or press Esc to close) |
/fit program | pick your program (the walkthrough) |
/fit strict / /fit easy | strict mode on / off |
/fit rules | house rules |
/fit start | replay the welcome |
/fit hide | close panels |
In /config, under terminal-gym:
| Setting | Default | What it does |
|---|---|---|
| Strict mode | off | Hold typed prompts while you're behind pace |
| Training window | 8 hours | How long after your first prompt the day's sets are spread over |
| Set size | auto | Reps per set; auto is a tenth of the goal, rounded to fives |
| Long-turn nudges | on | Easy mode: suggest a set when a turn runs long |
| Nudge after | 30 seconds | How long a turn runs before the nudge |
| Reps per nudge | 10 | The set the nudge suggests |
| Spinner takeover | on | Easy mode: show the suggested set in the spinner |
| Quick-log band | on | Show the tracker band above the prompt |
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.A mod runs inside Claude Code, so here is everything Terminal Gym touches:
~/.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./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.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.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.
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/.
MIT
hooks/register.tsx 811 lines1import { 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}
811hooks/lib.ts 282 lines1import 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\`.`
282types/index.d.ts 46 lines1export 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