SLOPSHOPPER

second-wind

A breath, an eye rest or a stretch while Claude works, sized to the wait.

newbandspinnercommandtoaststatus
★ 1v0.1.2MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/second-wind
A shopper browsing a rack in a slop shop
README

<img src="../../brand/pixel-icons/svg/second-wind.svg" width="64" height="64" alt="">

Second Wind

A breath, an eye rest or a stretch while Claude works, sized to the wait.

Install

/plugin install second-wind --marketplace modmyclaude/claude-code-mods

Use it

Just work. When a turn has run for 3 seconds, a small band appears above your prompt. Quick turns never show it.

Breathe in 4  physiological sigh
──────────▐████████████████████▌──────────
  • It opens with one physiological sigh: a deep inhale, a short top-up sip, a long slow exhale (15 seconds).
  • If the turn keeps going, it moves into slow paced breathing (5.5 seconds in, 5.5 seconds out), or box breathing (4-4-4-4) if you pick it.
  • On turns over 60 seconds it swaps in a 20-20-20 eye rest ("Look at something 20 feet away" for 20 seconds) or a one-line stretch at the end of a breath, then goes back to breathing. Another comes every couple of minutes after that.
  • In the terminal the words read the phase and count down (Breathe in 4, Sip in 2, Hold 4, Out 6), and a bar fills from the middle outward and empties with the breath. In the desktop app it is Bolt, eyes closed, in a soft halo that swells on the in-breath and settles on the out-breath, with the step's words fading beside him (Breathe in, Sip in, Hold, Breathe out) and the pattern's name after them. No countdown there: the drawing keeps its own clock.
  • When Claude answers, the breath is not cut off: an exhale already under way runs out its own time, and an inhale turns into a closing exhale. Then the band folds into one line, dims and clears:
  4 breaths while Claude worked · 37 today · 6-day streak
  • While you type your next prompt, the band shrinks to one quiet line. It also uses that one line when the space above the prompt is short, and it sizes itself to the width it is given (narrower beside a docked pane), drawing beneath anything other mods put in the band, so a tight band clips the breath, never another mod's prompt.
  • The spinner's verb follows the breath (Breathing in…), but only when Claude Code is not showing a status of its own there.
  • It only follows the main conversation. Subagents starting and finishing never restart or stutter the breath.

Commands

CommandWhat it does
/breatheToday's breaths, eye rests and stretches, your streak, all-time breaths and the current settings
/breathe offHides the band now and on every later turn
/breathe onBrings it back
/breathe snoozeOff for the rest of today, back tomorrow
/breathe cardCopies a small text card of your stats to the clipboard to share
/breathe helpWhat it does, and the safety line

/breathe runs at once, even mid-turn.

Movement card (off by default)

