SLOPSHOPPER

tempo

KOZMOS Tempo: a Pomodoro focus timer above the prompt. A tomato, the phase, a ring (desktop) or a sizzling bar (terminal) and the time left; long breaks every…

newbandcommandtoasttimeraudio
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/tempo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tempo
› 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 › /tempo ⎿ tempo: No timer running · 🍅×0 today. Start one with /tempo start [work=25] [break=5]. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Tempo

A Pomodoro focus timer above the prompt.

  • /tempo start [work=25] [break=5] (also start 50 10), /tempo stop, /tempo skip, /tempo for the status, /tempo hide / show (or ✕).
  • Every fourth focus round earns a long break (three short ones). The timer is kept in the store, so it survives a restart and catches up on phases it slept through.
  • At each phase end: a toast (and, with the speak setting, the system voice). Finished focus rounds are counted per day: 🍅×4 today.
  • During a break while Claude is still working, the band says Break — Claude keeps going ☕.

Terminal: an animated row: a tomato (or a steaming cup), the phase, a bar whose leading edge sizzles while a glint sweeps the filled part, the time left with a blinking colon, and today's tally. Desktop: a progress ring (stroke-dashoffset animation) around a bobbing tomato or a steaming cup, the clock, a progress track, the four-round cycle and today's tomatoes. The band is hidden when no timer runs.

Türkçe

İstemin üstünde bir Pomodoro odak zamanlayıcısı. /tempo start [iş=25] [mola=5], /tempo stop, /tempo skip, /tempo (durum). Her dördüncü turda uzun mola. Faz bitince bildirim (isteğe bağlı sesli), günlük 🍅×4 today sayacı. Moladayken Claude çalışmaya devam ediyorsa Break — Claude keeps going ☕ yazar. Terminalde animasyonlu çubuk ve süre, masaüstünde SVG halka. Zamanlayıcı yoksa band gizlidir.

