Guided breathing exercises above the prompt while Claude works: coherent, box, 4-7-8 and the physiological sigh, four animation styles, and the spinner reads…

Breathe while Claude works.
Every prompt you send gives you 10 to 60+ seconds of waiting. This Claude Mod turns that time into a guided breathing exercise. When Claude starts working, a breathing animation appears above the prompt and the spinner counts the breath with you. When Claude answers, it's gone.

Slow breathing at about 5.5 breaths per minute raises heart rate variability (HRV), a marker of stress resilience. Even short sessions lower cortisol and sharpen focus. Every Claude turn becomes a micro-session, and you never leave the terminal.
Needs Claude Code 2.1.269 or later. Claude Mods are in early access, so turn them on in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
Then:
claude plugin marketplace add halluton/Mindful-Claude
claude plugin install mindful-claude@mindful-claude
Start claude, send a prompt, breathe.
To remove it: claude plugin uninstall mindful-claude.
/breathe| Command | What it does |
|---|---|
/breathe | Show the current settings |
/breathe off / on | Hide or show the band |
/breathe hrv | Coherent Breathing: 5.5s in, 5.5s out |
/breathe sigh | Physiological Sigh: double inhale, long exhale |
/breathe box | Box Breathing: 4s in, 4s hold, 4s out, 4s hold |
/breathe 478 | 4-7-8 Breathing: 4s in, 7s hold, 8s out |
/breathe style wave | Pin a style: pulse, ripples, dots, wave, or random |
/breathe delay 5 | Seconds into a turn before the band appears (default 0) |
/breathe spinner off | Leave the spinner alone |
Settings persist across sessions.
| Exercise | Best for |
|---|---|
| Coherent Breathing | Sustained HRV improvement |
| Physiological Sigh | Quick calm-down |
| Box Breathing | Focus and concentration |
| 4-7-8 Breathing | Deep relaxation |
A Claude Mod is a plugin whose hooks are TypeScript functions running inside Claude Code. This one costs zero tokens: the model never sees it.
hooks/register.tsx hooks ui.render for the band above the prompt and mounts the animation while the engine says a turn is working. It also rewrites the spinner's word with the current phase and registers /breathe.hooks/breathe.tsx is the animation, drawn ten times a second on the surface's own clock.hooks/breath/ holds the exercises, easing and shapes as pure functions, covered by bun test.git clone https://github.com/halluton/Mindful-Claude
cd Mindful-Claude
claude --plugin-dir . # loads from disk, hot-reloads on save
bun test
For type checking, run /plugin-types in that session, then bunx -p typescript tsc -p ..
The original bash and tmux version lives in legacy/.
MIT. See LICENSE.
hooks/register.tsx 103 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { DEFAULTS, applyCommand, readConfig, type Config } from './breath/config.ts'
4import { pickStyle, type Style } from './breath/shapes.ts'
5
6// The hooks module. While a turn runs it mounts ./breathe.tsx above the prompt (the band's
7// `isWorking` prop is the trigger, so the band appears when Claude starts and goes when Claude
8// stops) and reads the breath's phase into the spinner line. /breathe changes the settings,
9// kept in $.store.
10
11const BAND_ROWS = 9 // seven rows of picture, the phase line, the exercise name
12
13let config: Config = DEFAULTS
14// the running turn: when it started and which style it drew
15let turn: { startedAt: number; style: Style } | undefined
16let lastStyle: Style | undefined
17// the breath the band last posted, for the spinner
18let phase: { word: string; exercise: string } | undefined
19
20const log = ($: { ui: { log: (text: string) => void } }, what: string) => (err: unknown) => $.ui.log(`mindful-claude: ${what}: ${err}`)
21
22export const register: Register = on => {
23 on('session.start', async ($, e, next) => {
24 const r = await next(e)
25 const saved = await $.store.get('config').catch(log($, 'store read failed'))
26 config = readConfig(saved)
27 await $.command.register({
28 name: 'breathe',
29 description: 'Breathing exercises above the prompt while Claude works: on, off, hrv, sigh, box, 478, style, delay (mindful-claude)',
30 argumentHint: '[on | off | hrv | sigh | box | 478 | style <name> | delay <s> | help]',
31 immediate: true,
32 }).catch(log($, '/breathe not registered'))
33 return r
34 })
35
36 on('command.run', { command: 'breathe' }, async ($, e) => {
37 const applied = applyCommand(config, e.args)
38 if (applied.config !== config) {
39 config = applied.config
40 await $.store.set('config', config).catch(log($, 'store write failed'))
41 $.ui.invalidate('ui.render')
42 }
43 return { text: applied.text }
44 })
45
46 on('turn.start', async ($, e, next) => {
47 const style = pickStyle(config.style, lastStyle)
48 lastStyle = style
49 turn = { startedAt: await $.clock.now(), style }
50 phase = undefined
51 // the band is drawn from a delay on: wake the render hook when it has passed
52 if (config.delay > 0) $.clock.after(config.delay * 1000, () => $.ui.invalidate('ui.render'))
53 $.ui.invalidate('ui.render')
54 return next(e)
55 })
56
57 on('turn.complete', async ($, e, next) => {
58 const r = await next(e)
59 turn = undefined
60 phase = undefined
61 $.ui.invalidate('ui.render')
62 return r
63 })
64
65 // the band posts its phase whenever the line changes (once a second): the spinner reads it
66 on('ui.message', async ($, e, next) => {
67 const data = e.data as { word?: unknown; exercise?: unknown } | null
68 if (typeof data?.word === 'string' && typeof data.exercise === 'string') {
69 phase = { word: data.word, exercise: data.exercise }
70 if (config.spinner) $.ui.invalidate('ui.render')
71 }
72 return next(e)
73 })
74
75 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
76 // the band needs a Client, which only the terminal draws; a survey keeps the band
77 if (e.surface !== 'terminal' || !config.enabled || !e.props.isWorking || e.props.hasSurvey) return next(e)
78 const now = await $.clock.now()
79 // a turn the hook saw start; else one that is working anyway (started before the plugin loaded)
80 const running = turn ?? (turn = { startedAt: now, style: pickStyle(config.style, lastStyle) })
81 const elapsedMs = now - running.startedAt
82 if (elapsedMs < config.delay * 1000) return next(e)
83 const { Box, Client } = $.ui.resolve(e)
84 const rows = Math.min(BAND_ROWS, e.props.maxRows)
85 if (rows < 1) return next(e)
86 // the key names the turn: one instance per turn, remounted when the settings change mid-turn
87 const key = `breathe:${running.startedAt}:${config.exercise}:${running.style}`
88 return (
89 <Box flexDirection="column">
90 <Client key={key} module="./breathe.tsx" width={e.viewport?.columns ?? 80} height={rows}
91 props={{ exercise: config.exercise, style: running.style, elapsedMs }} />
92 {await next(e)}
93 </Box>
94 )
95 })
96
97 // the spinner's word becomes the breath: `✻ Breathe in 4s…`
98 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
99 if (!config.enabled || !config.spinner || !phase) return next(e)
100 return next({ ...e, props: { ...e.props, message: phase.word } })
101 })
102}
103hooks/breath/config.ts 88 lines1// The plugin's settings and the /breathe command that changes them. Pure: `bun test` covers it.
2
3import { EXERCISES, exerciseOf, isExerciseKey, resolveExercise, type ExerciseKey } from './exercises.ts'
4import { STYLES, isStyle, type Style } from './shapes.ts'
5
6export type Config = {
7 enabled: boolean
8 exercise: ExerciseKey
9 style: Style | 'random'
10 /** Seconds Claude works before the band appears; 0 shows it at once. */
11 delay: number
12 /** Whether the spinner line reads the breath's phase too. */
13 spinner: boolean
14}
15
16export const DEFAULTS: Config = { enabled: true, exercise: 'hrv', style: 'random', delay: 0, spinner: true }
17
18/** A config from what the store held, field by field, defaults for the rest. */
19export function readConfig(saved: unknown): Config {
20 const s = (typeof saved === 'object' && saved !== null ? saved : {}) as Record<string, unknown>
21 return {
22 enabled: typeof s.enabled === 'boolean' ? s.enabled : DEFAULTS.enabled,
23 exercise: isExerciseKey(s.exercise) ? s.exercise : DEFAULTS.exercise,
24 style: s.style === 'random' || isStyle(s.style) ? s.style : DEFAULTS.style,
25 delay: typeof s.delay === 'number' && s.delay >= 0 && Number.isFinite(s.delay) ? s.delay : DEFAULTS.delay,
26 spinner: typeof s.spinner === 'boolean' ? s.spinner : DEFAULTS.spinner,
27 }
28}
29
30export function statusLine(c: Config): string {
31 const ex = exerciseOf(c.exercise)
32 return `breathe: ${c.enabled ? 'on' : 'off'} · ${ex.name} (${ex.pattern}) · style ${c.style} · delay ${c.delay}s · spinner ${c.spinner ? 'on' : 'off'}`
33}
34
35export const HELP = [
36 '/breathe status',
37 '/breathe on | off show or hide the breathing band while Claude works',
38 ...EXERCISES.map(e => `/breathe ${e.key.padEnd(14)}${e.name}: ${e.pattern}`),
39 `/breathe style <name> ${STYLES.join(', ')} or random`,
40 '/breathe delay <s> seconds Claude works before the band appears (0 = at once)',
41 '/breathe spinner on|off read the phase in the spinner line too',
42].join('\n')
43
44/** Applies one `/breathe` invocation; returns the new config and the transcript line. */
45export function applyCommand(config: Config, args: string): { config: Config; text: string } {
46 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
47 const [head, arg] = words
48 if (!head || head === 'status') return { config, text: statusLine(config) }
49 if (head === 'help') return { config, text: HELP }
50 if (head === 'on' || head === 'off') {
51 const next = { ...config, enabled: head === 'on' }
52 return { config: next, text: statusLine(next) }
53 }
54 const exercise = resolveExercise(head)
55 if (exercise) {
56 const next = { ...config, exercise }
57 return { config: next, text: statusLine(next) }
58 }
59 if (head === 'style') {
60 const style: Config['style'] | undefined = arg === 'random' ? 'random' : isStyle(arg) ? arg : undefined
61 if (style) {
62 const next: Config = { ...config, style }
63 return { config: next, text: statusLine(next) }
64 }
65 return { config, text: `breathe: style is one of ${STYLES.join(', ')}, random` }
66 }
67 if (isStyle(head)) {
68 const next = { ...config, style: head }
69 return { config: next, text: statusLine(next) }
70 }
71 if (head === 'delay') {
72 const delay = Number(arg)
73 if (arg !== undefined && Number.isFinite(delay) && delay >= 0) {
74 const next = { ...config, delay }
75 return { config: next, text: statusLine(next) }
76 }
77 return { config, text: 'breathe: delay takes a number of seconds, 0 or more' }
78 }
79 if (head === 'spinner') {
80 if (arg === 'on' || arg === 'off') {
81 const next = { ...config, spinner: arg === 'on' }
82 return { config: next, text: statusLine(next) }
83 }
84 return { config, text: 'breathe: spinner on or off' }
85 }
86 return { config, text: `breathe: no setting called "${head}"\n${HELP}` }
87}
88hooks/breath/shapes.ts 148 lines1// The four animation styles, ported from legacy/breathe.sh: each turns a breath's progress
2// (0..1000) into rows of text, the shape centred in `width` cells. Pure: `bun test` covers it.
3
4export type Style = 'pulse' | 'ripples' | 'dots' | 'wave'
5
6export const STYLES: readonly Style[] = ['pulse', 'ripples', 'dots', 'wave']
7
8export const isStyle = (value: unknown): value is Style =>
9 typeof value === 'string' && (STYLES as readonly string[]).includes(value)
10
11/** The style for a turn: the setting, or at random, never the one just shown. */
12export function pickStyle(setting: Style | 'random', last: Style | undefined, random: () => number = Math.random): Style {
13 if (setting !== 'random') return setting
14 const pool = STYLES.filter(s => s !== last)
15 return pool[Math.floor(random() * pool.length)] ?? 'pulse'
16}
17
18/** Rows the shape is drawn on, as breathe.sh drew it (its rows 2..8); the middle row is 3. */
19export const ART_ROWS = 7
20const MID = 3
21
22const HBLK = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
23const RIPPLE = ['━', '─', '╌', '┈'] as const
24const PULSE_RATIO = [200, 600, 1000, 600, 200] as const
25const RIPPLE_RATIO = [1000, 750, 500, 250] as const
26
27// [row, column factor]: the column is -1000..1000 from the centre, scaled to the box
28const DOTS: readonly (readonly [number, number])[] = [
29 [3, 0], [3, 70], [3, -80],
30 [2, 150], [4, -160], [2, -220], [4, 230], [3, 300], [3, -310],
31 [1, 250], [5, -260], [1, -400], [5, 410], [2, 450], [4, -460],
32 [2, -530], [4, 540], [3, 600], [3, -620],
33 [0, 400], [6, -420], [0, -600], [6, 620], [1, 700], [5, -710],
34 [1, -800], [5, 810], [2, 850], [4, -860],
35 [0, 900], [6, -910], [0, -950], [6, 960],
36]
37
38/** The shape's widest extent: the band less a margin, at most 60 cells. */
39export const boxWidth = (width: number): number => Math.min(60, Math.max(4, width - 4))
40
41/** Half the shape's current width in cells; never 0 while any breath is in. */
42export function scaledHalf(progress: number, width: number): number {
43 const maxHalf = Math.floor(boxWidth(width) / 2)
44 const half = Math.floor((maxHalf * progress) / 1000)
45 return half < 1 && progress > 0 ? 1 : half
46}
47
48/** A pulse bar's half: `░▒▓███` on the left, mirrored on the right, `h` cells each. */
49function pulseBar(h: number): string {
50 const fill = '█'.repeat(Math.max(0, h - 3))
51 const left = (h >= 3 ? '░' : '') + (h >= 2 ? '▒' : '') + '▓' + fill
52 const right = fill + '▓' + (h >= 2 ? '▒' : '') + (h >= 3 ? '░' : '')
53 return left + right
54}
55
56type Grid = string[][]
57
58const blank = (width: number): Grid => Array.from({ length: ART_ROWS }, () => Array<string>(width).fill(' '))
59
60/** Writes `text` into `grid[row]` centred, clipped to the grid. */
61function centred(grid: Grid, row: number, text: string): void {
62 const line = grid[row]
63 if (!line) return
64 const width = line.length
65 const chars = [...text]
66 const start = Math.floor((width - chars.length) / 2)
67 chars.forEach((ch, i) => {
68 const col = start + i
69 if (col >= 0 && col < width) line[col] = ch
70 })
71}
72
73function pulse(grid: Grid, half: number): void {
74 PULSE_RATIO.forEach((ratio, i) => {
75 const h = Math.min(32, Math.floor((half * ratio) / 1000))
76 if (h > 0) centred(grid, MID - 2 + i, pulseBar(h))
77 })
78}
79
80function ripples(grid: Grid, half: number): void {
81 RIPPLE_RATIO.forEach((ratio, dist) => {
82 const rw = Math.min(64, Math.floor((half * ratio) / 1000))
83 if (rw <= 0) return
84 const line = RIPPLE[dist]!.repeat(rw * 2)
85 centred(grid, MID - dist, line)
86 if (dist > 0) centred(grid, MID + dist, line)
87 })
88}
89
90function dots(grid: Grid, progress: number, width: number): void {
91 if (progress <= 0) return
92 const centre = Math.floor(width / 2)
93 const maxHalf = Math.floor(boxWidth(width) / 2)
94 for (const [row, dcol] of DOTS) {
95 const abs = Math.abs(dcol)
96 if (abs > progress) continue
97 const col = centre + Math.trunc((dcol * maxHalf) / 1000)
98 const ch = abs < 150 ? '✦' : abs < 500 ? '•' : '·'
99 const line = grid[row]
100 if (line && col >= 0 && col < width) line[col] = ch
101 }
102}
103
104function wave(grid: Grid, half: number): void {
105 if (half <= 0) return
106 const sh2 = half * half
107 for (let rowIdx = 0; rowIdx < 3; rowIdx++) {
108 const rowBase = rowIdx * 8
109 let text = ''
110 let any = false
111 for (let x = -half; x <= half; x++) {
112 const total = Math.floor((24 * (sh2 - x * x)) / sh2)
113 const rowH = Math.max(0, Math.min(8, total - rowBase))
114 text += HBLK[rowH]
115 if (rowH > 0) any = true
116 }
117 if (any) centred(grid, MID + 2 - rowIdx, text)
118 }
119}
120
121/**
122 * One frame: `rows` strings, each at most `width` cells with the shape centred.
123 * Fewer than 7 rows shows the middle of the picture; more pads it top and bottom.
124 */
125export function frame(style: Style, progress: number, width: number, rows: number = ART_ROWS): string[] {
126 const grid = blank(Math.max(1, width))
127 const half = scaledHalf(progress, width)
128 switch (style) {
129 case 'pulse': pulse(grid, half); break
130 case 'ripples': ripples(grid, half); break
131 case 'dots': dots(grid, progress, width); break
132 case 'wave': wave(grid, half); break
133 }
134 const lines = grid.map(line => line.join('').trimEnd() || ' ')
135 if (rows >= ART_ROWS) {
136 const above = Math.floor((rows - ART_ROWS) / 2)
137 return [...Array<string>(above).fill(' '), ...lines, ...Array<string>(rows - ART_ROWS - above).fill(' ')]
138 }
139 const skip = Math.floor((ART_ROWS - rows) / 2)
140 return lines.slice(skip, skip + rows)
141}
142
143/** `text` centred in `width` cells (left padding only). */
144export const centre = (text: string, width: number): string => {
145 const pad = Math.floor((width - [...text].length) / 2)
146 return ' '.repeat(Math.max(0, pad)) + text
147}
148hooks/breathe.tsx 55 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { exerciseOf, phaseAt, phaseLine, spinnerWord } from './breath/exercises.ts'
4import { centre, frame, isStyle, type Style } from './breath/shapes.ts'
5
6// The breathing band: a surface module the hooks module mounts above the prompt while Claude
7// works. It runs on the drawing thread with its own frame clock, ten frames a second like
8// legacy/breathe.sh, and posts the phase line to the hooks module whenever it changes so the
9// spinner can read it too.
10//
11// Never name a local `h` here: every JSX tag compiles to a call of `h`.
12
13type Props = { exercise?: string; style?: string; elapsedMs?: number } | undefined
14type State = { tick: number; offsetMs: number; line: string }
15
16const COLOR = '#f7835d' // Claude orange
17const TICK_MS = 100
18
19export default function Breathe(props: Props, surface: ClientSurface<State>) {
20 const { Box, Text } = surface.elements
21 const exercise = exerciseOf(props?.exercise)
22 const style: Style = isStyle(props?.style) ? props.style : 'pulse'
23
24 if (surface.state === undefined) {
25 // elapsedMs says how far into the turn the band mounted (a delay); the clock runs from there
26 surface.setState({ tick: 0, offsetMs: props?.elapsedMs ?? 0, line: '' })
27 surface.every(TICK_MS, () => {
28 const s = surface.state
29 if (!s) return
30 const tick = s.tick + 1
31 const next = phaseAt(exercise, s.offsetMs + tick * TICK_MS)
32 const line = phaseLine(next)
33 if (line !== s.line) surface.post({ word: spinnerWord(next), exercise: exercise.name })
34 surface.setState({ tick, offsetMs: s.offsetMs, line })
35 })
36 }
37
38 const s = surface.state
39 const elapsed = (s?.offsetMs ?? 0) + (s?.tick ?? 0) * TICK_MS
40 const phase = phaseAt(exercise, elapsed)
41 const width = surface.columns || 80
42 const rows = surface.rows || 9
43 // two rows of text under the picture; a short band keeps the phase line and drops the rest
44 const artRows = Math.max(0, rows - 2)
45 const art = frame(style, phase.progress, width, artRows)
46
47 return (
48 <Box flexDirection="column" width={width}>
49 {art.map(line => <Text color={COLOR}>{line}</Text>)}
50 {rows >= 1 ? <Text color={COLOR}>{centre(phaseLine(phase), width)}</Text> : null}
51 {rows >= 2 ? <Text color={COLOR} dimColor>{centre(exercise.name, width)}</Text> : null}
52 </Box>
53 )
54}
55hooks/breath/exercises.ts 92 lines1// The four breathing exercises and the maths of one breath, ported from legacy/breathe.sh.
2// Pure functions: no engine, no surface, so `bun test` covers them.
3
4export type ExerciseKey = 'hrv' | 'sigh' | 'box' | '478'
5
6export type Exercise = {
7 key: ExerciseKey
8 name: string
9 /** The pattern, as the list prints it. */
10 pattern: string
11 inhaleMs: number
12 hold1Ms: number
13 exhaleMs: number
14 hold2Ms: number
15 /** Physiological sigh: the first hold is a second, smaller inhale. */
16 sip?: true
17}
18
19export const EXERCISES: readonly Exercise[] = [
20 { key: 'hrv', name: 'Coherent Breathing', pattern: '5.5s in, 5.5s out', inhaleMs: 5500, hold1Ms: 0, exhaleMs: 5500, hold2Ms: 0 },
21 { key: 'sigh', name: 'Physiological Sigh', pattern: 'double inhale, long exhale', inhaleMs: 4000, hold1Ms: 1000, exhaleMs: 10000, hold2Ms: 0, sip: true },
22 { key: 'box', name: 'Box Breathing', pattern: '4s in, 4s hold, 4s out, 4s hold', inhaleMs: 4000, hold1Ms: 4000, exhaleMs: 4000, hold2Ms: 4000 },
23 { key: '478', name: '4-7-8 Breathing', pattern: '4s in, 7s hold, 8s out', inhaleMs: 4000, hold1Ms: 7000, exhaleMs: 8000, hold2Ms: 0 },
24]
25
26const ALIASES: Record<string, ExerciseKey> = {
27 hrv: 'hrv', coherent: 'hrv', coherence: 'hrv',
28 sigh: 'sigh', physiological: 'sigh',
29 box: 'box',
30 '478': '478', relax: '478',
31}
32
33export const isExerciseKey = (value: unknown): value is ExerciseKey =>
34 typeof value === 'string' && EXERCISES.some(e => e.key === value)
35
36/** The exercise a word names (`hrv`, `coherent`, `sigh`, `box`, `478`, `relax`), or undefined. */
37export const resolveExercise = (word: string): ExerciseKey | undefined => ALIASES[word.trim().toLowerCase()]
38
39export const exerciseOf = (key: unknown): Exercise => EXERCISES.find(e => e.key === key) ?? EXERCISES[0]!
40
41export const cycleMs = (ex: Exercise): number => ex.inhaleMs + ex.hold1Ms + ex.exhaleMs + ex.hold2Ms
42
43export type PhaseLabel = 'Breathe in' | 'Sip in' | 'Hold' | 'Breathe out'
44
45export type Phase = {
46 label: PhaseLabel
47 /** Milliseconds left in this phase. */
48 remainingMs: number
49 /** How full the lungs are, 0..1000. */
50 progress: number
51}
52
53/** Ease-out (quadratic) over 0..1000: fast start, gentle deceleration. */
54export const ease = (x: number): number => Math.floor((x * (2000 - x)) / 1000)
55
56/** Where in the breath `elapsedMs` since the start lands. */
57export function phaseAt(ex: Exercise, elapsedMs: number): Phase {
58 const cycle = cycleMs(ex)
59 const t = ((Math.floor(elapsedMs) % cycle) + cycle) % cycle
60 const inhaleEnd = ex.inhaleMs
61 const hold1End = inhaleEnd + ex.hold1Ms
62 const exhaleEnd = hold1End + ex.exhaleMs
63
64 if (t < inhaleEnd) {
65 const eased = ease(Math.floor((t * 1000) / ex.inhaleMs))
66 // a sigh's first inhale fills 85%; the sip takes the rest
67 const progress = ex.sip ? Math.floor((eased * 850) / 1000) : eased
68 return { label: 'Breathe in', remainingMs: inhaleEnd - t, progress }
69 }
70 if (t < hold1End) {
71 if (ex.sip) {
72 const eased = ease(Math.floor(((t - inhaleEnd) * 1000) / ex.hold1Ms))
73 return { label: 'Sip in', remainingMs: hold1End - t, progress: 850 + Math.floor((eased * 150) / 1000) }
74 }
75 return { label: 'Hold', remainingMs: hold1End - t, progress: 1000 }
76 }
77 if (t < exhaleEnd) {
78 const eased = ease(Math.floor(((t - hold1End) * 1000) / ex.exhaleMs))
79 return { label: 'Breathe out', remainingMs: exhaleEnd - t, progress: 1000 - eased }
80 }
81 return { label: 'Hold', remainingMs: cycle - t, progress: 0 }
82}
83
84/** The countdown the phase line shows: whole seconds, rounded up. */
85export const secondsLeft = (phase: Phase): number => Math.ceil(phase.remainingMs / 1000)
86
87/** The band's line: `Breathe in... 4s` */
88export const phaseLine = (phase: Phase): string => `${phase.label}... ${secondsLeft(phase)}s`
89
90/** The spinner's word, which the engine follows with its own ellipsis: `Breathe in 4s` */
91export const spinnerWord = (phase: Phase): string => `${phase.label} ${secondsLeft(phase)}s`
92