Turn on movementCard and every fifth long turn offers a card above the prompt, "Stand up and stretch" or "10 squats", with Did it and Skip buttons (click one, or Tab to it and press Enter; there are no letter keys, so they never clash with other mods' buttons). It never blocks the prompt: keep typing and answer it whenever you like. It goes away when you press a button or start your next turn. Did it counts a move for today.

Breathe gently and never strain. If you feel lightheaded, stop and breathe normally.

Settings

  • On: on by default. Off, Second Wind draws nothing, counts nothing and keeps no timer; /breathe says it is off. Turned off mid-turn, that turn's breath is dropped uncounted; turned back on, the band shows from the next turn. /config or the modmyclaude hub flips it, live.

Change them in /config. Installing says these options are not yet set: each has the default below, so nothing needs setting first.

SettingDefaultWhat it does
patternslowWhat follows the opening sigh: slow (slow paced breathing) or box (box breathing)
motionfullfull animates the shape; gentle moves it less and redraws twice a second; still shows the words only, for reduced motion
delaySeconds3How long a turn runs before the band appears (0 to 600)
eyeRestonOffer the 20-20-20 eye rest on turns over 60 seconds
stretchonOffer a one-line stretch on turns over 60 seconds
movementCardoffOffer the movement card every fifth long turn

What it can touch

  • Store: its own store keeps counts and dates only: breaths, eye rests, stretches and moves for today and in all, the day they belong to, the streak and its last day, how many long turns there were, and whether you turned it off or snoozed it (and which day).
  • Session state: the breath in progress, the time it was last drawn, whether you are typing, and an offered card. Nothing of your prompt is kept: typing is read only as "the box is empty or not".
  • Clipboard: only when you run /breathe card.
  • Commands: /breathe writes its answer as a dim transcript note for you. Claude never reads it, so it costs no tokens.
  • Status line: on mobile and in VS Code only, the current cue (see Limits).
  • Timers: one, and only while a turn runs and something can show the band: 8 redraws a second for full, 2 for gentle, 1 for still. While the fold line shows it waits without ticking. Between turns no timer runs, and a run whose turn ended unseen stops at the band's next draw. In a plain -p run, or with no window or phone attached, it does nothing at all and counts nothing.
  • Files, processes, network, model calls: none.

Limits

  • In the desktop app, a /breathe sent as the very first prompt of a new session may show "/breathe isn't a command here." The app checks the command before mods have registered theirs; the command still runs, and later ones show no such line.
  • Surfaces: the band and the spinner are drawn by Claude Code in the terminal and the desktop app only. On mobile and in VS Code mods get neither, so there Second Wind puts the current cue in its status line instead, and only when no terminal or desktop window is attached to the session.
  • The desktop guide is an SVG drawing in a frame of its own, so it uses Bolt's own colours (lime, with a lime halo at partial opacity) and follows the app's light or dark mode for the words; everything else uses your theme's colours. The desktop app loads that frame again whenever the band is drawn again, identical or not, which looked like flashing. So on the desktop the band is drawn once per stretch of breathing (about two minutes, until the next eye rest or stretch), and nothing beside the guide counts down. A draw that lands anyway (a draft cleared, a movement card, a resize) reloads the guide once, picking up where the breath is.
  • Days are your computer's local calendar days. Flying west so the date goes back never breaks a streak.
  • Typing: the band shrinks while the prompt box holds a draft and grows back when it is emptied or sent, in the terminal and the desktop app alike. It reads this from the hint line under the prompt, so it follows a beat after that line redraws.
  • Counts: breaths count only for the turn that showed them. If a turn's end never reaches the mod (another mod held it, or a reload landed at that moment), that turn's breaths are dropped rather than added to the next turn. In the terminal and the desktop app its clock stops at once; on mobile or in VS Code alone it runs on until your next turn starts.
  • Reloads: changing a setting reloads the mod. A breath already running keeps its old settings until the turn ends, and its animation resumes on the band's next draw.

Built from a prompt

This mod was built from prompts/second-wind.md.

More mods: the full catalogue.

Source 7 files
hooks/register.tsx 750 lines
1// Second Wind: while the main conversation's turn runs, a calm band above the
2// prompt guides a breath, an eye rest or a stretch, sized to the wait.
3//
4// The wiring: turn.start arms a run, a timer shows it after the delay, a
5// clock timer redraws it while it is visible, turn.complete (main loop only)
6// asks for a soft finish, and the fold line clears itself. Everything the
7// band draws is computed from the wall clock ($.clock.now), never from how
8// many frames ran. No files, processes, network or model calls.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register, RenderNode, Timer } from 'claude-code'
12
13import { LONG_MS, frameAt, guideSpan, softFinish } from './breath'
14import { GUIDE_SIZE, barCells, barWidth, fitHead, guideSvg, shownFill } from './draw'
15import {
16  CARD_TEXT, SAFETY, TICK_MS, USAGE, draftAfter, foldLine, planOf, readSettings, shareCard, statusText, unknownWord,
17} from './settings'
18import type { Settings } from './settings'
19import { EMPTY_STATS, SWITCH_ON, addCounts, isOn, localDay, parseStats, parseSwitch } from './stats'
20import type { Stats, Switch } from './stats'
21import type { SecondWindDesk, SecondWindRun } from '../types'
22
23const RUN = { plugin: 'second-wind', key: 'run' } as const
24const NOW = { plugin: 'second-wind', key: 'now' } as const
25const TYPING = { plugin: 'second-wind', key: 'isTyping' } as const
26const CARD = { plugin: 'second-wind', key: 'card' } as const
27const DESK = { plugin: 'second-wind', key: 'desk' } as const
28
29const run = atom(RUN, null)
30const clock = atom(NOW, 0)
31const typing = atom(TYPING, false)
32const card = atom(CARD, null)
33const desk = atom(DESK, null)
34
35/** A desktop guide drawn again this long after it was built is rebuilt from now, so the reload lands in step. */
36const REBUILD_MS = 1500
37
38const FADE_MS = 3000 // the fold line dims after this
39const FOLD_MS = 6000 // and clears after this
40const ACCENT = 'suggestion' // theme keys only: the band follows the person's theme
41const QUIET = 'subtle'
42
43type Spec = { kind: 'every' | 'after'; ms: number }
44
45// The switch (`enabled`, which /config and the modmyclaude hub flip): off, Second Wind
46// draws nothing, arms nothing and keeps no timer. The engine reloads the mod with the
47// new value; the config.set hook below is the same switch at once. Not `isOn`, which
48// is the /breathe on and off kept in the store.
49let isEnabled = true
50const OFF_TEXT = 'Second Wind is off. /mods or /config turns it on.'
51
52// Bookkeeping for this load of the module, reset by register(). A hot reload
53// drops it with the timers it names; `heal` restarts them from $.state.
54let settings: Settings = readSettings({})
55let timer: (Spec & { handle: Timer }) | null = null
56let isBusy = false
57let isAgain = false
58let isHealing = false
59let lastStatus: string | null = null
60let isMirroring = false
61let typingShown: boolean | undefined
62// The main turn in flight, so a surface that attaches mid-turn can still arm it.
63let mainTurn: { id: string; startedAt: number } | null = null
64
65/** The one timer a run needs next: none when idle, a tick only while it moves. */
66export function timerFor(r: SecondWindRun | null, now: number, tickMs: number): Spec | null {
67  if (r === null) return null
68  switch (r.phase) {
69    case 'waiting':
70      return { kind: 'after', ms: Math.max(0, r.breathStartedAt - now) }
71    case 'breathing':
72    case 'finishing':
73      return { kind: 'every', ms: tickMs }
74    case 'folded': {
75      if (r.fold === null) return { kind: 'after', ms: 0 }
76      const at = now < r.fold.fadeAt ? r.fold.fadeAt : r.fold.until
77      return { kind: 'after', ms: Math.max(0, at - now) }
78    }
79  }
80}
81
82/** `update` on the run, typed so the phase literals hold. */
83const changeRun = ($: EngineInterface, fn: (cur: SecondWindRun | null) => SecondWindRun | null): Promise<SecondWindRun | null> =>
84  update($, run, fn)
85
86async function loadStats($: EngineInterface): Promise<Stats> {
87  try {
88    return parseStats(await $.store.get('stats'))
89  } catch {
90    return EMPTY_STATS
91  }
92}
93
94async function loadSwitch($: EngineInterface): Promise<Switch> {
95  try {
96    return parseSwitch(await $.store.get('switch'))
97  } catch {
98    return SWITCH_ON
99  }
100}
101
102function want($: EngineInterface, spec: Spec | null): void {
103  if (spec === null) {
104    timer?.handle.cancel()
105    timer = null
106    return
107  }
108  if (timer !== null && timer.kind === 'every' && spec.kind === 'every' && timer.ms === spec.ms) return
109  timer?.handle.cancel()
110  if (spec.kind === 'every') {
111    timer = { ...spec, handle: $.clock.every(spec.ms, () => void pump($)) }
112    return
113  }
114  const mine: Spec & { handle: Timer } = {
115    ...spec,
116    handle: $.clock.after(spec.ms, () => {
117      if (timer === mine) timer = null
118      void pump($)
119    }),
120  }
121  timer = mine
122}
123
124/** Runs `step` one at a time; a call that lands mid-step runs it once more after. */
125async function pump($: EngineInterface): Promise<void> {
126  // Switched off: a tick that still lands stops its timer instead.
127  if (!isEnabled) {
128    want($, null)
129    return
130  }
131  if (isBusy) {
132    isAgain = true
133    return
134  }
135  isBusy = true
136  try {
137    do {
138      isAgain = false
139      await step($)
140    } while (isAgain)
141  } catch {
142    // A failed step leaves the timer as it was; the next tick tries again.
143  } finally {
144    isBusy = false
145  }
146}
147
148/** Moves the run along by the wall clock, publishes the time, and picks the next timer. */
149async function step($: EngineInterface): Promise<void> {
150  const now = await $.clock.now()
151  const r = await read($, run)
152  if (r !== null) {
153    if (r.phase === 'waiting' && now >= r.breathStartedAt) {
154      await changeRun($, cur => (cur?.id === r.id && cur.phase === 'waiting' ? { ...cur, phase: 'breathing' } : cur))
155    } else if (r.phase === 'breathing' && !r.isLong && now - r.turnStartedAt > LONG_MS) {
156      await markLong($, r.id)
157    } else if (r.phase === 'finishing' && r.finish !== null && now >= r.finish.at + r.finish.ms) {
158      await fold($, r, now)
159    } else if (r.phase === 'folded' && (r.fold === null || now >= r.fold.until)) {
160      await changeRun($, cur => (cur?.id === r.id && cur.phase === 'folded' ? null : cur))
161    }
162  }
163  await $.state.set(NOW, now)
164  const cur = await read($, run)
165  await syncDesk($, cur, now)
166  const text = cur === null ? null : cur.phase === 'folded' ? (cur.fold?.text ?? null) : (frameAt(cur, now)?.cue ?? null)
167  await mirrorStatus($, text)
168  want($, timerFor(cur, now, TICK_MS[settings.motion]))
169}
170
171/** The turn passed 60 seconds: count a long turn, and maybe offer the movement card. */
172async function markLong($: EngineInterface, id: string): Promise<void> {
173  await changeRun($, cur => (cur?.id === id ? { ...cur, isLong: true } : cur))
174  try {
175    const stats = await loadStats($)
176    const next: Stats = { ...stats, longTurns: stats.longTurns + 1 }
177    await $.store.set('stats', next)
178    if (settings.movementCard && next.longTurns % 5 === 0) {
179      await $.state.set(CARD, { kind: (next.longTurns / 5) % 2 === 1 ? 'stand' : 'squats' })
180    }
181  } catch {
182    // Counting is best effort; the breath goes on.
183  }
184}
185
186/** The closing exhale is done: record the counts and fold into one line. */
187async function fold($: EngineInterface, r: SecondWindRun, now: number): Promise<void> {
188  const f = r.finish
189  const day = localDay(now)
190  let stats: Stats | null = null
191  if (f !== null && f.breaths > 0) {
192    try {
193      stats = addCounts(await loadStats($), day, { breaths: f.breaths, eyeRests: f.eyeRests, stretches: f.stretches })
194      await $.store.set('stats', stats)
195    } catch {
196      stats = null // the line still says this turn's breaths
197    }
198  }
199  await changeRun($, cur => {
200    if (cur?.id !== r.id || cur.phase !== 'finishing') return cur
201    if (f === null || f.breaths === 0) return null // nothing worth a line: clear quietly
202    return { ...cur, phase: 'folded', fold: { text: foldLine(f.breaths, stats, day), fadeAt: now + FADE_MS, until: now + FOLD_MS } }
203  })
204}
205
206/** Mobile and VS Code draw neither the band nor the spinner: there the cue rides the status line. */
207async function mirrorStatus($: EngineInterface, text: string | null): Promise<void> {
208  if (text === lastStatus) return
209  lastStatus = text
210  let isRemoteOnly = false
211  if (text !== null) {
212    try {
213      const surfaces = await $.session.surfaces()
214      isRemoteOnly = surfaces.length > 0 && !surfaces.some(s => s === 'terminal' || s === 'desktop')
215    } catch {
216      isRemoteOnly = false
217    }
218  }
219  if (isRemoteOnly && text !== null) {
220    $.ui.status(text)
221    isMirroring = true
222  } else if (isMirroring) {
223    $.ui.status(undefined)
224    isMirroring = false
225  }
226}
227
228/** What the desktop should draw for the run now: the guide for its span, the fold line, or nothing. */
229function deskView(r: SecondWindRun | null, now: number): SecondWindDesk | null {
230  if (r === null) return null
231  if (r.phase === 'folded') {
232    return r.fold === null ? null : { kind: 'fold', key: `fold:${r.fold.until}`, text: r.fold.text, isFaded: now >= r.fold.fadeAt }
233  }
234  const span = guideSpan(r, now)
235  if (span === null) return null
236  return {
237    kind: 'guide',
238    key: span.key,
239    phase: r.phase === 'finishing' ? 'finishing' : 'breathing',
240    at: now - span.offsetMs,
241    segments: span.segments.map(s => ({ steps: s.steps.map(st => ({ phase: st.phase, ms: st.ms, from: st.from, to: st.to })), loop: s.loop, atMs: s.atMs })),
242    pose: span.pose,
243    words: span.words,
244    label: span.label,
245    still: span.still,
246  }
247}
248
249/** What a desk view amounts to on screen: a write that changes nothing here is skipped. */
250const deskKey = (v: SecondWindDesk | null): string => (v === null ? '' : v.kind === 'fold' ? `${v.key}:${v.isFaded}` : v.key)
251
252/**
253 * Writes the desktop's view when, and only when, it changed. Each write draws the
254 * desktop band again, and the desktop re-sets the guide's frame on every draw
255 * (seen live), so the view holds for a whole span of breathing.
256 */
257async function syncDesk($: EngineInterface, r: SecondWindRun | null, now: number): Promise<void> {
258  const next = deskView(r, now)
259  if (deskKey(next) !== deskKey(await read($, desk))) await $.state.set(DESK, next)
260}
261
262/** After a hot reload the run is still in $.state but its timer is gone. */
263function heal($: EngineInterface, hasRun: boolean): void {
264  if (!hasRun || timer !== null || isHealing) return
265  isHealing = true
266  $.clock.after(0, () => {
267    isHealing = false
268    void pump($)
269  })
270}
271
272async function stop($: EngineInterface): Promise<void> {
273  await $.state.set(RUN, null)
274  await $.state.set(CARD, null)
275  await $.state.set(DESK, null)
276  want($, null)
277  await mirrorStatus($, null)
278}
279
280/** Typing never waits on this: the write is fired and forgotten, and only when the answer changes. */
281function noteTyping($: EngineInterface, isTyping: boolean): void {
282  if (typingShown === isTyping) return
283  typingShown = isTyping
284  $.state.set(TYPING, isTyping).catch(() => {
285    typingShown = undefined
286  })
287}
288
289async function answerCard($: EngineInterface, didIt: boolean): Promise<void> {
290  await $.state.set(CARD, null)
291  if (!didIt) return
292  try {
293    const now = await $.clock.now()
294    await $.store.set('stats', addCounts(await loadStats($), localDay(now), { moves: 1 }))
295  } catch {
296    // The card is gone either way.
297  }
298}
299
300async function saveSwitch($: EngineInterface, next: Switch): Promise<boolean> {
301  try {
302    await $.store.set('switch', next)
303    return true
304  } catch {
305    return false
306  }
307}
308
309const NOT_SAVED = 'Second Wind could not save that. Try again.'
310
311async function copyCard($: EngineInterface, text: string): Promise<string> {
312  try {
313    const r = await $.ui.copy({ text })
314    if (r.isCopied) return `Copied your stats card:\n\n${text}`
315    return `Could not copy it (${r.reason}). Here it is to copy by hand:\n\n${text}`
316  } catch {
317    return `Could not copy it. Here it is to copy by hand:\n\n${text}`
318  }
319}
320
321/** Who may turn the mod off or write the clipboard: the person typing. */
322const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
323const PERSON_ONLY: ReadonlySet<string> = new Set(['off', 'snooze', 'card'])
324
325/** /breathe and its arguments. */
326async function breathe($: EngineInterface, args: string, origin: string): Promise<{ text: string }> {
327  const arg = args.trim().toLowerCase()
328  if (PERSON_ONLY.has(arg) && !PERSON_ORIGINS.has(origin)) {
329    return { text: `Second Wind only does /breathe ${arg} when you type it yourself.` }
330  }
331  const now = await $.clock.now()
332  const day = localDay(now)
333  const sw = await loadSwitch($)
334  noteTyping($, false)
335  switch (arg) {
336    case '':
337    case 'stats':
338    case 'status':
339      return { text: statusText(await loadStats($), sw, day, settings) }
340    case 'help':
341      return { text: `Second Wind guides a breath, an eye rest or a stretch while Claude works.\n${USAGE}\n${SAFETY}` }
342    case 'off':
343      if (!(await saveSwitch($, { ...sw, off: true }))) return { text: NOT_SAVED }
344      await stop($)
345      return { text: 'Second Wind is off. /breathe on brings it back.' }
346    case 'on':
347      if (!(await saveSwitch($, SWITCH_ON))) return { text: NOT_SAVED }
348      return { text: `Second Wind is on. It shows once a turn has run ${settings.delayMs / 1000}s.` }
349    case 'snooze':
350      if (!(await saveSwitch($, { ...sw, snoozeDay: day }))) return { text: NOT_SAVED }
351      await stop($)
352      return { text: 'Second Wind is snoozed for the rest of today. /breathe on brings it back sooner.' }
353    case 'card':
354      return { text: await copyCard($, shareCard(await loadStats($), day)) }
355    default:
356      return { text: unknownWord(arg) }
357  }
358}
359
360/** A main turn began: clear an unanswered card, and arm the turn's run. */
361async function begin($: EngineInterface, turnId: string): Promise<void> {
362  mainTurn = { id: turnId, startedAt: await $.clock.now() }
363  if ((await read($, card)) !== null) await $.state.set(CARD, null)
364  await arm($)
365}
366
367/**
368 * Arms the main turn's run once something can draw it; safe to call again.
369 * A plain -p run or an SDK host with no client attached draws nothing, so it
370 * gets no run: no timer and no counts. A surface that
371 * attaches mid-turn arms it then, and its breath starts no earlier than that.
372 */
373async function arm($: EngineInterface): Promise<void> {
374  const t = mainTurn
375  if (t === null) return
376  const now = await $.clock.now()
377  if (!isOn(await loadSwitch($), localDay(now))) return
378  if ((await $.session.surfaces()).length === 0) return
379  const fresh: SecondWindRun = {
380    id: t.id, phase: 'waiting', turnStartedAt: t.startedAt, breathStartedAt: Math.max(t.startedAt + settings.delayMs, now),
381    plan: planOf(settings), isLong: false, finish: null, fold: null,
382  }
383  await changeRun($, cur => {
384    // Main turns never overlap and a subagent raises no turn.start, so a run
385    // still waiting or breathing for another turn lost its turn.complete: it
386    // is dropped uncounted, never inherited.
387    if (cur !== null && (cur.phase === 'waiting' || cur.phase === 'breathing')) return cur.id === t.id ? cur : fresh
388    // Asked again mid-exhale: carry on, now for this turn.
389    if (cur !== null && cur.phase === 'finishing') return { ...cur, id: t.id, phase: 'breathing', finish: null }
390    return fresh
391  })
392  await pump($)
393}
394
395/** Drops a run that no surface can show, or whose turn is gone, counting nothing. */
396async function drop($: EngineInterface, r: SecondWindRun): Promise<void> {
397  await changeRun($, cur => (cur?.id === r.id && cur.phase === r.phase ? null : cur))
398  await pump($)
399}
400
401/** The main turn answered (or was interrupted): finish softly, or drop a run that never showed. */
402async function end($: EngineInterface, turnId: string, isAborted: boolean): Promise<void> {
403  mainTurn = null
404  const now = await $.clock.now()
405  const r = await read($, run)
406  if (r === null || (r.phase !== 'waiting' && r.phase !== 'breathing')) return
407  // Breaths count only for the turn that armed them: a run left over from a
408  // turn whose own turn.complete never came is dropped, not folded in here.
409  if (r.id !== turnId) return drop($, r)
410  const isQuick = r.phase === 'waiting'
411  const finish = softFinish(r.plan, now - r.breathStartedAt, isAborted)
412  await changeRun($, cur => {
413    if (cur?.id !== r.id || cur.phase !== r.phase) return cur
414    return isQuick ? null : { ...cur, phase: 'finishing', finish: { at: now, ...finish } }
415  })
416  await pump($)
417}
418
419/** The desktop guide's markup for the span it was built for, and when. */
420let guide: { key: string; source: string; shown: boolean; builtAt: number } = { key: '', source: '', shown: false, builtAt: 0 }
421
422export const register: Register = (on, options) => {
423  isEnabled = options.enabled !== false
424  settings = readSettings(options)
425  timer = null
426  isBusy = false
427  isAgain = false
428  isHealing = false
429  lastStatus = null
430  isMirroring = false
431  typingShown = undefined
432  mainTurn = null
433
434  // Off drops the breath in progress uncounted, as /breathe off does; on, the next turn shows it.
435  on('config.set', { key: 'second-wind.enabled' }, async ($, e, next) => {
436    const set = await next(e)
437    if (set.deny === undefined && typeof set.value === 'boolean') {
438      isEnabled = set.value
439      if (!isEnabled) {
440        mainTurn = null
441        try {
442          await stop($)
443        } catch {
444          // A tick that still lands stops its timer instead.
445        }
446      }
447      $.ui.invalidate('ui.render')
448    }
449    return set
450  })
451
452  on('session.start', async ($, e, next) => {
453    try {
454      await $.command.register({
455        name: 'breathe',
456        description: "Second Wind: today's breaths and settings, or on, off, snooze, card",
457        argumentHint: '[on|off|snooze|card|help]',
458        immediate: true,
459      })
460    } catch {
461      // Without the command the band still works.
462    }
463    return next(e)
464  })
465
466  // The answer is for the person, not the model: a dim
467  // transcript line Claude never reads, and an empty result.
468  on('command.run', { command: 'breathe' }, async ($, e) => {
469    if (!isEnabled) {
470      $.ui.toast(OFF_TEXT)
471      return {}
472    }
473    // One log line per line: the desktop app runs a multi-line log together.
474    for (const line of (await breathe($, e.args, e.origin.kind)).text.split('\n')) $.ui.log(line)
475    return {}
476  }).catch(($) => {
477    $.ui.log('Second Wind could not answer that just now.')
478    return {}
479  })
480
481  // A client joining mid-turn (the desktop app, a phone) arms the turn it can now
482  // show; one leaving the session with nothing attached drops the run uncounted.
483  on('session.attach', async ($, e, next) => {
484    const r = await next(e)
485    try {
486      await arm($)
487    } catch {
488      // The band waits for the next turn instead.
489    }
490    return r
491  })
492
493  on('session.detach', async ($, e, next) => {
494    const r = await next(e)
495    if (!isEnabled) return r
496    try {
497      const cur = await read($, run)
498      if (cur !== null && (cur.phase === 'waiting' || cur.phase === 'breathing') && (await $.session.surfaces()).length === 0) await drop($, cur)
499    } catch {
500      // The turn's end clears it instead.
501    }
502    return r
503  })
504
505  // Only the main conversation raises turn.start: a subagent's run never does.
506  on('turn.start', async ($, e, next) => {
507    if (!isEnabled) return next(e)
508    try {
509      await begin($, e.turnId)
510    } catch {
511      // The turn runs whatever happens here.
512    }
513    return next(e)
514  })
515
516  // A subagent's completion carries its agentId and is ignored: only the main
517  // loop's answer folds the band.
518  on('turn.complete', async ($, e, next) => {
519    if (isEnabled && e.agentId === undefined) {
520      try {
521        await end($, e.turnId, e.isAborted)
522      } catch {
523        // The answer shows whatever happens here.
524      }
525    }
526    return next(e)
527  })
528
529  // The band shrinks to one line while the person types. The prompt line says
530  // whether the box holds a draft on the terminal and the desktop alike; the
531  // desktop app draws its own composer and raises no prompt.edit. A render hook cannot write state, so the write waits one clock turn.
532  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
533    if (!isEnabled) return next(e)
534    const isDraft = e.props.isDraft
535    if (isDraft !== typingShown) {
536      $.clock.after(0, () => {
537        if (isEnabled) noteTyping($, isDraft)
538      })
539    }
540    return next(e)
541  })
542
543  // The terminal's own prompt box also reports each edit as it lands, a beat
544  // before its prompt line redraws. Never awaited: the edit passes through
545  // untouched and at once.
546  on('prompt.edit', ($, e, next) => {
547    if (isEnabled) noteTyping($, draftAfter(e).length > 0)
548    return next(e)
549  }).catch(($, e, next) => next(e))
550
551  on('prompt.submit', ($, e, next) => {
552    if (isEnabled) noteTyping($, false)
553    return next(e)
554  }).catch(($, e, next) => next(e))
555
556  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
557    if (!isEnabled || e.props.hasSurvey) return next(e)
558
559    // The desktop runs the guide in a frame of its own, and re-sets that frame on every draw of
560    // the band, identical tree or not (seen live four times). So this path reads only
561    // what changes at a span's edges: the desk view, the draft and the card. Never the per-tick
562    // clock, never the run (its isLong flips at 60 s). With motion off it draws words like the
563    // terminal, below.
564    if (e.surface === 'desktop' && settings.motion !== 'still') {
565      const view = await read($, desk)
566      const isTyping = await read($, typing)
567      const offered = await read($, card)
568      if (view !== null && view.kind === 'guide' && view.phase === 'breathing' && !e.props.isWorking) {
569        // Its turn.complete was lost (a reload at the wrong moment, a mod above that kept it):
570        // draw nothing and stop its clock; the next turn.start drops it uncounted. A guide drawn
571        // after this is a fresh frame, so it is rebuilt from now.
572        guide = { ...guide, shown: false }
573        want($, null)
574        return next(e)
575      }
576      heal($, view !== null)
577      const rest = await next(e)
578      if (view === null && offered === null) return rest
579      const { Box, Text, Button, Svg } = $.ui.resolve(e)
580      const motion = settings.motion
581      const rows: RenderNode[] = []
582      if (offered !== null) {
583        rows.push(
584          <Box key="sw-card" flexDirection="row" gap={1}>
585            <Text bold color={ACCENT} wrap="truncate-end">{CARD_TEXT[offered.kind]}</Text>
586            <Button key="sw-did" label="Did it" variant="primary" onPress={() => answerCard($, true)} />
587            <Button key="sw-skip" label="Skip" onPress={() => answerCard($, false)} />
588          </Box>,
589        )
590      }
591      if (view !== null && view.kind === 'fold') {
592        rows.push(
593          <Box key="sw-fold">
594            <Text dimColor={view.isFaded} color={view.isFaded ? QUIET : undefined} wrap="truncate-end">{view.text}</Text>
595          </Box>,
596        )
597      } else if (view !== null && (isTyping || e.props.maxRows < 3)) {
598        // One quiet line while the next prompt is typed, or when the band has under three rows:
599        // what holds for the span, never a countdown, so the line is drawn once.
600        guide = { ...guide, shown: false }
601        rows.push(
602          <Box key="sw-compact">
603            <Text dimColor wrap="truncate-end">{view.still ?? view.label}</Text>
604          </Box>,
605        )
606      } else if (view !== null) {
607        const now = await $.clock.now()
608        const key = `${view.key}:${motion}`
609        // Built once per span. A draw that lands later anyway (a card, a draft cleared, a resize)
610        // loads the frame again, so it is rebuilt from now and lands in step instead of jumping back.
611        if (guide.key !== key || !guide.shown || now - guide.builtAt > REBUILD_MS) {
612          const source = guideSvg(view.segments, { motion, delayMs: Math.max(0, now - view.at), pose: view.pose, words: view.words })
613          guide = { key, shown: true, builtAt: now, source }
614        }
615        rows.push(
616          <Box key="sw-breath" flexDirection="row" gap={2} alignItems="center">
617            <Svg key="sw-guide" source={guide.source} alt="Breathing guide" width={GUIDE_SIZE.width} height={GUIDE_SIZE.height} isInteractive />
618            <Box key="sw-head" flexDirection="column">
619              {view.still !== null ? (
620                <Box key="sw-cue">
621                  <Text bold color={ACCENT} wrap="truncate-end">{view.still}</Text>
622                </Box>
623              ) : null}
624              <Box key="sw-caption">
625                <Text dimColor>{view.label}</Text>
626              </Box>
627            </Box>
628          </Box>,
629        )
630      }
631      // What other mods draw here comes first.
632      return (
633        <Box key="second-wind" flexDirection="column">
634          {rest}
635          {rows}
636        </Box>
637      )
638    }
639
640    const r = await read($, run)
641    const now = await read($, clock)
642    const isTyping = await read($, typing)
643    const offered = await read($, card)
644    // A run still `breathing` while no turn works lost its turn.complete (a
645    // reload at the wrong moment, a mod above that kept it): draw nothing for
646    // it and stop its clock. A draw while a turn works wakes it; the next
647    // turn.start drops it uncounted.
648    const isOrphan = r !== null && r.phase === 'breathing' && !e.props.isWorking
649    if (isOrphan) want($, null)
650    else heal($, r !== null)
651    const rest = await next(e)
652
653    const frame = r !== null && !isOrphan ? frameAt(r, now) : null
654    const folded = r !== null && r.phase === 'folded' ? r.fold : null
655    if (frame === null && folded === null && offered === null) return rest
656
657    const { Box, Text, Button } = $.ui.resolve(e)
658    const cols = Math.max(1, Math.floor(e.props.bodyColumns))
659    const motion = settings.motion
660    const rows: RenderNode[] = []
661
662    if (offered !== null) {
663      // Click, or Tab and Enter: no hotkeys, as d and s are other band mods'
664      // Dismiss, Deny, Done and Skip.
665      rows.push(
666        <Box key="sw-card" flexDirection="row" gap={1}>
667          <Text bold color={ACCENT} wrap="truncate-end">{CARD_TEXT[offered.kind]}</Text>
668          <Button key="sw-did" label="Did it" variant="primary" onPress={() => answerCard($, true)} />
669          <Button key="sw-skip" label="Skip" onPress={() => answerCard($, false)} />
670        </Box>,
671      )
672    }
673
674    if (folded !== null) {
675      const isFaded = motion !== 'still' && now >= folded.fadeAt
676      rows.push(
677        <Box key="sw-fold">
678          <Text dimColor={isFaded} color={isFaded ? QUIET : undefined} wrap="truncate-end">{folded.text}</Text>
679        </Box>,
680      )
681    } else if (frame !== null) {
682      const fill = shownFill(frame.fill, motion)
683      // One quiet line while the next prompt is typed, or when the band has under three rows to give.
684      const isSmall = isTyping || e.props.maxRows < 3
685      let shape: RenderNode | null = null
686      if (motion !== 'still' && !isSmall) {
687        const [left, body, right] = barCells(fill, barWidth(cols))
688        shape = (
689          <Box key="sw-shape" flexDirection="row">
690            <Text color={QUIET}>{left}</Text>
691            <Text color={ACCENT}>{body}</Text>
692            <Text color={QUIET}>{right}</Text>
693          </Box>
694        )
695      }
696      if (isSmall) {
697        const room = cols - frame.cue.length - 1
698        const mini = e.surface === 'terminal' && motion !== 'still' && room >= 4 ? barCells(fill, Math.min(8, room)).join('') : null
699        rows.push(
700          <Box key="sw-compact" flexDirection="row" gap={1}>
701            <Text dimColor wrap="truncate-end">{frame.cue}</Text>
702            {mini !== null ? <Text dimColor>{mini}</Text> : null}
703          </Box>,
704        )
705      } else {
706        const head = fitHead(frame.cue, frame.caption, cols)
707        const words = (
708          <Box key="sw-head" flexDirection="row">
709            <Box key="sw-cue">
710              <Text bold color={ACCENT} wrap="truncate-end">{head.cue}</Text>
711            </Box>
712            {head.caption !== null ? (
713              <Box key="sw-caption" marginLeft={2}>
714                <Text dimColor>{head.caption}</Text>
715              </Box>
716            ) : null}
717          </Box>
718        )
719        rows.push(
720          <Box key="sw-breath" flexDirection="column">
721            {words}
722            {shape}
723          </Box>,
724        )
725      }
726    }
727
728    // What other mods draw here comes first: a tight band
729    // clips the breath, never another mod's hold or prompt.
730    return (
731      <Box key="second-wind" flexDirection="column">
732        {rest}
733        {rows}
734      </Box>
735    )
736  })
737
738  // The spinner carries the phase only in the terminal, and only when Claude Code shows nothing of
739  // its own there. The desktop's turn row is left alone: swapping Claude's word in and out read as
740  // flashing, and the band's guide already breathes (seen live).
741  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
742    if (!isEnabled || e.props.message !== null || e.surface === 'desktop') return next(e)
743    const r = await read($, run)
744    if (r === null) return next(e)
745    const frame = frameAt(r, await read($, clock))
746    if (frame === null) return next(e)
747    return next({ ...e, props: { ...e.props, word: frame.word } })
748  })
749}
750
hooks/breath.ts 338 lines
1// The breathing math: patterns, the escalation schedule, the soft finish and
2// what each moment says. Pure: every function takes elapsed milliseconds the
3// caller read off the wall clock, so the pace never drifts with frame timing.
4
5import type { SecondWindPaced, SecondWindPlan, SecondWindRun } from '../types'
6
7export type Pattern = 'sigh' | SecondWindPaced
8export type PhaseName = 'in' | 'sip' | 'hold' | 'out'
9export type Interlude = 'eyes' | 'stretch'
10
11type Step = { readonly phase: PhaseName; readonly ms: number; readonly from: number; readonly to: number }
12
13/** Each pattern as steps of fill (0 empty, 1 full) over time. */
14export const PATTERNS: Readonly<Record<Pattern, readonly Step[]>> = {
15  // A deep inhale, a short top-up sip, a long slow exhale: 15 seconds.
16  sigh: [
17    { phase: 'in', ms: 4000, from: 0, to: 0.75 },
18    { phase: 'sip', ms: 2000, from: 0.75, to: 1 },
19    { phase: 'out', ms: 9000, from: 1, to: 0 },
20  ],
21  // Slow paced breathing: 5.5 seconds in, 5.5 seconds out.
22  slow: [
23    { phase: 'in', ms: 5500, from: 0, to: 1 },
24    { phase: 'out', ms: 5500, from: 1, to: 0 },
25  ],
26  // Box breathing: in, hold, out, hold, 4 seconds each.
27  box: [
28    { phase: 'in', ms: 4000, from: 0, to: 1 },
29    { phase: 'hold', ms: 4000, from: 1, to: 1 },
30    { phase: 'out', ms: 4000, from: 1, to: 0 },
31    { phase: 'hold', ms: 4000, from: 0, to: 0 },
32  ],
33}
34
35export const LABELS: Readonly<Record<Pattern | Interlude, string>> = {
36  sigh: 'physiological sigh',
37  slow: 'slow paced breathing',
38  box: 'box breathing',
39  eyes: '20-20-20 eye rest',
40  stretch: 'stretch',
41}
42
43/** One-line stretches, taken in turn. */
44export const STRETCHES: readonly string[] = [
45  'Roll your shoulders back, slowly',
46  'Drop your shoulders and lengthen your neck',
47  'Open your hands and spread your fingers wide',
48  'Sit tall and turn gently to each side',
49]
50
51export const EYE_REST = 'Look at something 20 feet away'
52
53export const LONG_MS = 60_000 // a turn past this is a long turn
54export const INTERLUDE_MS = 20_000 // an eye rest or a stretch
55export const GAP_MS = 120_000 // breathing between interludes, at least
56export const MIN_CLOSE_MS = 1500 // the shortest closing exhale
57export const ABANDON_FILL = 0.2 // an inhale barely begun is let go, not finished
58
59export const cycleMs = (p: Pattern): number => PATTERNS[p].reduce((n, s) => n + s.ms, 0)
60export const SIGH_MS = cycleMs('sigh')
61
62/** The exhale step's length. */
63export const exhaleMs = (p: Pattern): number => PATTERNS[p].find(s => s.phase === 'out')?.ms ?? 0
64
65/** Where one cycle's exhale ends, from the cycle's start: a breath is counted there. */
66function exhaleEndMs(p: Pattern): number {
67  let t = 0
68  for (const s of PATTERNS[p]) {
69    t += s.ms
70    if (s.phase === 'out') return t
71  }
72  return t
73}
74
75export const clamp01 = (x: number): number => (!(x > 0) ? 0 : x >= 1 ? 1 : x)
76
77/** Sine in-out: lungs speed up and slow down, they do not move linearly. */
78export const ease = (p: number): number => (1 - Math.cos(Math.PI * clamp01(p))) / 2
79
80const secondsOf = (ms: number): number => Math.max(1, Math.ceil(ms / 1000))
81
82export type Breath = {
83  phase: PhaseName
84  /** Whole seconds left in this step, never 0 while it runs. */
85  secondsLeft: number
86  /** 0 empty to 1 full. */
87  fill: number
88  msLeft: number
89  stepMs: number
90  from: number
91  to: number
92}
93
94/** Where a pattern stands `elapsedMs` after it began. */
95export function phaseAt(pattern: Pattern, elapsedMs: number): Breath {
96  const steps = PATTERNS[pattern]
97  const total = cycleMs(pattern)
98  const t = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : 0
99  let u = t - Math.floor(t / total) * total
100  let i = 0
101  while (i < steps.length - 1 && u >= steps[i]!.ms) {
102    u -= steps[i]!.ms
103    i += 1
104  }
105  const s = steps[i]!
106  const msLeft = Math.max(0, s.ms - u)
107  return {
108    phase: s.phase,
109    secondsLeft: secondsOf(msLeft),
110    fill: s.from + (s.to - s.from) * ease(u / s.ms),
111    msLeft,
112    stepMs: s.ms,
113    from: s.from,
114    to: s.to,
115  }
116}
117
118/** The interludes a plan takes, alternating in this order. */
119export function interludes(plan: SecondWindPlan): Interlude[] {
120  return [...(plan.eyeRest ? (['eyes'] as const) : []), ...(plan.stretch ? (['stretch'] as const) : [])]
121}
122
123/**
124 * Breath time of the first interlude: the first cycle boundary once the turn
125 * (delay included) has run 60 seconds, so no breath is cut in half.
126 */
127export function firstInterludeAt(plan: SecondWindPlan): number {
128  const c = cycleMs(plan.pattern)
129  return SIGH_MS + c * Math.max(0, Math.ceil((LONG_MS - plan.delayMs - SIGH_MS) / c))
130}
131
132/** Whole paced cycles between two interludes. */
133export const cyclesPerGap = (plan: SecondWindPlan): number => Math.max(1, Math.ceil(GAP_MS / cycleMs(plan.pattern)))
134
135export type Segment =
136  | { kind: 'breath'; pattern: Pattern; breath: Breath; startMs: number }
137  | { kind: 'interlude'; interlude: Interlude; index: number; nth: number; fill: number; msLeft: number; secondsLeft: number }
138
139const breathFrom = (pattern: Pattern, spanStart: number, t: number): Segment => {
140  const breath = phaseAt(pattern, t - spanStart)
141  return { kind: 'breath', pattern, breath, startMs: t - (breath.stepMs - breath.msLeft) }
142}
143
144/**
145 * The escalation schedule: one physiological sigh, then the paced pattern;
146 * once the turn is long, an eye rest or a stretch every couple of minutes,
147 * each followed by more breathing. `t` is breath time (ms since the band
148 * first showed).
149 */
150export function segmentAt(plan: SecondWindPlan, t: number): Segment {
151  const at = Number.isFinite(t) ? Math.max(0, t) : 0
152  if (at < SIGH_MS) return breathFrom('sigh', 0, at)
153  const kinds = interludes(plan)
154  const first = firstInterludeAt(plan)
155  if (kinds.length === 0 || at < first) return breathFrom(plan.pattern, SIGH_MS, at)
156  const block = INTERLUDE_MS + cyclesPerGap(plan) * cycleMs(plan.pattern)
157  const k = Math.floor((at - first) / block)
158  const u = at - first - k * block
159  if (u < INTERLUDE_MS) {
160    const msLeft = INTERLUDE_MS - u
161    // `nth`: which interlude of its own kind this is, so stretches take their lines in turn.
162    const nth = Math.floor(k / kinds.length)
163    return { kind: 'interlude', interlude: kinds[k % kinds.length]!, index: k, nth, fill: msLeft / INTERLUDE_MS, msLeft, secondsLeft: secondsOf(msLeft) }
164  }
165  return breathFrom(plan.pattern, first + k * block + INTERLUDE_MS, at)
166}
167
168export type Tally = { breaths: number; eyeRests: number; stretches: number }
169
170/** Breaths whose exhale has ended, and interludes begun, by breath time `t`. */
171export function tally(plan: SecondWindPlan, t: number): Tally {
172  const at = Number.isFinite(t) ? Math.max(0, t) : 0
173  if (at < SIGH_MS) return { breaths: 0, eyeRests: 0, stretches: 0 }
174  const c = cycleMs(plan.pattern)
175  const end = exhaleEndMs(plan.pattern)
176  const exhaled = (span: number) => Math.floor(span / c) + (span % c >= end ? 1 : 0)
177  const kinds = interludes(plan)
178  const first = firstInterludeAt(plan)
179  if (kinds.length === 0 || at < first) return { breaths: 1 + exhaled(at - SIGH_MS), eyeRests: 0, stretches: 0 }
180  const per = cyclesPerGap(plan)
181  const block = INTERLUDE_MS + per * c
182  const k = Math.floor((at - first) / block)
183  const u = at - first - k * block
184  const breaths = 1 + (first - SIGH_MS) / c + k * per + (u < INTERLUDE_MS ? 0 : exhaled(u - INTERLUDE_MS))
185  const begun = k + 1
186  const eyes = kinds.length === 2 ? Math.ceil(begun / 2) : kinds[0] === 'eyes' ? begun : 0
187  return { breaths, eyeRests: eyes, stretches: begun - eyes }
188}
189
190export type Finish = Tally & { ms: number; fromFill: number }
191
192/**
193 * What happens when the main turn ends at breath time `t`: an exhale already
194 * under way runs out its own time; an inhale or hold turns into a closing
195 * exhale from where the breath stands; an interlude, an interrupt or an inhale
196 * barely begun fold at once. The finished breath counts.
197 */
198export function softFinish(plan: SecondWindPlan, t: number, isAborted = false): Finish {
199  const base = tally(plan, t)
200  const seg = segmentAt(plan, t)
201  if (isAborted || seg.kind === 'interlude') return { ...base, ms: 0, fromFill: 0 }
202  const b = seg.breath
203  if (b.phase === 'out') return { ...base, breaths: base.breaths + 1, ms: b.msLeft, fromFill: b.fill }
204  if (b.fill < ABANDON_FILL) return { ...base, ms: 0, fromFill: 0 }
205  const ms = Math.round(Math.max(MIN_CLOSE_MS, b.fill * exhaleMs(seg.pattern)))
206  return { ...base, breaths: base.breaths + 1, ms, fromFill: b.fill }
207}
208
209/** The step being drawn, whole: what the desktop circle animates across. */
210export type FrameStep = { key: string; from: number; to: number; ms: number }
211
212export type Frame = {
213  mode: 'breath' | 'interlude' | 'finish'
214  /** The band's words: `Breathe in 4`, `Hold 2`, `Out 6`. */
215  cue: string
216  /** The spinner's verb while it carries the breath. */
217  word: string
218  /** What is running, dim: `slow paced breathing · 3 breaths`. */
219  caption: string
220  fill: number
221  step: FrameStep
222}
223
224const CUES: Readonly<Record<PhaseName, string>> = { in: 'Breathe in', sip: 'Sip in', hold: 'Hold', out: 'Out' }
225const WORDS: Readonly<Record<PhaseName, string>> = { in: 'Breathing in', sip: 'Sipping in', hold: 'Holding', out: 'Breathing out' }
226
227export const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`
228
229/** What a run shows at `now`, or null while it shows no breath. */
230export function frameAt(run: SecondWindRun, now: number): Frame | null {
231  if (run.phase === 'finishing' && run.finish !== null && run.finish.ms > 0) {
232    const f = run.finish
233    const since = Math.min(f.ms, Math.max(0, now - f.at))
234    return {
235      mode: 'finish',
236      cue: `Out ${secondsOf(f.ms - since)}`,
237      word: WORDS.out,
238      caption: plural(f.breaths, 'breath'),
239      fill: f.fromFill * (1 - ease(since / f.ms)),
240      step: { key: `finish:${f.at}`, from: f.fromFill, to: 0, ms: f.ms },
241    }
242  }
243  if (run.phase !== 'breathing') return null
244  const t = Math.max(0, now - run.breathStartedAt)
245  const seg = segmentAt(run.plan, t)
246  if (seg.kind === 'interlude') {
247    const isEyes = seg.interlude === 'eyes'
248    const text = isEyes ? EYE_REST : STRETCHES[seg.nth % STRETCHES.length]!
249    return {
250      mode: 'interlude',
251      cue: `${text} · ${seg.secondsLeft}`,
252      word: isEyes ? 'Resting your eyes' : 'Stretching',
253      caption: LABELS[seg.interlude],
254      fill: seg.fill,
255      step: { key: `rest:${seg.index}`, from: 1, to: 0, ms: INTERLUDE_MS },
256    }
257  }
258  const b = seg.breath
259  const done = tally(run.plan, t).breaths
260  return {
261    mode: 'breath',
262    cue: `${CUES[b.phase]} ${b.secondsLeft}`,
263    word: WORDS[b.phase],
264    caption: done > 0 ? `${LABELS[seg.pattern]} · ${plural(done, 'breath')}` : LABELS[seg.pattern],
265    fill: b.fill,
266    step: { key: `step:${seg.startMs}`, from: b.from, to: b.to, ms: b.stepMs },
267  }
268}
269
270/** What the desktop guide draws now: its timeline, how far in, Bolt's pose, and the still words beside it. */
271export type GuideSpan = {
272  key: string
273  segments: readonly { steps: readonly Step[]; loop: boolean; atMs: number }[]
274  offsetMs: number
275  pose: 'calm' | 'idle'
276  words: boolean
277  /** Beside the drawing, unchanged for the whole span: what is running. */
278  label: string
279  /** An interlude's instruction, also unchanged for the span; null when the drawing carries the words. */
280  still: string | null
281}
282
283/**
284 * The stretch of the wait the desktop guide animates by itself, so the band
285 * around it holds still: the opening sigh then the paced pattern on a loop,
286 * until the next interlude; a still Bolt for an interlude; or the closing
287 * exhale. `key` changes only when the span does.
288 */
289export function guideSpan(run: SecondWindRun, now: number): GuideSpan | null {
290  if (run.phase === 'finishing' && run.finish !== null && run.finish.ms > 0) {
291    const f = run.finish
292    return {
293      key: `finish:${f.at}`,
294      segments: [{ steps: [{ phase: 'out', ms: f.ms, from: f.fromFill, to: 0 }], loop: false, atMs: 0 }],
295      offsetMs: Math.max(0, now - f.at),
296      pose: 'calm',
297      words: true,
298      label: plural(f.breaths, 'breath'),
299      still: null,
300    }
301  }
302  if (run.phase !== 'breathing') return null
303  const t = Math.max(0, now - run.breathStartedAt)
304  const seg = segmentAt(run.plan, t)
305  if (seg.kind === 'interlude') {
306    const still = seg.interlude === 'eyes' ? EYE_REST : STRETCHES[seg.nth % STRETCHES.length]!
307    return { key: `rest:${run.breathStartedAt}:${seg.index}`, segments: [], offsetMs: 0, pose: 'idle', words: false, label: LABELS[seg.interlude], still }
308  }
309  const pattern = run.plan.pattern
310  const first = firstInterludeAt(run.plan)
311  const block = INTERLUDE_MS + cyclesPerGap(run.plan) * cycleMs(pattern)
312  const k = interludes(run.plan).length === 0 || t < first ? -1 : Math.floor((t - first) / block)
313  if (k < 0) {
314    return {
315      key: `breath:${run.breathStartedAt}:start`,
316      segments: [
317        { steps: PATTERNS.sigh, loop: false, atMs: 0 },
318        { steps: PATTERNS[pattern], loop: true, atMs: SIGH_MS },
319      ],
320      offsetMs: t,
321      pose: 'calm',
322      words: true,
323      label: `${LABELS.sigh}, then ${LABELS[pattern]}`,
324      still: null,
325    }
326  }
327  const spanStart = first + k * block + INTERLUDE_MS
328  return {
329    key: `breath:${run.breathStartedAt}:${k}`,
330    segments: [{ steps: PATTERNS[pattern], loop: true, atMs: 0 }],
331    offsetMs: t - spanStart,
332    pose: 'calm',
333    words: true,
334    label: LABELS[pattern],
335    still: null,
336  }
337}
338
hooks/draw.ts 149 lines
1// Pure drawing helpers: the terminal's breath bar in text cells, the desktop's
2// circle as SVG markup, and how much of each fits the band's width.
3
4import { BOLT_PALETTE, BOLT_SPRITES } from './bolt'
5import { clamp01 } from './breath'
6import type { PhaseName } from './breath'
7
8export type Motion = 'full' | 'gentle' | 'still'
9
10/** Gentle motion keeps the shape from emptying all the way: less travel, same pace. */
11export const shownFill = (fill: number, motion: Motion): number =>
12  motion === 'gentle' ? 0.35 + 0.65 * clamp01(fill) : clamp01(fill)
13
14export const TRACK = '─'
15export const BAR_MAX = 40
16export const BAR_MIN = 6
17
18/**
19 * A bar `width` cells wide (rounded down to even) that fills from its middle
20 * outward, at half-cell steps on each side: [left track, body, right track].
21 */
22export function barCells(fill: number, width: number): [string, string, string] {
23  const half = Math.max(1, Math.floor(width / 2))
24  const units = Math.round(clamp01(fill) * half * 2) // half-cells filled per side
25  const full = Math.floor(units / 2)
26  const part = units % 2
27  const track = TRACK.repeat(half - full - part)
28  const body = (part === 1 ? '▐' : '') + '█'.repeat(full * 2) + (part === 1 ? '▌' : '')
29  return [track, body, track]
30}
31
32/** The bar's width for a band `columns` cells wide. */
33export const barWidth = (columns: number): number => Math.max(2, Math.min(BAR_MAX, Math.floor(columns)))
34
35/**
36 * The head row's parts that fit: the cue always (cut if it must be), the
37 * caption only with two cells to spare beside it.
38 */
39export function fitHead(cue: string, caption: string, columns: number): { cue: string; caption: string | null } {
40  const room = Math.max(1, Math.floor(columns))
41  const shortCue = cue.length > room ? cue.slice(0, Math.max(1, room - 1)) + '…' : cue
42  return { cue: shortCue, caption: shortCue.length + 2 + caption.length <= room ? caption : null }
43}
44
45// ---------------------------------------------------------------- the desktop guide
46
47/** One step of the desktop guide: its words, how long it takes, the fill it eases from and to. */
48export type GuideStep = { phase: PhaseName; ms: number; from: number; to: number }
49export type GuidePose = 'calm' | 'idle'
50
51const GUIDE_WORDS: Readonly<Record<PhaseName, string>> = { in: 'Breathe in', sip: 'Sip in', hold: 'Hold', out: 'Breathe out' }
52/** The guide's size in CSS pixels, fixed for a span: Bolt and his halo, the step's words beside him. */
53export const GUIDE_SIZE = { width: 245, height: 72 } as const
54
55const round = (n: number): number => Math.round(n * 100) / 100
56const pct = (at: number, total: number): string => `${round((100 * at) / total)}%`
57
58/** Bolt's small pose as crisp pixel rects, 2 units a pixel, one rect per run of a colour. */
59function boltRects(pose: GuidePose): string {
60  const palette = BOLT_PALETTE as Readonly<Record<string, string>>
61  let out = ''
62  BOLT_SPRITES.small[pose].forEach((row, y) => {
63    for (let x = 0; x < row.length; ) {
64      const key = row[x]!
65      let n = 1
66      while (row[x + n] === key) n += 1
67      if (palette[key] !== undefined) out += `<rect x="${x * 2}" y="${y * 2}" width="${n * 2}" height="2" fill="${palette[key]}"/>`
68      x += n
69    }
70  })
71  return out
72}
73
74/** One stretch of the guide's timeline: its steps, whether they loop, and when it starts. */
75export type GuideSegment = { steps: readonly GuideStep[]; loop: boolean; atMs: number }
76
77/**
78 * The desktop breathing guide: Bolt in his calm pose with a soft halo that
79 * swells on the in-breath and settles on the out-breath, and the step's word
80 * beside him ("Breathe in", "Hold", "Breathe out"), all on the drawing's own
81 * eased keyframes. One drawing carries a whole stretch of the wait (the
82 * opening sigh, then the paced pattern on a loop), so the desktop loads it
83 * once and the band around it never changes. `delayMs` is how far
84 * into the timeline it opens. Reduced motion (the setting or the system's)
85 * keeps Bolt and the halo still; the words still change.
86 */
87export function guideSvg(segments: readonly GuideSegment[], opts: { motion: Motion; delayMs: number; pose: GuidePose; words?: boolean }): string {
88  const isMoving = opts.motion !== 'still'
89  const [r0, r1] = opts.motion === 'gentle' ? [15, 19] : [12, 21]
90  const grow = opts.motion === 'gentle' ? 0.03 : 0.06
91  const r = (f: number) => round(r0 + (r1 - r0) * clamp01(f))
92  const scale = (f: number) => round(1 + grow * clamp01(f))
93  const eased = 'animation-timing-function:cubic-bezier(.45,0,.55,1)'
94  let css = ':root{background:transparent}.h{fill:#C8F53C;fill-opacity:.2}.t{font:600 13px -apple-system,BlinkMacSystemFont,system-ui,sans-serif;fill:#8a94a6}' +
95    '@media (prefers-color-scheme:dark){.h{fill-opacity:.16}.t{fill:#c9d1d9}}.b{transform-box:fill-box;transform-origin:center}'
96  let words = ''
97  const halos: string[] = []
98  const bodies: string[] = []
99  segments.forEach((seg, j) => {
100    const total = seg.steps.reduce((n, step) => n + step.ms, 0)
101    const last = seg.steps[seg.steps.length - 1]
102    if (total <= 0 || !last) return
103    // A later segment takes over when it starts (later in the list wins); before that it draws nothing.
104    const delay = Math.round(seg.atMs - Math.max(0, opts.delayMs))
105    const play = (name: string, fill: string) => `${name} ${Math.round(total)}ms linear ${delay}ms ${seg.loop ? 'infinite' : '1'} ${fill}`
106    let at = 0
107    const halo: string[] = []
108    const body: string[] = []
109    seg.steps.forEach((step, i) => {
110      halo.push(`${pct(at, total)}{r:${r(step.from)}px;${eased}}`)
111      body.push(`${pct(at, total)}{transform:scale(${scale(step.from)});${eased}}`)
112      if (opts.words !== false) {
113        // A word fades out, then the next fades in: never two words on top of each other.
114        const half = Math.min(250, step.ms / 6)
115        const end = at + step.ms
116        const opensAtOne = i === 0 && !seg.loop
117        const endsAtOne = i === seg.steps.length - 1 && !seg.loop
118        const points: [number, number][] = []
119        if (opensAtOne) points.push([0, 1])
120        else points.push([0, 0], [at, 0], [at + half, 1])
121        if (endsAtOne) points.push([total, 1])
122        else points.push([end - half, 1], [end, 0], [total, 0])
123        const frames = points.map(([t, o]) => `${pct(Math.max(0, Math.min(total, t)), total)}{opacity:${o}}`).join('')
124        const fill = j === 0 && segments.length > 1 ? 'backwards' : seg.loop ? 'none' : 'both'
125        css += `@keyframes w${j}_${i}{${frames}}.w${j}_${i}{animation:${play(`w${j}_${i}`, fill)}}`
126        words += `<text class="t w${j}_${i}" x="52" y="26.5" opacity="0">${GUIDE_WORDS[step.phase]}</text>`
127      }
128      at += step.ms
129    })
130    halo.push(`100%{r:${r(last.to)}px}`)
131    body.push(`100%{transform:scale(${scale(last.to)})}`)
132    css += `@keyframes h${j}{${halo.join('')}}@keyframes b${j}{${body.join('')}}`
133    halos.push(play(`h${j}`, j === 0 ? 'both' : 'none'))
134    bodies.push(play(`b${j}`, j === 0 ? 'both' : 'none'))
135  })
136  if (isMoving && halos.length > 0) css += `.h{animation:${halos.join(',')}}.b{animation:${bodies.join(',')}}`
137  css += '@media (prefers-reduced-motion:reduce){.h,.b{animation:none}}'
138  const first = segments[0]?.steps[0]
139  const still = r(isMoving && first ? first.from : 0.5)
140  return (
141    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 44" width="${GUIDE_SIZE.width}" height="${GUIDE_SIZE.height}" style="background:transparent">` +
142    `<style>${css}</style>` +
143    `<circle class="h" cx="22" cy="22" r="${still}"/>` +
144    `<g transform="translate(12 12)" shape-rendering="crispEdges"><g class="b">${boltRects(opts.pose)}</g></g>` +
145    words +
146    '</svg>'
147  )
148}
149
hooks/settings.ts 131 lines
1// Settings, and every sentence Second Wind writes outside the band itself:
2// /breathe's answers, the fold line, the share card. Pure.
3
4import type { PluginOptions } from 'claude-code'
5
6import { LABELS, plural } from './breath'
7import type { Motion } from './draw'
8import { isOn, streakOn, todayOn } from './stats'
9import type { Stats, Switch } from './stats'
10import type { SecondWindPaced, SecondWindPlan } from '../types'
11
12export type Settings = {
13  pattern: SecondWindPaced
14  motion: Motion
15  delayMs: number
16  eyeRest: boolean
17  stretch: boolean
18  movementCard: boolean
19}
20
21export const SAFETY = 'Breathe gently and never strain. If you feel lightheaded, stop and breathe normally.'
22
23/** How often the band redraws while it moves: often for full motion, once a second for still. */
24export const TICK_MS: Readonly<Record<Motion, number>> = { full: 125, gentle: 500, still: 1000 }
25
26const MAX_DELAY_S = 600
27
28export function readSettings(options: PluginOptions): Settings {
29  const pick = <T extends string>(v: unknown, allowed: readonly T[], fallback: T): T =>
30    allowed.includes(v as T) ? (v as T) : fallback
31  const flag = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback)
32  const raw = options.delaySeconds
33  const seconds = typeof raw === 'number' && Number.isFinite(raw) ? Math.min(MAX_DELAY_S, Math.max(0, raw)) : 3
34  return {
35    pattern: pick(options.pattern, ['slow', 'box'] as const, 'slow'),
36    motion: pick(options.motion, ['full', 'gentle', 'still'] as const, 'full'),
37    delayMs: Math.round(seconds * 1000),
38    eyeRest: flag(options.eyeRest, true),
39    stretch: flag(options.stretch, true),
40    movementCard: flag(options.movementCard, false),
41  }
42}
43
44export const planOf = (s: Settings): SecondWindPlan => ({ pattern: s.pattern, delayMs: s.delayMs, eyeRest: s.eyeRest, stretch: s.stretch })
45
46const onOff = (b: boolean): string => (b ? 'on' : 'off')
47
48export function settingsLine(s: Settings): string {
49  return [
50    `pattern ${s.pattern} (${LABELS[s.pattern]})`,
51    `motion ${s.motion}`,
52    `delay ${s.delayMs / 1000}s`,
53    `eye rest ${onOff(s.eyeRest)}`,
54    `stretch ${onOff(s.stretch)}`,
55    `movement card ${onOff(s.movementCard)}`,
56  ].join(' · ')
57}
58
59export const USAGE = 'Use /breathe, /breathe on, /breathe off, /breathe snooze or /breathe card.'
60
61/** What /breathe answers: today's counts, the streak, the settings, the switch and the safety line. */
62export function statusText(stats: Stats, sw: Switch, day: string, s: Settings): string {
63  const t = todayOn(stats, day)
64  const streak = streakOn(stats, day)
65  const state = sw.off ? 'off (/breathe on turns it back on)' : !isOn(sw, day) ? 'snoozed for the rest of today' : 'on'
66  const today = [plural(t.breaths, 'breath'), plural(t.eyeRests, 'eye rest'), plural(t.stretches, 'stretch', 'stretches')]
67  if (s.movementCard || t.moves > 0) today.push(plural(t.moves, 'move'))
68  return [
69    `Second Wind is ${state}.`,
70    `Today: ${today.join(' · ')} · ${streak}-day streak`,
71    `All time: ${plural(stats.all.breaths, 'breath')} while Claude worked`,
72    `Settings: ${settingsLine(s)} (change them in /config)`,
73    USAGE,
74    SAFETY,
75  ].join('\n')
76}
77
78/** The line the band folds into once Claude answers. */
79export function foldLine(breaths: number, stats: Stats | null, day: string): string {
80  const head = `${plural(breaths, 'breath')} while Claude worked`
81  if (stats === null) return head
82  return `${head} · ${todayOn(stats, day).breaths} today · ${streakOn(stats, day)}-day streak`
83}
84
85/** The small text card /breathe card copies. */
86export function shareCard(stats: Stats, day: string): string {
87  const t = todayOn(stats, day)
88  const lines = [
89    'Second Wind',
90    `${plural(t.breaths, 'breath')} today · ${streakOn(stats, day)}-day streak`,
91    `${plural(stats.all.breaths, 'breath')} while Claude worked, all time`,
92  ]
93  const extras = [
94    ...(t.eyeRests > 0 ? [plural(t.eyeRests, 'eye rest')] : []),
95    ...(t.stretches > 0 ? [plural(t.stretches, 'stretch', 'stretches')] : []),
96    ...(t.moves > 0 ? [plural(t.moves, 'move')] : []),
97  ]
98  if (extras.length > 0) lines.push(`${extras.join(' · ')} today`)
99  return lines.join('\n')
100}
101
102/** The two movement cards. */
103export const CARD_TEXT: Readonly<Record<'stand' | 'squats', string>> = { stand: 'Stand up and stretch', squats: '10 squats' }
104
105// OSC (ESC ] or C1 U+009D, to BEL or ST), CSI (ESC [ or C1 U+009B), then any other escape.
106const ANSI =
107  /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u009d[^\u0007\u001b\u009c]*(?:\u0007|\u009c|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]|\u009b[0-?]*[ -/]*[@-~]|\u001b[ -/]*[0-~]?/g
108const BREAKS = /\r\n?|[\n\t\u2028\u2029]/g
109const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
110// Zero-width, bidi overrides and isolates, invisible fillers, tag characters, variation selectors.
111const INVISIBLE =
112  /[\u00ad\u061c\u115f\u1160\u17b4\u17b5\u180b-\u180f\u200b-\u200f\u202a-\u202e\u2060-\u206f\u3164\ufe00-\ufe0f\ufeff\uffa0\ufff0-\ufffb\u{e0000}-\u{e007f}\u{e0100}-\u{e01ef}]/gu
113
114/**
115 * Untrusted text made safe to show on one line. Everything
116 * the band, spinner and status line draw is Second Wind's own words and
117 * numbers; the one thing it repeats from outside is a word typed after
118 * /breathe. ANSI sequences go, line breaks and tabs become spaces, any other
119 * C0, DEL or C1 becomes U+FFFD, invisible or bidi characters go.
120 */
121export function clean(text: string): string {
122  return text.replace(ANSI, '').replace(BREAKS, ' ').replace(CONTROL, '\uFFFD').replace(INVISIBLE, '')
123}
124
125/** What /breathe answers to a word it does not know. */
126export const unknownWord = (word: string): string => `Second Wind does not know "${clean(word)}". ${USAGE}`
127
128/** The prompt box's text after one edit, from the splice `prompt.edit` carries. */
129export const draftAfter = (e: { text: string; start: number; end: number; inputText: string }): string =>
130  e.text.slice(0, e.start) + e.inputText + e.text.slice(e.end)
131
hooks/stats.ts 117 lines
1// Days, streaks and the counts Second Wind keeps. Pure: a day is a calendar
2// date in the person's local time, computed from a clock reading and a UTC
3// offset, so tests can cross midnight in any time zone.
4
5export type DayCounts = { breaths: number; eyeRests: number; stretches: number; moves: number }
6
7/** What $.store keeps under `stats`: counts and dates, nothing else. */
8export type Stats = {
9  v: 1
10  /** The local day `today` belongs to (YYYY-MM-DD). */
11  day: string | null
12  today: DayCounts
13  all: DayCounts
14  /** Consecutive days with at least one breath, ending on `streakDay`. */
15  streak: number
16  streakDay: string | null
17  /** Turns that ran past 60 seconds; every fifth may offer a movement card. */
18  longTurns: number
19}
20
21/** What $.store keeps under `switch`: /breathe off and /breathe snooze. */
22export type Switch = { off: boolean; snoozeDay: string | null }
23
24export const ZERO: DayCounts = { breaths: 0, eyeRests: 0, stretches: 0, moves: 0 }
25export const EMPTY_STATS: Stats = { v: 1, day: null, today: ZERO, all: ZERO, streak: 0, streakDay: null, longTurns: 0 }
26export const SWITCH_ON: Switch = { off: false, snoozeDay: null }
27
28const DAY = /^\d{4}-\d{2}-\d{2}$/
29
30/** The local calendar day of `ms`, `offsetMinutes` east of UTC. */
31export function dayKey(ms: number, offsetMinutes: number): string {
32  return new Date(ms + offsetMinutes * 60_000).toISOString().slice(0, 10)
33}
34
35/** This machine's offset east of UTC at `ms` (daylight saving included); 0 if unknown. */
36export function localOffset(ms: number): number {
37  try {
38    const off = -new Date(ms).getTimezoneOffset()
39    return Number.isFinite(off) ? off + 0 : 0
40  } catch {
41    return 0
42  }
43}
44
45export const localDay = (ms: number): string => dayKey(ms, localOffset(ms))
46
47/** Days since 1970-01-01 for a day key. */
48export const dayNumber = (day: string): number => Math.round(Date.parse(`${day}T00:00:00Z`) / 86_400_000)
49
50/**
51 * The streak after a breathing day. The same day keeps it; the next day adds
52 * one; a gap starts over at 1. A day before the last (the clock moved back,
53 * or a flight west) changes nothing.
54 */
55export function bumpStreak(streak: number, streakDay: string | null, day: string): { streak: number; streakDay: string } {
56  if (streakDay === null || streak < 1) return { streak: 1, streakDay: day }
57  const gap = dayNumber(day) - dayNumber(streakDay)
58  if (gap <= 0) return { streak, streakDay }
59  return gap === 1 ? { streak: streak + 1, streakDay: day } : { streak: 1, streakDay: day }
60}
61
62/** The streak as of `day`: still alive through the day after its last breath. */
63export function streakOn(stats: Stats, day: string): number {
64  if (stats.streakDay === null) return 0
65  return dayNumber(day) - dayNumber(stats.streakDay) <= 1 ? stats.streak : 0
66}
67
68/** Today's counts as of `day` (a day before the stored one reads as the stored one). */
69export const todayOn = (stats: Stats, day: string): DayCounts =>
70  stats.day !== null && day <= stats.day ? stats.today : ZERO
71
72const add = (a: DayCounts, b: Partial<DayCounts>): DayCounts => ({
73  breaths: a.breaths + (b.breaths ?? 0),
74  eyeRests: a.eyeRests + (b.eyeRests ?? 0),
75  stretches: a.stretches + (b.stretches ?? 0),
76  moves: a.moves + (b.moves ?? 0),
77})
78
79/** Adds one turn's counts on `day`. A day with breaths extends the streak. */
80export function addCounts(stats: Stats, day: string, counts: Partial<DayCounts>): Stats {
81  const on = stats.day !== null && day < stats.day ? stats.day : day
82  const today = stats.day === on ? add(stats.today, counts) : add(ZERO, counts)
83  const streak = (counts.breaths ?? 0) > 0 ? bumpStreak(stats.streak, stats.streakDay, on) : { streak: stats.streak, streakDay: stats.streakDay }
84  return { ...stats, day: on, today, all: add(stats.all, counts), ...streak }
85}
86
87const count = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) && x > 0 ? Math.floor(x) : 0)
88const dayOrNull = (x: unknown): string | null => (typeof x === 'string' && DAY.test(x) ? x : null)
89const record = (x: unknown): Record<string, unknown> => (typeof x === 'object' && x !== null ? (x as Record<string, unknown>) : {})
90
91function counts(x: unknown): DayCounts {
92  const r = record(x)
93  return { breaths: count(r.breaths), eyeRests: count(r.eyeRests), stretches: count(r.stretches), moves: count(r.moves) }
94}
95
96/** Reads what the store holds, whatever shape it is in. */
97export function parseStats(raw: unknown): Stats {
98  const r = record(raw)
99  return {
100    v: 1,
101    day: dayOrNull(r.day),
102    today: counts(r.today),
103    all: counts(r.all),
104    streak: count(r.streak),
105    streakDay: dayOrNull(r.streakDay),
106    longTurns: count(r.longTurns),
107  }
108}
109
110export function parseSwitch(raw: unknown): Switch {
111  const r = record(raw)
112  return { off: r.off === true, snoozeDay: dayOrNull(r.snoozeDay) }
113}
114
115/** Whether the band runs on `day`: not off, and not snoozed through it. */
116export const isOn = (sw: Switch, day: string): boolean => !sw.off && !(sw.snoozeDay !== null && day <= sw.snoozeDay)
117
hooks/bolt.ts 110 lines
1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7  B: '#C8F53C',
8  S: '#9BC21A',
9  O: '#FF5C8A',
10  K: '#FF8FB1',
11  E: '#101114',
12  W: '#FBFBF8',
13  G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'calm' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26  small: {
27    'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28    'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29    'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30    'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31    'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32    'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33    'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34    'calm': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
35    'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
36    'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
37    'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38    'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39    'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
40    'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
41    'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
42    'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
43    'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
44  },
45  tiny: {
46    'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
47    'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
48    'run2': ['.O.', 'BEB', 'BBB', '.S.'],
49    'jump': ['.O.', 'BEB', 'BBB', '.S.'],
50    'crash': ['...', '.SO', 'BEB', 'SSS'],
51    'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
52    'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
53  },
54} as const
55
56/** Letters drawn over a whole cell (cell coordinates). */
57const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
58  'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
59}
60
61/** The two run frames, in order. Swap them about every 120 ms while running. */
62export const BOLT_RUN = ['run1', 'run2'] as const
63
64function colour(key: string | undefined): string | undefined {
65  if (key === undefined || key === '.') return undefined
66  return (BOLT_PALETTE as Record<string, string>)[key]
67}
68
69/**
70 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
71 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
72 */
73export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
74export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
75export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
76  const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
77  const out: BoltCell[][] = []
78  for (let r = 0; r + 1 < rows.length; r += 2) {
79    const top = rows[r] ?? ''
80    const bottom = rows[r + 1] ?? ''
81    const line: BoltCell[] = []
82    for (let c = 0; c < top.length; c += 1) {
83      const t = colour(top[c])
84      const b = colour(bottom[c])
85      if (t === undefined && b === undefined) line.push({ ch: ' ' })
86      else if (b === undefined) line.push({ ch: '\u2580', fg: t })
87      else if (t === undefined) line.push({ ch: '\u2584', fg: b })
88      else if (t === b) line.push({ ch: '\u2588', fg: t })
89      else line.push({ ch: '\u2580', fg: t, bg: b })
90    }
91    out.push(line)
92  }
93  for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
94    const line = out[glyph.row]
95    if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
96  }
97  return out
98}
99
100/** Merges a row of cells into runs of one style, for coloured Text spans. */
101export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
102  const runs: BoltCell[] = []
103  for (const cell of line) {
104    const last = runs[runs.length - 1]
105    if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
106    else runs.push({ ...cell })
107  }
108  return runs
109}
110
types/index.d.ts 95 lines
1// What Second Wind keeps in $.state for the session: the breath that is
2// running (or folding away), the clock the terminal's drawings read, what the
3// desktop draws (written only when it changes), whether the person is typing,
4// and an offered movement card. Persistent counts live in $.store.
5
6/** The paced pattern after the opening sigh, as the `pattern` setting names it. */
7export type SecondWindPaced = 'slow' | 'box'
8
9/** What one run of the band follows, fixed when the turn starts. */
10export type SecondWindPlan = {
11  pattern: SecondWindPaced
12  delayMs: number
13  eyeRest: boolean
14  stretch: boolean
15}
16
17/** The closing exhale after Claude answered, and what the run adds up to. */
18export type SecondWindFinish = {
19  /** When the closing exhale began (ms since the epoch). */
20  at: number
21  /** How long it lasts; 0 folds at once. */
22  ms: number
23  /** How full the breath was when the turn ended. */
24  fromFill: number
25  breaths: number
26  eyeRests: number
27  stretches: number
28}
29
30/** The one-line summary shown after the breath folds away. */
31export type SecondWindFold = {
32  text: string
33  /** When it dims. */
34  fadeAt: number
35  /** When it clears. */
36  until: number
37}
38
39/** One band run, tied to the main conversation's turn. */
40export type SecondWindRun = {
41  /** The main turn's id that started it. */
42  id: string
43  phase: 'waiting' | 'breathing' | 'finishing' | 'folded'
44  turnStartedAt: number
45  /** turnStartedAt plus the delay: the breath's own zero. */
46  breathStartedAt: number
47  plan: SecondWindPlan
48  /** Set once the turn has run past 60 seconds. */
49  isLong: boolean
50  finish: SecondWindFinish | null
51  fold: SecondWindFold | null
52}
53
54/** The optional movement card. */
55export type SecondWindCard = { kind: 'stand' | 'squats' }
56
57/** One step of a breath pattern, as the desktop guide's timeline carries it. */
58export type SecondWindStep = { phase: 'in' | 'sip' | 'hold' | 'out'; ms: number; from: number; to: number }
59
60/**
61 * What the desktop draws for the run: the guide for one stretch of the wait
62 * (its whole timeline, so the drawing keeps its own clock), or the fold line.
63 * Written only when it changes. The desktop's band reads this and never the
64 * per-tick clock, so nothing around a running guide is drawn again.
65 */
66export type SecondWindDesk =
67  | {
68      kind: 'guide'
69      /** Changes only when the span does. */
70      key: string
71      phase: 'breathing' | 'finishing'
72      /** When the span's timeline began (ms since the epoch). */
73      at: number
74      segments: { steps: SecondWindStep[]; loop: boolean; atMs: number }[]
75      pose: 'calm' | 'idle'
76      words: boolean
77      /** Beside the drawing, unchanged for the whole span. */
78      label: string
79      /** An interlude's instruction; null when the drawing carries the words. */
80      still: string | null
81    }
82  | { kind: 'fold'; key: string; text: string; isFaded: boolean }
83
84declare module 'claude-code' {
85  interface PluginState {
86    'second-wind': {
87      run: SecondWindRun | null
88      now: number
89      desk: SecondWindDesk | null
90      isTyping: boolean
91      card: SecondWindCard | null
92    }
93  }
94}
95