SLOPSHOPPER

push-ups

Predicts how long Claude's turn will take and gives you push-ups to do while you wait, in a bar above the prompt. Local only: no model calls, no tokens.

newbandcommandtimer
v0.2.0MITupdated 2026-10-09Risdon8/push-ups
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · push-ups
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pushups ⎿ push-ups: push-ups, medium mode (one per 4s of predicted wait, up to 30 a turn) ⎿ push-ups: this session 0 ⎿ push-ups: today 0 ⎿ push-ups: last 7 days 0 ⎿ push-ups: all time 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Push Ups

A Claude Code mod for waiting on Claude. It predicts how long the turn will take and gives you push-ups to do in the meantime, in a bar above the prompt.

It makes no model calls, so it uses no tokens. Everything runs locally.

The bar mid-turn: do 8 push-ups, 3 done so far, 20s left

What you see

While Claude works. A figure does push-ups, the bar fills as the predicted time passes, and the title tells you how many to do ("Do 8 push-ups") and the pill counts how many you should have done by now (3/8).

Mid-turn

If the turn runs long. The bar is full and the title changes. The right side counts how far past the prediction you are.

Past the prediction

When it ends. The row turns green and shows the set you finished and your total for today. It stays until you press ✕ or send your next prompt.

Done

These images are rendered from the mod's own drawing code (tools/generate-previews.mjs), so they match the real row. In Claude Code's terminal the same row is drawn in text with a █░ bar. In the desktop app the figure and the bar animate.

How it works

  • Prediction: the median of your last 10 turns of a similar prompt size (short, medium, long). Until that size has 3 turns it uses the median of all your turns, and before that a default of 15s, 30s or 45s. It never predicts more than 90s.
  • The bar: a figure doing push-ups, a dithered progress bar, the target up front, your count so far (3/8) and the time left. Past the prediction it says "Keep going". When the turn ends it shows what you did and your total for today.
  • Honor system: only the push-ups the wait covered count. A turn that finishes in 5 seconds counts a couple, not a whole set, and an interrupted turn counts the share of time that passed.

Install

Needs Claude Code 2.1.287 or later.

claude plugin marketplace add Risdon8/push-ups
claude plugin install push-ups@push-ups

Restart Claude Code or run /reload-plugins.

Commands

/pushups totals: this session, today, last 7 days, all time /pushups mode show the current mode /pushups mode easy one per 6s of predicted wait, up to 20 a turn /pushups mode medium one per 4s, up to 30 (default) /pushups mode hard one per 2.5s, up to 60

Development

claude plugin validate . claude plugin test .

MIT licensed.