Source 5 files
hooks/register.tsx 309 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TempoSnap } from '../types'
5import { KZ, fitText, pxOf, svg, svgBar, svgText, xml } from './lib/kz.ts'
6import { catchUp, dayKey, isTimer, mmss, nextPhase, phaseLabel, parseStart, pruneDays, remainingMs, startTimer } from './timer.ts'
7import type { Phase, Timer } from './timer.ts'
8
9const snapAtom = atom({ plugin: 'tempo', key: 'snap' } as const, null)
10const hiddenAtom = atom({ plugin: 'tempo', key: 'isHidden' } as const, false)
11
12// Module memory, reloaded from $.store on session.start.
13let timer: Timer | null = null
14let days: Record<string, number> = {}
15let isClaudeWorking = false
16let shouldSpeak = false
17let lastPublished = ''
18
19async function saveTimer($: EngineInterface): Promise<void> {
20  try {
21    if (timer) await $.store.set('timer', timer)
22    else await $.store.delete('timer')
23  } catch {
24    // Kept in memory for this session.
25  }
26}
27
28async function publish($: EngineInterface): Promise<void> {
29  const now = await $.clock.now()
30  const snap: TempoSnap | null = timer
31    ? {
32        phase: timer.phase,
33        startedAt: timer.startedAt,
34        endsAt: timer.endsAt,
35        now,
36        workMin: timer.workMin,
37        breakMin: timer.breakMin,
38        round: timer.round,
39        today: days[dayKey(now)] ?? 0,
40        isClaudeWorking,
41      }
42    : null
43  const key = JSON.stringify(snap ? { ...snap, now: Math.floor(now / 1000) } : null)
44  if (key === lastPublished) return
45  lastPublished = key
46  await update($, snapAtom, () => snap)
47}
48
49async function announce($: EngineInterface, ended: Phase, after: Timer, at: number): Promise<void> {
50  let text: string
51  let spoken: string
52  if (ended === 'work') {
53    const k = dayKey(at)
54    days = pruneDays({ ...days, [k]: (days[k] ?? 0) + 1 })
55    try {
56      await $.store.set('days', days)
57    } catch {
58      // The tally stays in memory.
59    }
60    const mins = Math.round((after.endsAt - after.startedAt) / 60_000)
61    text = `🍅 Focus round ${after.round} done — ${after.phase === 'long' ? 'long break' : 'break'}, ${mins} min. 🍅×${days[k]} today`
62    spoken = `Focus round done. Take ${mins} minutes.`
63  } else {
64    text = `☕ Break over — focus round ${after.round}, ${after.workMin} min. Let's go.`
65    spoken = 'Break over. Back to focus.'
66  }
67  $.ui.toast(text, { timeoutMs: 9000 })
68  if (shouldSpeak) {
69    try {
70      await $.audio.speak(spoken)
71    } catch {
72      // No synthesizer here.
73    }
74  }
75}
76
77async function tick($: EngineInterface): Promise<void> {
78  if (!timer) return
79  const now = await $.clock.now()
80  const { timer: moved, finished } = catchUp(timer, now)
81  if (finished.length) {
82    timer = moved
83    await saveTimer($)
84    // Several phases slept through: only the latest is worth a toast.
85    const last = finished[finished.length - 1]!
86    for (const p of finished.slice(0, -1)) if (p === 'work') days = pruneDays({ ...days, [dayKey(now)]: (days[dayKey(now)] ?? 0) + 1 })
87    await announce($, last, moved, now)
88  }
89  await publish($)
90}
91
92async function setHidden($: EngineInterface, value: boolean): Promise<void> {
93  await update($, hiddenAtom, () => value)
94  try {
95    await $.store.set('hidden', value)
96  } catch {
97    // Session only.
98  }
99}
100
101function status(now: number): string {
102  const today = days[dayKey(now)] ?? 0
103  const tally = `🍅×${today} today`
104  if (!timer) return `No timer running · ${tally}. Start one with /tempo start [work=25] [break=5].`
105  return `${timer.phase === 'work' ? '🍅' : '☕'} ${phaseLabel(timer.phase)}${timer.phase === 'work' ? ` round ${timer.round}` : ''} · ${mmss(remainingMs(timer, now))} left · ${timer.workMin}/${timer.breakMin} min · ${tally}`
106}
107
108export const register: Register = (on, options) => {
109  on('session.start', async ($, e, next) => {
110    const started = await next(e)
111    shouldSpeak = options.speak === true
112    lastPublished = ''
113    isClaudeWorking = false
114    try {
115      const t = await $.store.get('timer')
116      timer = isTimer(t) ? t : null
117      const d = await $.store.get('days')
118      days = d && typeof d === 'object' ? pruneDays(d as Record<string, number>) : {}
119      const isHidden = (await $.store.get('hidden')) === true
120      await update($, hiddenAtom, () => isHidden)
121    } catch {
122      timer = null
123      days = {}
124    }
125    await $.command.register({ name: 'tempo', description: 'KOZMOS: Pomodoro focus timer (start [work] [break], stop, skip, hide, show)', argumentHint: '[start [work=25] [break=5] | stop | skip | hide | show]', immediate: true })
126    await tick($).catch(() => undefined)
127    $.clock.every(1000, () => void tick($).catch(() => undefined))
128    return started
129  })
130
131  on('command.run', { command: 'tempo' }, async ($, e) => {
132    const words = e.args.trim().split(/\s+/).filter(Boolean)
133    const verb = (words[0] ?? '').toLowerCase()
134    const now = await $.clock.now()
135    if (verb === 'start') {
136      const parsed = parseStart(words.slice(1))
137      if ('error' in parsed) return { text: parsed.error }
138      timer = startTimer(now, parsed.workMin, parsed.breakMin)
139      await saveTimer($)
140      await setHidden($, false)
141      await publish($)
142      return { text: `🍅 Focus round 1 · ${parsed.workMin} min, then ${parsed.breakMin} min break. /tempo stop ends it.` }
143    }
144    if (verb === 'stop') {
145      if (!timer) return { text: 'No timer is running.' }
146      timer = null
147      await saveTimer($)
148      await publish($)
149      return { text: `Timer stopped · 🍅×${days[dayKey(now)] ?? 0} today.` }
150    }
151    if (verb === 'skip') {
152      if (!timer) return { text: 'No timer is running.' }
153      timer = nextPhase(timer, now)
154      await saveTimer($)
155      await publish($)
156      return { text: `Skipped to ${phaseLabel(timer.phase).toLowerCase()} · ${mmss(remainingMs(timer, now))}.` }
157    }
158    if (verb === 'hide' || verb === 'show') {
159      await setHidden($, verb === 'hide')
160      return { text: verb === 'hide' ? 'Tempo band hidden (the timer keeps running).' : 'Tempo band shown.' }
161    }
162    return { text: status(now) }
163  })
164
165  on('turn.start', async ($, e, next) => {
166    isClaudeWorking = true
167    void publish($).catch(() => undefined)
168    return next(e)
169  }).catch(($, e, next) => next(e))
170
171  on('turn.complete', async ($, e, next) => {
172    if (e.agentId === undefined) {
173      isClaudeWorking = false
174      void publish($).catch(() => undefined)
175    }
176    return next(e)
177  }).catch(($, e, next) => next(e))
178
179  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180    const drawn = await next(e)
181    const snap = await read($, snapAtom)
182    if (!snap || e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
183    const { Box, Button } = $.ui.resolve(e)
184    const cols = Math.max(30, e.props.bodyColumns || 80)
185    const remain = Math.max(0, snap.endsAt - snap.now)
186    const total = Math.max(1, snap.endsAt - snap.startedAt)
187    const hide = <Button key="tempo-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
188
189    // The terminal animates a Client; every other surface draws SVG.
190    let mine
191    if (e.surface === 'terminal') {
192      const { Client } = $.ui.resolve(e)
193      const props = { cols: cols - 3, phase: snap.phase, remain, total, round: snap.round, today: snap.today, claude: snap.isClaudeWorking }
194      mine = (
195        <Box key="tempo" flexDirection="row">
196          <Client key="tempo-band" module="./band.tsx" width={cols - 3} height={1} props={props} />
197          {hide}
198        </Box>
199      )
200    } else {
201      const { Svg } = $.ui.resolve(e)
202      const W = pxOf(cols)
203      mine = (
204        <Box key="tempo" flexDirection="row">
205          <Svg source={ringRow(snap, W)} alt={altOf(snap)} width={W} height={48} />
206          {hide}
207        </Box>
208      )
209    }
210    return (
211      <Box flexDirection="column">
212        {drawn}
213        {mine}
214      </Box>
215    )
216  })
217}
218
219function altOf(s: TempoSnap): string {
220  const left = mmss(Math.max(0, s.endsAt - s.now))
221  const what = s.phase === 'work' ? `Focus round ${s.round}` : s.phase === 'long' ? 'Long break' : 'Break'
222  const claude = s.phase !== 'work' && s.isClaudeWorking ? ' — Claude keeps going ☕' : ''
223  return `${s.phase === 'work' ? '🍅' : '☕'} ${what} · ${left} left · 🍅×${s.today} today${claude}`
224}
225
226/** The desktop row: a progress ring around a tomato (or cup), the clock, round pips and today's tally. */
227function ringRow(s: TempoSnap, W: number): string {
228  const isWork = s.phase === 'work'
229  const col = isWork ? '#ef4444' : KZ.teal
230  const remain = Math.max(0, s.endsAt - s.now)
231  const total = Math.max(1, s.endsAt - s.startedAt)
232  const p = Math.max(0, Math.min(1, 1 - remain / total))
233  const r = 17
234  const C = 2 * Math.PI * r
235  const cx = 24
236  const cy = 24
237  const secs = Math.max(1, Math.ceil(remain / 1000))
238  // Unique per drawing: inline SVGs on one page share keyframe names.
239  const anim = `tpa${Math.round(p * 1e5)}`
240  const css = `
241.tp-trk{stroke:#e7e5e0}@media (prefers-color-scheme: dark){.tp-trk{stroke:#2d2d2b}}
242.tp-arc{stroke-dasharray:${C.toFixed(2)};animation:${anim} ${secs}s linear forwards}
243@keyframes ${anim}{from{stroke-dashoffset:${(C * (1 - p)).toFixed(2)}}to{stroke-dashoffset:0}}
244.tp-bob{transform-box:fill-box;transform-origin:center bottom;animation:tpb 2.4s ease-in-out infinite}
245@keyframes tpb{50%{transform:translateY(-1px) rotate(-4deg)}}
246.tp-stm{animation:tps 2.2s ease-in-out infinite;opacity:0}.tp-stm2{animation-delay:.7s}.tp-stm3{animation-delay:1.4s}
247@keyframes tps{0%{opacity:0;transform:translateY(3px)}40%{opacity:.8}100%{opacity:0;transform:translateY(-6px)}}
248.tp-hot{animation:tph 1s ease-in-out infinite}@keyframes tph{50%{opacity:.45}}
249.tp-dots tspan{animation:tpd 1.2s infinite;opacity:.2}.tp-dots tspan:nth-child(2){animation-delay:.2s}.tp-dots tspan:nth-child(3){animation-delay:.4s}
250@keyframes tpd{40%{opacity:1}}
251`
252  const parts: string[] = []
253  parts.push(`<rect class="p" x="0" y="0" width="${W}" height="48" rx="12"/>`)
254  parts.push(`<circle class="tp-trk" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="4"/>`)
255  parts.push(`<g class="${remain < 60_000 ? 'tp-hot' : ''}"><circle class="tp-arc" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${col}" stroke-width="4" stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/></g>`)
256  if (isWork) {
257    // A tomato: round body, a highlight, a five-point calyx and a stem.
258    parts.push(`<g class="tp-bob"><circle cx="${cx}" cy="${cy + 1.5}" r="8.5" fill="#ef4444"/><ellipse cx="${cx - 3}" cy="${cy - 1}" rx="2.4" ry="1.6" fill="#fecaca" opacity=".8"/>` +
259      `<path d="M${cx} ${cy - 6.5}l-4.2-1.2 2.6 2.6-3.6 1.4 4.2.2 1 3 1-3 4.2-.2-3.6-1.4 2.6-2.6z" fill="#22c55e"/><path d="M${cx} ${cy - 6.5}v-3" stroke="#15803d" stroke-width="1.6" stroke-linecap="round"/></g>`)
260  } else {
261    // A cup with three wisps of steam.
262    parts.push(`<path d="M${cx - 7} ${cy - 2}h12v5a6 6 0 0 1-6 6a6 6 0 0 1-6-6z" fill="${KZ.teal}"/><path d="M${cx + 5} ${cy}h1.5a2.3 2.3 0 0 1 0 4.6h-2" stroke="${KZ.teal}" stroke-width="1.6" fill="none"/>`)
263    for (const [i, dx] of [[1, -4], [2, -1], [3, 2]] as const) {
264      parts.push(`<path class="tp-stm tp-stm${i}" d="M${cx + dx} ${cy - 4}c-1.5-1.5 1.5-2.5 0-4.5" stroke="#94a3b8" stroke-width="1.2" fill="none" stroke-linecap="round"/>`)
265    }
266  }
267  const x = 54
268  const label = isWork ? `FOCUS · ROUND ${s.round}` : s.phase === 'long' ? 'LONG BREAK' : 'BREAK'
269  parts.push(svgText(x, 18, label, { size: 10, weight: 700, fill: col }))
270  parts.push(svgText(x, 38, mmss(remain), { size: 19, weight: 700, mono: true }))
271  const span = isWork ? `of ${s.workMin} min` : `of ${Math.round(total / 60_000)} min`
272  parts.push(svgText(x + 64, 38, span, { cls: 'm', size: 10.5 }))
273  const roomy = W >= 420
274  // A track from here to the pips: the phase's progress with a glowing head,
275  // then either Claude's note or when the phase ends.
276  const tx0 = x + 128
277  const tx1 = roomy ? W - 178 : W - 14
278  if (tx1 - tx0 > 60) {
279    const tw = tx1 - tx0
280    parts.push(svgBar(tx0, 14, tw, 6, p, col))
281    parts.push(`<circle class="pulse" cx="${(tx0 + Math.max(3, tw * p)).toFixed(1)}" cy="17" r="4.5" fill="${col}" opacity=".45"/>`)
282    if (!isWork && s.isClaudeWorking) {
283      parts.push(`<text class="s tp-dots" x="${tx0}" y="38" font-size="11.5" font-family="-apple-system,'Segoe UI',sans-serif">${xml(fitText('Break — Claude keeps going ☕', 11.5, tw - 20))}<tspan>.</tspan><tspan>.</tspan><tspan>.</tspan></text>`)
284    } else {
285      const end = new Date(s.endsAt)
286      const hhmm = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`
287      parts.push(svgText(tx0, 38, `${isWork ? 'break at' : 'back to focus at'} ${hhmm}`, { cls: 'm', size: 10.5 }))
288    }
289  }
290  if (!roomy) return svg(W, 48, parts.join(''), css)
291  // Round pips: the four rounds of a cycle, the current one pulsing.
292  const inCycle = ((s.round - 1) % 4) + 1
293  const pipX = W - 150
294  for (let i = 1; i <= 4; i++) {
295    const done = i < inCycle || (i === inCycle && !isWork)
296    const cur = i === inCycle && isWork
297    parts.push(`<circle cx="${pipX + (i - 1) * 12}" cy="16" r="3.6" ${done || cur ? `fill="#ef4444"` : 'class="k"'}${cur ? ' class="pulse"' : ''}/>`)
298  }
299  parts.push(svgText(pipX + 46, 19, 'cycle', { cls: 'm', size: 9.5 }))
300  // Today's tally: little tomatoes, then the count.
301  const shown = Math.min(6, s.today)
302  for (let i = 0; i < shown; i++) {
303    const tx = W - 150 + i * 11
304    parts.push(`<circle cx="${tx}" cy="35" r="4" fill="#ef4444"/><path d="M${tx - 2} ${cy + 7.5}l2 1.5 2-1.5" stroke="#22c55e" stroke-width="1.2" fill="none"/>`)
305  }
306  parts.push(svgText(W - 12, 38, `×${s.today} today`, { cls: 's', size: 11, weight: 600, anchor: 'end' }))
307  return svg(W, 48, parts.join(''), css)
308}
309
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/timer.ts 112 lines
1// Tempo's clockwork, pure: phases, transitions, day keys and argument parsing.
2
3export type Phase = 'work' | 'break' | 'long'
4
5export type Timer = {
6  phase: Phase
7  startedAt: number
8  endsAt: number
9  workMin: number
10  breakMin: number
11  /** The focus round this phase belongs to, from 1. */
12  round: number
13}
14
15export const MIN = 60_000
16/** Every fourth focus round earns a long break, three short ones long. */
17export const LONG_EVERY = 4
18
19export function phaseMs(phase: Phase, workMin: number, breakMin: number): number {
20  if (phase === 'work') return workMin * MIN
21  return (phase === 'long' ? breakMin * 3 : breakMin) * MIN
22}
23
24export function startTimer(now: number, workMin = 25, breakMin = 5): Timer {
25  return { phase: 'work', startedAt: now, endsAt: now + workMin * MIN, workMin, breakMin, round: 1 }
26}
27
28/** The phase after this one, starting at `at`. */
29export function nextPhase(t: Timer, at: number): Timer {
30  if (t.phase === 'work') {
31    const phase: Phase = t.round % LONG_EVERY === 0 ? 'long' : 'break'
32    return { ...t, phase, startedAt: at, endsAt: at + phaseMs(phase, t.workMin, t.breakMin) }
33  }
34  return { ...t, phase: 'work', round: t.round + 1, startedAt: at, endsAt: at + phaseMs('work', t.workMin, t.breakMin) }
35}
36
37/**
38 * Moves the timer past every phase that ended by `now` (a machine asleep
39 * through several), reporting each finished phase in order.
40 */
41export function catchUp(t: Timer, now: number): { timer: Timer; finished: Phase[] } {
42  let cur = t
43  const finished: Phase[] = []
44  let guard = 0
45  while (now >= cur.endsAt && guard++ < 64) {
46    finished.push(cur.phase)
47    cur = nextPhase(cur, cur.endsAt)
48  }
49  return { timer: cur, finished }
50}
51
52export function remainingMs(t: Timer, now: number): number {
53  return Math.max(0, t.endsAt - now)
54}
55
56export function progressOf(t: Timer, now: number): number {
57  const total = t.endsAt - t.startedAt
58  return total <= 0 ? 1 : Math.max(0, Math.min(1, (now - t.startedAt) / total))
59}
60
61/** 25:00, 4:07; hours fold into minutes (90:00). */
62export function mmss(ms: number): string {
63  const s = Math.max(0, Math.ceil(ms / 1000))
64  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
65}
66
67/** The local calendar day of a timestamp: `2026-10-09`. */
68export function dayKey(ms: number): string {
69  const d = new Date(ms)
70  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
71}
72
73/** Keeps only the most recent `keep` days of counts. */
74export function pruneDays(days: Record<string, number>, keep = 60): Record<string, number> {
75  const keys = Object.keys(days).sort().slice(-keep)
76  const out: Record<string, number> = {}
77  for (const k of keys) out[k] = days[k] ?? 0
78  return out
79}
80
81export function phaseLabel(p: Phase): string {
82  return p === 'work' ? 'Focus' : p === 'long' ? 'Long break' : 'Break'
83}
84
85export type StartArgs = { workMin: number; breakMin: number } | { error: string }
86
87/** `start`, `start 50`, `start 50 10`, `start work=50 break=10`. */
88export function parseStart(words: readonly string[]): StartArgs {
89  let workMin = 25
90  let breakMin = 5
91  let positional = 0
92  for (const w of words) {
93    const m = /^(?:(work|break|w|b)=)?(\d+(?:\.\d+)?)$/i.exec(w)
94    if (!m) return { error: `Could not read “${w}”. Try /tempo start 25 5` }
95    const n = Number(m[2])
96    const key = (m[1] ?? (positional++ === 0 ? 'work' : 'break')).toLowerCase()
97    if (key.startsWith('w')) workMin = n
98    else breakMin = n
99  }
100  if (!(workMin >= 1 && workMin <= 180)) return { error: 'Focus length must be 1–180 minutes.' }
101  if (!(breakMin >= 1 && breakMin <= 60)) return { error: 'Break length must be 1–60 minutes.' }
102  return { workMin, breakMin }
103}
104
105export function isTimer(v: unknown): v is Timer {
106  if (!v || typeof v !== 'object') return false
107  const t = v as Record<string, unknown>
108  return (t.phase === 'work' || t.phase === 'break' || t.phase === 'long') &&
109    typeof t.startedAt === 'number' && typeof t.endsAt === 'number' &&
110    typeof t.workMin === 'number' && typeof t.breakMin === 'number' && typeof t.round === 'number'
111}
112
hooks/band.tsx 101 lines
1// Tempo's terminal band: one animated row drawn by the surface itself.
2// A tomato (or a steaming cup on a break), the phase, a bar whose leading
3// edge sizzles while a glint sweeps the filled part, the time left with a
4// blinking colon, and today's tally. Counts down locally between redraws.
5import type { ClientModule } from 'claude-code'
6
7type P = {
8  cols: number
9  phase: 'work' | 'break' | 'long'
10  remain: number
11  total: number
12  round: number
13  today: number
14  claude: boolean
15}
16type S = { f: number; since: number; seen: number }
17
18const FRAME = 100
19const TOMATO = ['#ef4444', '#f87171', '#fb923c', '#fbbf24'] as const
20const MINT = ['#2dd4bf', '#5eead4', '#99f6e4', '#ccfbf1'] as const
21const STEAM = ['  ', '° ', '°˚', ' ˚', '˚ ', ' °']
22
23function clock(ms: number, blink: boolean): string {
24  const s = Math.max(0, Math.ceil(ms / 1000))
25  return `${Math.floor(s / 60)}${blink ? ' ' : ':'}${String(s % 60).padStart(2, '0')}`
26}
27
28const TempoBand: ClientModule<P, S> = (props, surface) => {
29  if (surface.state === undefined) {
30    surface.setState({ f: 0, since: 0, seen: props.remain })
31    surface.every(FRAME, () => {
32      const s = surface.state ?? { f: 0, since: 0, seen: 0 }
33      surface.setState({ f: s.f + 1, since: s.since + 1, seen: s.seen })
34    })
35  }
36  const st = surface.state ?? { f: 0, since: 0, seen: props.remain }
37  // New props from the hooks module: the countdown restarts from them.
38  const since = st.seen === props.remain ? st.since : 0
39  if (st.seen !== props.remain) surface.setState({ f: st.f, since: 0, seen: props.remain })
40
41  const { Box, Text } = surface.elements
42  const isWork = props.phase === 'work'
43  const pal = isWork ? TOMATO : MINT
44  const remain = Math.max(0, props.remain - since * FRAME)
45  // The hooks module never sends a total under 1 ms.
46  const ratio = Math.max(0, Math.min(1, 1 - remain / props.total))
47  const f = st.f
48
49  const icon = isWork ? (remain < 60_000 && remain > 0 && f % 10 < 5 ? '⏰' : '🍅') : '☕'
50  const steam = isWork ? '' : STEAM[Math.floor(f / 3) % STEAM.length]!
51  const label = isWork ? `FOCUS ${props.round}` : props.phase === 'long' ? 'LONG BREAK' : 'BREAK'
52  const time = clock(remain, f % 10 >= 5 && remain > 0)
53  const tally = props.today > 0 ? ` 🍅×${props.today} today` : ''
54  const note = !isWork && props.claude ? ` — Claude keeps going${'.'.repeat(1 + (Math.floor(f / 4) % 3))}` : ''
55
56  const fixed = 2 + steam.length + 1 + label.length + 2 + 2 + time.length + tally.length + note.length + 1
57  const width = Math.max(6, Math.min(48, props.cols - fixed))
58  const filled = ratio * width
59  const full = Math.floor(filled)
60  // No glint on a bar this short: then no cell is near it.
61  const glint = full > 2 ? Math.floor((f / 2) % (full + 8)) : -Infinity
62
63  // Runs of one color, so the row stays a handful of Text nodes.
64  const runs: { color: string; text: string }[] = []
65  const push = (color: string, ch: string) => {
66    const last = runs[runs.length - 1]
67    if (last && last.color === color) last.text += ch
68    else runs.push({ color, text: ch })
69  }
70  for (let i = 0; i < width; i++) {
71    if (i < full) {
72      const d = Math.abs(i - glint)
73      push(d === 0 ? pal[3] : d === 1 ? pal[2] : i % 2 ? pal[0] : pal[1], '█')
74    } else if (i === full && ratio < 1) {
75      const frac = filled - full
76      const sizzle = '░▒▓'.charAt((f + i) % 3)
77      push(pal[1], frac > 0.5 ? '▓' : sizzle)
78    } else {
79      push('#4b5563', '·')
80    }
81  }
82
83  return (
84    <Box flexDirection="row">
85      <Text wrap="truncate-end">
86        <Text>{icon}</Text>
87        {steam ? <Text color="#cbd5e1">{steam}</Text> : null}
88        <Text bold color={pal[0]}> {label}</Text>
89        <Text color="#6b7280"> ▕</Text>
90        {runs.map((r, i) => <Text key={`r${i}`} color={r.color}>{r.text}</Text>)}
91        <Text color="#6b7280">▏ </Text>
92        <Text bold {...(remain < 60_000 ? { color: '#facc15' } : {})}>{time}</Text>
93        {tally ? <Text color="#fca5a5">{tally}</Text> : null}
94        {note ? <Text color="#5eead4" italic>{note}</Text> : null}
95      </Text>
96    </Box>
97  )
98}
99
100export default TempoBand
101
types/index.d.ts 18 lines
1export type TempoSnap = {
2  phase: 'work' | 'break' | 'long'
3  startedAt: number
4  endsAt: number
5  now: number
6  workMin: number
7  breakMin: number
8  round: number
9  today: number
10  isClaudeWorking: boolean
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    tempo: { snap: TempoSnap | null; isHidden: boolean }
16  }
17}
18