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

<img src="../../brand/pixel-icons/svg/second-wind.svg" width="64" height="64" alt="">
A breath, an eye rest or a stretch while Claude works, sized to the wait.
/plugin install second-wind --marketplace modmyclaude/claude-code-mods
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
──────────▐████████████████████▌──────────
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. 4 breaths while Claude worked · 37 today · 6-day streak
Breathing in…), but only when Claude Code is not showing a status of its own there.| Command | What it does |
|---|---|
/breathe | Today's breaths, eye rests and stretches, your streak, all-time breaths and the current settings |
/breathe off | Hides the band now and on every later turn |
/breathe on | Brings it back |
/breathe snooze | Off for the rest of today, back tomorrow |
/breathe card | Copies a small text card of your stats to the clipboard to share |
/breathe help | What it does, and the safety line |
/breathe runs at once, even mid-turn.
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.
/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.
| Setting | Default | What it does |
|---|---|---|
pattern | slow | What follows the opening sigh: slow (slow paced breathing) or box (box breathing) |
motion | full | full animates the shape; gentle moves it less and redraws twice a second; still shows the words only, for reduced motion |
delaySeconds | 3 | How long a turn runs before the band appears (0 to 600) |
eyeRest | on | Offer the 20-20-20 eye rest on turns over 60 seconds |
stretch | on | Offer a one-line stretch on turns over 60 seconds |
movementCard | off | Offer the movement card every fifth long turn |
/breathe card./breathe writes its answer as a dim transcript note for you. Claude never reads it, so it costs no tokens.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./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.This mod was built from prompts/second-wind.md.
More mods: the full catalogue.
hooks/register.tsx 750 lines1// 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}
750hooks/breath.ts 338 lines1// 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}
338hooks/draw.ts 149 lines1// 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}
149hooks/settings.ts 131 lines1// 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)
131hooks/stats.ts 117 lines1// 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)
117hooks/bolt.ts 110 lines1// 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}
110types/index.d.ts 95 lines1// 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