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.

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.
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).
If the turn runs long. The bar is full and the title changes. The right side counts how far past the prediction you are.
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.
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.
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.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.
/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
claude plugin validate . claude plugin test .
MIT licensed.
hooks/register.tsx 356 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
356types/index.d.ts 9 lines1export 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