Source 2 files
hooks/register.tsx 356 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Result, Run } from '../types'
5
6// Push-ups for the wait. The prediction is the median of recent turns of the
7// same size, kept in $.store across sessions. No model is called, so no tokens.
8
9const run = atom({ plugin: 'push-ups', key: 'run' } as const, null)
10const now = atom({ plugin: 'push-ups', key: 'now' } as const, 0)
11const result = atom({ plugin: 'push-ups', key: 'result' } as const, null)
12const session = atom({ plugin: 'push-ups', key: 'session' } as const, 0)
13
14const HISTORY = 10
15const MIN_SAMPLES = 3
16const DEFAULT_MS = { short: 15_000, medium: 30_000, long: 45_000 }
17const MIN_PREDICT_MS = 8_000
18const MAX_PREDICT_MS = 90_000
19// Seconds of predicted wait per push-up, and the most one turn asks for.
20const MODES = {
21  easy: { ms: 6_000, max: 20 },
22  medium: { ms: 4_000, max: 30 },
23  hard: { ms: 2_500, max: 60 },
24}
25const MIN_PUSHUPS = 2
26const TICK_MS = 1_000
27
28const CLAY = '#D97757'
29const DONE = '#5fbf8f'
30
31type Bucket = keyof typeof DEFAULT_MS
32type Mode = keyof typeof MODES
33
34const bucketOf = (text: string): Bucket => {
35  const n = text.trim().length
36  return n < 80 ? 'short' : n < 300 ? 'medium' : 'long'
37}
38
39const fmtSec = (ms: number): string => `${Math.max(0, Math.round(ms / 1000))}s`
40
41const median = (xs: number[]): number => {
42  const sorted = [...xs].sort((a, b) => a - b)
43  return sorted[Math.floor(sorted.length / 2)]
44}
45
46const history = async ($: EngineInterface, bucket: Bucket | 'all'): Promise<number[]> => {
47  const past = await $.store.get(`hist:${bucket}`)
48  return Array.isArray(past) ? past.filter((x): x is number => typeof x === 'number') : []
49}
50
51const isMode = (x: unknown): x is Mode => x === 'easy' || x === 'medium' || x === 'hard'
52
53async function readMode($: EngineInterface): Promise<Mode> {
54  const mode = await $.store.get('mode')
55  return isMode(mode) ? mode : 'medium'
56}
57
58const pad = (n: number): string => String(n).padStart(2, '0')
59
60const dayKey = (ms: number): string => {
61  const d = new Date(ms)
62  return `day:${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
63}
64
65const asCount = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
66
67/** Adds finished push-ups to today's count and the all-time total; answers today's count. */
68async function logReps($: EngineInterface, reps: number): Promise<number> {
69  const key = dayKey(await $.clock.now())
70  const today = asCount(await $.store.get(key)) + reps
71  await $.store.set(key, today)
72  await $.store.set('all', asCount(await $.store.get('all')) + reps)
73  return today
74}
75
76async function statsText($: EngineInterface, sessionCount: number): Promise<string> {
77  const mode = await readMode($)
78  const nowMs = await $.clock.now()
79  let week = 0
80  for (let i = 0; i < 7; i++) week += asCount(await $.store.get(dayKey(nowMs - i * 86_400_000)))
81  const today = asCount(await $.store.get(dayKey(nowMs)))
82  const all = asCount(await $.store.get('all'))
83  return [
84    `push-ups, ${mode} mode (one per ${MODES[mode].ms / 1000}s of predicted wait, up to ${MODES[mode].max} a turn)`,
85    `this session  ${sessionCount}`,
86    `today         ${today}`,
87    `last 7 days   ${week}`,
88    `all time      ${all.toLocaleString('en-US')}`,
89  ].join('\n')
90}
91
92// ---------------------------------------------------------------------------
93// What the band shows for one moment: running, past the prediction, or done.
94
95type View = {
96  phase: 'running' | 'over' | 'done'
97  ratio: number
98  reps: number
99  total: number
100  title: string
101  pill: string
102  right: string
103}
104
105export const viewOf = (r: Run | null, res: Result | null, t: number): View | null => {
106  if (res !== null) {
107    return { phase: 'done', ratio: 1, reps: res.total, total: res.total, title: 'Nice work', pill: res.reps < res.total ? `✓ ${res.reps}/${res.total}` : `✓ ${res.reps}`, right: `${res.today.toLocaleString('en-US')} today` }
108  }
109  if (r === null) return null
110  const elapsed = Math.max(0, t - r.startedAt)
111  if (elapsed > r.predictedMs) {
112    return { phase: 'over', ratio: 1, reps: r.total, total: r.total, title: 'Keep going', pill: `${r.total}/${r.total}`, right: `+${fmtSec(elapsed - r.predictedMs)}` }
113  }
114  const ratio = elapsed / r.predictedMs
115  const reps = Math.min(r.total, Math.floor(ratio * r.total))
116  return { phase: 'running', ratio, reps, total: r.total, title: `Do ${r.total} push-ups`, pill: `${reps}/${r.total}`, right: `${fmtSec(r.predictedMs - elapsed)} left` }
117}
118
119// ---------------------------------------------------------------------------
120// Desktop: the whole row is one SVG (the dithered bar of savvy-progress, with a
121// small figure doing push-ups). Terminal: the same row in text.
122
123const H = 24
124const BAR_H = 16
125const CELL = 3
126const FIG_W = 32
127const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
128
129const xml = (s: string): string =>
130  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
131
132const noise = (a: number, b: number): number => {
133  const x = Math.sin(a * 127.1 + b * 311.7) * 43758.5453
134  return x - Math.floor(x)
135}
136
137// Rough advance of system UI text, in em; enough to size the title's slot.
138const charEm = (ch: string): number =>
139  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%]/.test(ch) ? 0.72 : 0.56
140
141const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
142
143/** A side-on figure: hands and feet stay on the floor, the shoulder rises and falls. */
144const figure = (color: string, isMoving: boolean): string => {
145  const fx = 28, fy = 21, hx = 5, sx = 8
146  const up = 15.5, down = 19
147  const head = (ys: number) => {
148    const dx = sx - fx, dy = ys - fy, len = Math.hypot(dx, dy)
149    return { cx: +(sx + (dx / len) * 5).toFixed(2), cy: +(ys + (dy / len) * 5).toFixed(2) }
150  }
151  const a = head(up), b = head(down)
152  const anim = (attr: string, v1: number, v2: number) => isMoving
153    ? `<animate attributeName="${attr}" values="${v1};${v2};${v1}" keyTimes="0;.5;1" calcMode="spline" keySplines=".4 0 .6 1;.4 0 .6 1" dur="1.6s" repeatCount="indefinite"/>`
154    : ''
155  return `<rect class="k" x="0" y="22.2" width="${FIG_W}" height="1.6" rx="0.8"/>
156<g stroke="${color}" stroke-width="2.2" stroke-linecap="round" fill="none">
157<line x1="${hx}" y1="${fy}" x2="${sx}" y2="${up}">${anim('y2', up, down)}</line>
158<line x1="${sx}" y1="${up}" x2="${fx}" y2="${fy}">${anim('y1', up, down)}</line>
159</g>
160<circle cx="${a.cx}" cy="${a.cy}" r="2.7" fill="${color}">${anim('cx', a.cx, b.cx)}${anim('cy', a.cy, b.cy)}</circle>`
161}
162
163export const rowSvg = (v: View, W: number): string => {
164  const color = v.phase === 'done' ? DONE : CLAY
165  const isDone = v.phase === 'done'
166  const BAR_X = Math.round(FIG_W + 10 + textWidth(v.title, 13) + 12)
167  const BAR_W = Math.max(60, W - BAR_X - 74)
168  const y0 = (H - BAR_H) / 2
169  const fillW = Math.round(BAR_W * v.ratio)
170  const dots: string[] = []
171
172  // Dithered fill: sparse at the start, dense toward the head.
173  const cols = Math.floor(fillW / CELL)
174  const rows = Math.floor(BAR_H / CELL)
175  for (let c = 0; c < cols; c++) {
176    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
177    for (let r = 0; r < rows; r++) {
178      if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
179    }
180  }
181
182  // One tick per push-up still to do.
183  const ticks: string[] = []
184  for (let i = 1; i < v.total; i++) {
185    const x = Math.round((BAR_W * i) / v.total)
186    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
187  }
188
189  const pillW = Math.round(18 + v.pill.length * 6.6)
190  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
191
192  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
193<style>
194.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
195@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
196.t0,.t1,.t2,.t3{animation:tw ${isDone ? 3.2 : 2.2}s ease-in-out infinite}
197.t1{animation-duration:${isDone ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${isDone ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${isDone ? 3.5 : 3.3}s;animation-delay:-.4s}
198@keyframes tw{0%,100%{opacity:1}50%{opacity:${isDone ? 0.8 : 0.3}}}
199@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
200</style>
201<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
202${figure(color, !isDone)}
203<text class="t" x="${FIG_W + 10}" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(v.title)}</text>
204<g transform="translate(${BAR_X},${y0})">
205<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
206<g clip-path="url(#c)">
207<g fill="${color}">${dots.join('')}</g>
208<g class="tk">${ticks.join('')}</g>
209</g>
210<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
211<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(v.pill)}</text>
212</g>
213<text class="m" x="${W - 2}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${xml(v.right)}</text>
214</svg>`
215}
216
217const barText = (ratio: number, width: number): string => {
218  const filled = Math.round(width * ratio)
219  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
220}
221
222// ---------------------------------------------------------------------------
223
224let ticking: { cancel: () => void } | null = null
225let activeTurn = ''
226
227function stopTicking() {
228  if (ticking) ticking.cancel()
229  ticking = null
230}
231
232function tick($: EngineInterface) {
233  ticking = $.clock.after(TICK_MS, async () => {
234    ticking = null
235    if (!activeTurn) return
236    const t = await $.clock.now()
237    await update($, now, () => t)
238    if (activeTurn) tick($)
239  })
240}
241
242export const register: Register = on => {
243  on('turn.start', async ($, e, next) => {
244    const r = await next(e)
245    stopTicking()
246
247    const bucket = bucketOf(e.text)
248    // This prompt size's own turns first, then every turn so far, then a default.
249    const own = await history($, bucket)
250    const every = await history($, 'all')
251    const guess = own.length >= MIN_SAMPLES ? median(own) : every.length >= MIN_SAMPLES ? median(every) : DEFAULT_MS[bucket]
252    const predictedMs = Math.min(MAX_PREDICT_MS, Math.max(MIN_PREDICT_MS, guess))
253    const mode = MODES[await readMode($)]
254    const total = Math.min(mode.max, Math.max(MIN_PUSHUPS, Math.round(predictedMs / mode.ms)))
255    const startedAt = await $.clock.now()
256
257    activeTurn = e.turnId
258    await update($, result, () => null)
259    await update($, now, () => startedAt)
260    await update($, run, () => ({ turnId: e.turnId, startedAt, predictedMs, total, bucket }))
261    tick($)
262    return r
263  })
264
265  on('turn.complete', async ($, e, next) => {
266    const r = await next(e)
267    if (e.agentId || e.turnId !== activeTurn) return r
268
269    activeTurn = ''
270    stopTicking()
271    const mine = await read($, run)
272    await update($, run, () => null)
273    if (mine === null) {
274      await update($, result, () => null)
275      return r
276    }
277
278    // Only the push-ups the wait covered count, whether the turn finished early, ran on or was interrupted.
279    const reps = Math.min(mine.total, Math.floor((Math.max(0, e.durationMs) / mine.predictedMs) * mine.total))
280    if (!e.isAborted) {
281      for (const key of [mine.bucket, 'all'] as const) {
282        const hist = [...(await history($, key)), e.durationMs].slice(-HISTORY)
283        await $.store.set(`hist:${key}`, hist)
284      }
285    }
286    if (reps === 0) {
287      await update($, result, () => null)
288      return r
289    }
290
291    const today = await logReps($, reps)
292    await update($, session, n => n + reps)
293    await update($, result, () => ({ tookMs: e.durationMs, predictedMs: mine.predictedMs, total: mine.total, reps, today }))
294    return r
295  })
296
297  on('session.start', async ($, e, next) => {
298    await $.command.register({
299      name: 'pushups',
300      description: 'Push-up totals for this session, today, the week and all time; mode easy|medium|hard (push-ups)',
301      argumentHint: '[mode easy|medium|hard]',
302    })
303    return next(e)
304  })
305
306  on('command.run', { command: 'pushups' }, async ($, e) => {
307    const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
308    if (words[0] === 'mode') {
309      if (!isMode(words[1])) return { text: `push-ups is in ${await readMode($)} mode; /pushups mode easy, medium or hard changes it` }
310      await $.store.set('mode', words[1])
311      return { text: `push-ups set to ${words[1]}: one per ${MODES[words[1]].ms / 1000}s of predicted wait, up to ${MODES[words[1]].max} a turn` }
312    }
313    return { text: await statsText($, await read($, session)) }
314  })
315
316  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
317    const r = await read($, run)
318    const res = await read($, result)
319    if (e.props.hasSurvey) return next(e)
320
321    const view = viewOf(r, res, await read($, now))
322    if (view === null) return next(e)
323
324    const ui = $.ui.resolve(e)
325    const { Box, Text, Button } = ui
326    const dismiss = view.phase === 'done'
327      ? <Button key="pu-dismiss" label="✕" plain role="dismiss" onPress={() => update($, result, () => null)} />
328      : null
329    const alt = `${view.title}: ${view.pill}, ${view.right}`
330
331    if ('Svg' in ui) {
332      const { Svg } = ui
333      const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 48))
334      return (
335        <Box flexDirection="row" alignItems="center" gap={1}>
336          <Svg source={rowSvg(view, width)} alt={alt} width={width} height={H} />
337          {dismiss}
338        </Box>
339      )
340    }
341
342    const color = view.phase === 'done' ? DONE : CLAY
343    const cols = e.props.bodyColumns
344    const barW = Math.max(6, Math.min(40, cols - 36))
345    return (
346      <Box flexDirection="row" gap={2}>
347        <Text color={color}>▣ <Text bold>{view.title}</Text></Text>
348        <Text color={color}>{barText(view.ratio, barW)}</Text>
349        <Text bold>{view.pill}</Text>
350        <Text dimColor>{view.right}</Text>
351        {dismiss}
352      </Box>
353    )
354  })
355}
356
types/index.d.ts 9 lines
1export type Run = { turnId: string; startedAt: number; predictedMs: number; total: number; bucket: 'short' | 'medium' | 'long' }
2export type Result = { tookMs: number; predictedMs: number; total: number; reps: number; today: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'push-ups': { run: Run | null; now: number; result: Result | null; session: number }
7  }
8}
9