A short breathing exercise beside the chat: /breathe, on a schedule, or when your prompts run hot.

Claude Code mod: a short breathing exercise in a pane beside the chat. A soft core inside a nebula of uneven, drifting contour lines grows as you breathe in and shrinks as you breathe out; one line lights up as the phase's timer, and dust gathers in on the breath and scatters out. Each phase has its colours (icy in, violet held full, amber to rose out, indigo held empty), and a low organ drone with a long reverb swells with the breath. It closes itself after 30 s by default; Esc at an empty prompt, or the pane's close mark, closes it sooner. Zero tokens: nothing it does reaches the model.

In the terminal the picture is drawn in character cells, 30 frames a second: the glow as coloured half blocks, the lines and dust as braille dots. A pane wider than the nebula widens it a little and fills the rest with faint stars. The pane opens at once and takes no keys. The old Edge window is still there (where: window), and opens instead wherever a pane cannot be drawn.
Three ways it opens:
/breathe (or /breathe box, /breathe 4-7-8): runs at once, even mid-turn.!!!/??, only make the rest louder, so casual or glad swearing ("explain it fucking better", "holy shit it works") never opens it. The score fades by half every 10 minutes and opens the window at 5 (sensitivity: 3 on high, 8 on low); repeats compound, so one very heated prompt can open it alone, and milder ones add up over a run. At most once an hour, across sessions. /breathe heat shows the score now, the last prompt's, and when the next break may open. The prompt itself goes on untouched. To score words of your own as insults, put them in insults.local.txt in this folder (gitignored): one per line, each a regex fragment matched as a whole word, case-insensitive (dumb\w* also catches "dumbest"). Lines starting with # are comments. It is read when a session starts.The window never takes the keyboard either: Windows keeps the foreground from it. The launcher pins it on top, and keeps re-pinning it until it closes, since Edge drops the pin now and then and a covered Edge window stops animating.
In Claude Code 2.1.275 or later:
/plugin marketplace add asfbr/claude-code-vibe-mods
/plugin install breathe@claude-code-vibe-mods
Or from a clone: in ~/.claude/settings.json, add this folder to the mods list (; separates folders on Windows, : elsewhere), and restart Claude Code. claude plugin list should show breathe@inline ... loaded.
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-vibe-mods/announcer;~/claude-code-vibe-mods/breathe" }
/config)where: pane (default) or window (Edge, bottom-right of the screen, on top of everything).anger: open (default), hint (a status-line "rough one? /breathe" for 5 minutes), off. sensitivity: high (opens at 3), normal (5, default), low (8). cooldown: 30 min, 1 h, 2 h.schedule: off (default), 30 min … 2 h. Two sessions open still give one window per interval.pattern: 4-6 (in 4, out 6; default), box (4-4-4-4), 4-7-8. length: 15 s, 30 s (default), 1 min, 2 min, rounded to whole breaths.sound: the drone. volume: 100% … 10% (default 35%).hooks/register.tsx (triggers, the pane and its frame clock), hooks/anger.ts (the score), hooks/sky.ts (the breath's timeline and the pane's picture as terminal cells, ported from the page), ui/breathe.html (the window's page; ?p= pattern, s= seconds, v= volume, so it opens straight in a browser too; there the sound waits for a click), scripts/launch.ps1 (Edge --app window with its own profile under %LOCALAPPDATA%\claude-breathe, placed and pinned).
The window synthesises its drone in the page. The pane cannot, and Claude Code's own player is silent on Windows, so scripts/drone.py renders the same synth offline into sounds/drone-<pattern>.wav (rerun it after changing the synth or the timings; needs numpy) and scripts/drone.ps1 stitches it to the run's length and plays it.
The pane runs anywhere Claude Code does, silent off Windows; the window is Windows only (PowerShell + Edge). The window was checked at 100% display scaling on one monitor; other scalings and multi-monitor placement are untested. The pane is drawn for the terminal; in the desktop app or the VS Code extension the window opens instead.
claude plugin validate . and claude plugin test . from this folder.
hooks/register.tsx 233 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Run, Words } from '../types'
5import { heat, localInsults } from './anger'
6import { PATTERNS, cyclesFor, encode, moment, paint, runSeconds } from './sky'
7
8// Summed heat that opens the window, by the `sensitivity` setting.
9const TRIGGERS: Record<string, number> = { low: 8, normal: 5, high: 3 }
10const HALF_LIFE = 10 * 60_000 // heat halves every 10 minutes
11const HINT_MS = 5 * 60_000
12const PANE = 'breathe'
13const FRAME_MS = 33
14// Prompts the machinery sends, not the person: never scored.
15const MACHINE = new Set(['task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'coordinator',
16 'observer', 'observer-activity', 'auto-continuation'])
17
18// "30 s" / "30 min" / "1 h" → milliseconds; "off" → 0.
19const UNIT: Record<string, number> = { s: 1000, min: 60_000, h: 3_600_000 }
20const duration = (text: unknown) => {
21 const m = /(\d+)\s*(h|min|s)\b/.exec(String(text))
22 return m ? Number(m[1]) * (UNIT[m[2] ?? ''] ?? 0) : 0
23}
24
25type Breath = { pattern: string; seconds: number; volume: number; where: string }
26
27const runRef = atom({ plugin: 'breathe', key: 'run' } as const, null as Run | null)
28const wordsRef = atom({ plugin: 'breathe', key: 'words' } as const, { label: '', count: '', done: 0 } as Words)
29const wordsOf = (m: Words): Words => ({ label: m.label, count: m.count, done: m.done })
30const windows = ($: EngineInterface) => /^[A-Za-z]:[\\/]/.test($.plugin.root)
31
32// The pane's run, kept here for the frame clock; $.state holds it for drawing and across a reload.
33let current: Run | undefined
34let timer: Timer | undefined
35let drawn: { columns: number; rows: number } | undefined
36let painting = false
37let refused = false
38
39// One frame: the words when they change (a redraw), else the picture alone (a blit).
40async function tick($: EngineInterface) {
41 const run = current
42 if (run === undefined || painting) return
43 painting = true
44 try {
45 const time = ((await $.clock.now()) - run.startedAt) / 1000
46 if (time >= runSeconds(run)) return void await end($, false)
47 const m = moment(run, time), words = wordsOf(m), shown = await read($, wordsRef)
48 if (words.label !== shown.label || words.count !== shown.count || words.done !== shown.done) {
49 await update($, wordsRef, () => words)
50 } else if (drawn !== undefined) {
51 const { columns, rows } = drawn
52 const r = await $.ui.blit({ requestId: PANE, key: 'sky', columns, rows, cells: encode(paint(m, time, columns, rows)) })
53 // Refused (resized, or another tab shown): draw again once, not on every frame it stays refused.
54 if ('deny' in r && r.deny !== undefined) { if (!refused) $.ui.invalidate('ui.render'); refused = true }
55 else refused = false
56 }
57 } finally {
58 painting = false
59 }
60}
61
62function follow($: EngineInterface, run: Run) {
63 current = run
64 timer?.cancel()
65 timer = $.clock.every(FRAME_MS, () => void tick($))
66}
67
68// Stops the run; `early` (closed by the person) also silences the drone, which otherwise ends with it.
69async function end($: EngineInterface, early: boolean) {
70 const run = current
71 if (run === undefined) return
72 current = undefined
73 timer?.cancel()
74 timer = undefined
75 drawn = undefined
76 await $.state.set({ plugin: 'breathe', key: 'run' }, null)
77 if (early && windows($)) {
78 void $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command',
79 // Not this process, whose own command line matches the filter too.
80 `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%drone.ps1%${run.startedAt}%' AND ProcessId != $PID" | Invoke-CimMethod -MethodName Terminate | Out-Null`])
81 .catch((err: unknown) => $.ui.log(`breathe: ${String(err)}`))
82 }
83 await $.ui.close({ id: PANE })
84}
85
86const launch = ($: EngineInterface, argv: string[], timeoutMs: number) =>
87 void $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ...argv],
88 { timeoutMs })
89 .then(r => { if (r.exitCode !== 0) $.ui.log(`breathe: ${r.stderr.trim() || `${argv[0]} exited ${r.exitCode}`}`) })
90 .catch((err: unknown) => $.ui.log(`breathe: ${String(err)}`))
91
92// The pane beside the chat; false when the surface would not place it (unasked on a narrow terminal).
93async function openPane($: EngineInterface, breath: Breath, now: number): Promise<boolean> {
94 if (!(await $.session.surfaces()).includes('terminal')) return false // the picture is terminal cells
95 const run: Run = { pattern: breath.pattern, cycles: cyclesFor(breath.pattern, breath.seconds), startedAt: now }
96 await $.state.set({ plugin: 'breathe', key: 'run' }, run)
97 await update($, wordsRef, () => wordsOf(moment(run, 0)))
98 follow($, run)
99 const opened = await $.ui.open({ id: PANE, title: 'Breathe', rows: 18, closeOnEscape: true })
100 if (!opened.isPlaced) {
101 await end($, false)
102 return false
103 }
104 if (breath.volume > 0 && windows($)) {
105 const root = $.plugin.root.replace(/\\/g, '/')
106 launch($, [`${root}/scripts/drone.ps1`, '-Pattern', run.pattern, '-Cycles', String(run.cycles), '-Volume',
107 String(breath.volume), '-StartedAt', String(now)], runSeconds(run) * 1000 + 60_000)
108 }
109 return true
110}
111
112// Opens the breath unless any session opened one within `gap`: the pane, or Edge's window when that is
113// the setting or the pane could not be placed. Says what happened, for /breathe.
114async function open($: EngineInterface, breath: Breath, gap: number): Promise<string> {
115 const now = await $.clock.now()
116 if (current !== undefined || now - Number((await $.store.get('lastOpen')) ?? 0) < gap) return 'a breathing window is already open'
117 if (breath.where === 'pane' && await openPane($, breath, now)) {
118 await $.store.set('lastOpen', now)
119 return 'Breathe. Esc at an empty prompt closes it.'
120 }
121 if (!windows($)) {
122 return breath.where === 'pane' ? 'no room for the breathing pane here; widen the terminal or run /breathe'
123 : 'the breathing window needs Windows and Edge'
124 }
125 await $.store.set('lastOpen', now)
126 const root = $.plugin.root.replace(/\\/g, '/')
127 const url = `file:///${encodeURI(`${root}/ui/breathe.html`)}?p=${breath.pattern}&s=${breath.seconds}&v=${breath.volume}`
128 // Not awaited: the launcher keeps the window pinned on top until it closes.
129 launch($, [`${root}/scripts/launch.ps1`, '-Url', url], breath.seconds * 1000 + 60_000)
130 return 'Breathe. The window is in the bottom-right corner; Esc closes it.'
131}
132
133export const register: Register = (on, options) => {
134 const seconds = duration(options.length) / 1000 || 30
135 const volume = options.sound === true ? Math.min(100, parseInt(String(options.volume), 10) || 35) / 100 : 0
136 const breath: Breath = { pattern: String(options.pattern), seconds, volume, where: String(options.where ?? 'pane') }
137 const windowMs = seconds * 1000 + 15_000 // its breathing plus Edge's start-up
138 const every = duration(options.schedule)
139 const cooldown = duration(options.cooldown) || 3_600_000
140 const trigger = TRIGGERS[String(options.sensitivity)] ?? TRIGGERS.normal!
141 let level = 0
142 let scoredAt = 0
143 let last = 0 // the last prompt's own heat, for /breathe heat
144 const heatNow = (now: number) => level * 0.5 ** ((now - scoredAt) / HALF_LIFE)
145 let extra: RegExp | undefined // your own insults, from insults.local.txt
146
147 on('session.start', async ($, e, next) => {
148 const local = await $.fs.read(`${$.plugin.root}/insults.local.txt`).catch(() => undefined)
149 extra = typeof local === 'string' ? localInsults(local) : undefined
150 await $.command.register({
151 name: 'breathe',
152 description: 'A short breathing exercise beside the chat',
153 argumentHint: '[4-6|box|4-7-8|heat]',
154 immediate: true,
155 })
156 if (every > 0) $.clock.every(every, () => void open($, breath, every - 60_000))
157 // A reload mid-breath: pick the run up again, or close what it left.
158 const { value: run } = await $.state.get({ plugin: 'breathe', key: 'run' })
159 if (run) {
160 if (((await $.clock.now()) - run.startedAt) / 1000 < runSeconds(run)) follow($, run)
161 else { current = run; await end($, false) }
162 }
163 return next(e)
164 })
165
166 on('command.run', { command: 'breathe' }, async ($, e) => {
167 const asked = e.args.trim()
168 const names = Object.keys(PATTERNS)
169 if (asked === 'heat') {
170 const now = await $.clock.now()
171 const wait = cooldown - (now - Number((await $.store.get('lastHot')) ?? 0))
172 const off = options.anger === 'off' ? ' Hot prompts are off.' : ''
173 return { text: `breathe: heat ${heatNow(now).toFixed(1)} of ${trigger} (sensitivity ${options.sensitivity}), `
174 + `the last prompt ${last.toFixed(1)}; it halves every 10 min. Next hot break `
175 + `${wait > 0 ? `in ${Math.ceil(wait / 60_000)} min at the earliest` : 'possible now'}.${off}` }
176 }
177 if (asked !== '' && !names.includes(asked)) return { text: `breathe: pick ${names.join(', ')}, heat, or nothing` }
178 return { text: await open($, asked === '' ? breath : { ...breath, pattern: asked }, windowMs) }
179 })
180
181 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
182 if (e.surface !== 'terminal') return next(e)
183 const { Box, Text, Raster } = $.ui.resolve(e)
184 const run = await read($, runRef), words = await read($, wordsRef)
185 if (run === null) return <Text dimColor> </Text>
186 const columns = Math.max(12, e.props.bodyColumns)
187 const rows = Math.max(6, Math.min(e.props.scroll.bodyRows - 2, Math.round(columns * 0.6)))
188 drawn = { columns, rows }
189 refused = false
190 const time = ((await $.clock.now()) - run.startedAt) / 1000
191 const dots = Array.from({ length: run.cycles }, (_, i) => (i < words.done ? '●' : '·')).join(' ')
192 return (
193 <Box flexDirection="column">
194 <Raster key="sky" columns={columns} rows={rows} cells={encode(paint(moment(run, time), time, columns, rows))} />
195 <Box justifyContent="center">
196 <Text italic>{words.label}</Text>
197 <Text dimColor>{words.count === '' ? '' : ` ${words.count}`}</Text>
198 </Box>
199 <Box justifyContent="center"><Text dimColor>{dots}</Text></Box>
200 </Box>
201 )
202 })
203
204 // The person's close mark or Esc: stop drawing, and the drone with it.
205 on('ui.close', async ($, e, next) => {
206 const r = await next(e)
207 if (e.id === PANE && e.origin.kind !== 'plugin') await end($, true)
208 return r
209 }).catch(($, e, next) => next(e)) // a failure here never keeps a pane open
210
211 // Scores what the person typed; the prompt itself goes on untouched.
212 on('prompt.submit', async ($, e, next) => {
213 const origin = e.origin as typeof e.origin | undefined
214 const machine = MACHINE.has(origin?.kind ?? '') || (origin?.kind === 'plugin' && origin.asUser !== true)
215 if (options.anger === 'off' || e.command !== undefined || e.isMeta === true || machine) return next(e)
216 const now = await $.clock.now()
217 last = heat(e.text, extra)
218 level = heatNow(now) + last
219 scoredAt = now
220 if (level >= trigger &&now - Number((await $.store.get('lastHot')) ?? 0) >= cooldown) {
221 level = 0
222 await $.store.set('lastHot', now)
223 if (options.anger === 'hint') {
224 $.ui.status('rough one? /breathe')
225 $.clock.after(HINT_MS, () => $.ui.status(undefined))
226 } else {
227 await open($, breath, windowMs)
228 }
229 }
230 return next(e)
231 }).catch(($, e, next) => next(e)) // only watches: a failure here never blocks a prompt
232}
233hooks/anger.ts 63 lines1// How hot one prompt reads, 0-6, from what was typed. English and Portuguese.
2// Anger is frustrated phrases, insults, shouting, and swearing aimed at something: at you ("fuck you"), at what
3// broke ("fucking broken again"), or a bare outburst ("porra."). Any other swear only amplifies, so casual
4// swearing ("explain it fucking better", "holy shit it works") never builds up past the trigger.
5
6const SWEAR = /^(fuck\w*|motherfuck\w*|shit\w*|bullshit|damn\w*|goddamn\w*|crap\w*|porra\w*|caralh\w*|merda\w*|puta\w*|fod\w*|vsf|fdp|vtnc|krl|cacete|bosta\w*)$/i
7const INSULTS = /\b(imbecile\w*|imbecil|idiot\w*)\b/gi
8const FRUSTRATED = /\b(wtf|ffs|pqp|again\?|still (not|broken|wrong|failing|the same|doesn'?t|isn'?t)|how many times|i (already )?told you|are you (kidding|serious)|seriously\?|what the (hell|heck|fuck)|wrong again|useless|for the \w+ time|(doesn'?t|does not|still not) work|de novo|ainda (n[aã]o|errado|quebrado)|n[aã]o funciona|quantas vezes|(eu )?j[aá] (te )?(disse|falei)|t[aá] de brincadeira)/gi
9const CODE = /```[\s\S]*?```|`[^`\n]*`/g // pasted code is not the person talking
10
11// What a swear is aimed at, within a couple of words of it in the same sentence; and what makes it glee.
12const AT_YOU = /\b(you|u|ya|your|yours|you'?re|voc[eê]|vc|tu|teu|tua)\b|\bfuck\w*\s+off\b|\bvai\s+(se|te)\s+f/i
13const AIMED = /^(vsf|fdp|vtnc)$/i // "vai se foder", "filho da puta", "vai tomar no cu": aimed by what they say
14const BROKEN = /\b(broken|broke|breaks|breaking|fail\w*|crash\w*|wrong|bug\w*|errors?|useless|garbage|mess|again|still|doesn'?t work|does not work|(not|isn'?t|won'?t) work\w*|quebr\w*|errad\w*|errou|falh\w*|lixo|in[uú]til|de novo|ainda|n[aã]o funciona)\b/i
15const GLAD = /\b(works?|worked|nice|great|awesome|amazing|love\w*|cool|yeah|yes|perfect|beautiful|brilliant|nailed|finally|good|thanks?|thank you|lol|haha\w*|lindo|[oó]timo|perfeito|massa|boa|valeu|demais|funcionou)\b/i
16
17const count = (text: string, pattern: RegExp) => text.match(pattern)?.length ?? 0
18// Repeats compound: 1 → 1, 2 → 2.8, 3 → 5.2 (the total is capped at 6 anyway)
19const repeats = (n: number) => n ** 1.5
20
21// Each sentence's swears: all of them (amplifiers), and those aimed at something (anger on their own). A
22// sentence that is half swears is an outburst, once, unless the prompt is glad.
23export const swearing = (text: string) => {
24 let all = 0, aimed = 0
25 const glad = GLAD.test(text)
26 for (const sentence of text.split(/[.!?;\n]+/)) {
27 const words = sentence.match(/[\p{L}']+/gu) ?? []
28 const near = (i: number, d: number) => words.slice(Math.max(0, i - d), i + d + 1).join(' ')
29 const swears = words.flatMap((w, i) => (SWEAR.test(w) ? [i] : []))
30 all += swears.length
31 let hits = 0
32 for (const i of swears) {
33 const broken = BROKEN.test(near(i, 3))
34 if (GLAD.test(near(i, 3)) && !broken) continue
35 if (AIMED.test(words[i]!) || AT_YOU.test(near(i, 2)) || broken) hits++
36 }
37 aimed += hits
38 if (hits === 0 && swears.length > 0 && swears.length * 2 >= words.length && !glad) aimed++
39 }
40 return { all, aimed }
41}
42
43// insults.local.txt (gitignored): one regex fragment a line, `#` comments; scored as INSULTS, a bad line skipped.
44export const localInsults = (text: string): RegExp | undefined => {
45 const valid = (line: string) => { try { new RegExp(line); return true } catch { return false } }
46 const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l !== '' && !l.startsWith('#') && valid(l))
47 return lines.length > 0 ? new RegExp(`\\b(${lines.join('|')})\\b`, 'gi') : undefined
48}
49
50export const heat = (prompt: string, extra?: RegExp): number => {
51 const text = prompt.replace(CODE, ' ')
52 const letters = text.match(/\p{L}/gu) ?? []
53 const upper = letters.filter(c => c !== c.toLowerCase()).length / Math.max(1, letters.length)
54 // 0 below 60% caps; 2 at 60%, rising to 3 at all caps (8+ letters)
55 const shouting = letters.length >= 8 && upper >= 0.6 ? 2 + Math.min(1, (upper - 0.6) / 0.4) : 0
56 const swears = swearing(text)
57 const directed = count(text, FRUSTRATED) + count(text, INSULTS) + (extra ? count(text, extra) : 0) + swears.aimed
58 if (directed === 0 && shouting === 0) return 0
59 const longestMarks = Math.max(0, ...(text.match(/[!?]+/g) ?? []).map(run => run.length))
60 const marks = longestMarks >= 5 ? 2 : longestMarks >= 2 ? 1 : 0
61 return Math.min(6, repeats(directed) + shouting + repeats(swears.all) + marks)
62}
63hooks/sky.ts 191 lines1// The breath's timeline and its picture as terminal cells: a soft glow in each cell's background,
2// the nebula's contour lines and the dust as braille dots over it. Ported from ui/breathe.html.
3import type { Run } from '../types'
4
5export const PATTERNS: Record<string, [Kind, number][]> = {
6 '4-6': [['in', 4], ['out', 6]],
7 'box': [['in', 4], ['hold', 4], ['out', 4], ['hold', 4]],
8 '4-7-8': [['in', 4], ['hold', 7], ['out', 8]],
9}
10export const INTRO = 1.6 // seconds of "settle in" before the first breath
11export const GOODBYE = 1.8 // "well done" holds this long, then the picture fades
12export const FADE = 1.4
13
14type Kind = 'in' | 'hold' | 'out'
15type Hue = 'in' | 'full' | 'out' | 'empty'
16type RGB = [number, number, number]
17
18// Each phase its own light, core to edge, blended as one phase turns into the next.
19const HUES: Record<Hue, [RGB, RGB]> = {
20 in: [[168, 228, 255], [84, 112, 255]],
21 full: [[218, 182, 255], [116, 84, 236]],
22 out: [[255, 204, 140], [232, 96, 124]],
23 empty: [[150, 160, 246], [64, 74, 176]],
24}
25const BLEND = 1.6 // seconds a phase takes to reach its colours
26const WORDS: Record<Kind, string> = { in: 'breathe in', hold: 'hold', out: 'breathe out' }
27const SKY: RGB = [4, 5, 11]
28const WHITE: RGB = [255, 255, 255]
29
30const ease = (t: number) => (1 - Math.cos(Math.PI * t)) / 2
31const lerp = (a: RGB, b: RGB, t: number): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
32
33export const phasesOf = (pattern: string) => PATTERNS[pattern] ?? PATTERNS['4-6']!
34export const cycleSeconds = (pattern: string) => phasesOf(pattern).reduce((s, [, d]) => s + d, 0)
35export const cyclesFor = (pattern: string, seconds: number) => Math.max(1, Math.round(seconds / cycleSeconds(pattern)))
36// From the first frame to the last: intro, the breaths, the goodbye and its fade.
37export const runSeconds = (run: Run) => INTRO + run.cycles * cycleSeconds(run.pattern) + GOODBYE + FADE
38
39export type Moment = {
40 label: string; count: string; done: number // done: whole breaths finished
41 b: number; motion: number; progress: number // fullness 0-1, its speed 0-1, the phase's timer 0-1
42 stirred: number // the breath's movement so far, which drives the dust
43 colors: [RGB, RGB]; light: number // fades in at the start, out after the goodbye
44}
45
46// Where the breath is `time` seconds after the run started. Pure: any frame can be drawn on its own.
47export function moment(run: Run, time: number): Moment {
48 const phases = phasesOf(run.pattern), cycleSec = cycleSeconds(run.pattern)
49 const light = Math.min(1, time / FADE, Math.max(0, (runSeconds(run) - time) / FADE))
50 const t = time - INTRO
51 const moved = phases.filter(([k]) => k !== 'hold').length // in and out each move the breath by 1
52 if (t < 0) return { label: 'settle in', count: '', done: 0, b: 0, motion: 0, progress: 0, stirred: 0, colors: HUES.empty, light }
53 const cycle = Math.floor(t / cycleSec)
54 if (cycle >= run.cycles) {
55 const [kind] = phases.at(-1)!, last = kind === 'hold' ? (phases.at(-2)![0] === 'in' ? 'full' : 'empty') : kind
56 return { label: 'well done', count: '', done: run.cycles, b: 0, motion: 0, progress: 0,
57 stirred: run.cycles * moved * 2.5, colors: HUES[last], light }
58 }
59 let into = t - cycle * cycleSec, i = 0, stirred = cycle * moved
60 while (into >= phases[i]![1]) { if (phases[i]![0] !== 'hold') stirred++; into -= phases[i++]![1] }
61 const [kind, dur] = phases[i]!, p = into / dur
62 const before = phases[(i + phases.length - 1) % phases.length]!
63 const hueOf = (k: Kind, prev: Kind): Hue => k === 'hold' ? (prev === 'in' ? 'full' : 'empty') : k
64 const hue = hueOf(kind, before[0])
65 const from = cycle === 0 && i === 0 ? HUES.empty
66 : HUES[hueOf(before[0], phases[(i + phases.length - 2) % phases.length]![0])]
67 const blend = ease(Math.min(1, into / BLEND))
68 const b = kind === 'in' ? ease(p) : kind === 'out' ? 1 - ease(p) : hue === 'full' ? 1 : 0
69 // |db/dt| over 0.4 per second, as the page measured it; its integral is 2.5 per in or out.
70 const motion = kind === 'hold' ? 0 : Math.min(1, (Math.PI / 2) * Math.sin(Math.PI * p) / dur / 0.4)
71 if (kind !== 'hold') stirred += ease(p)
72 return { label: WORDS[kind], count: String(Math.ceil(dur - into)), done: cycle, b, motion, progress: p,
73 stirred: stirred * 2.5, colors: [lerp(from[0], HUES[hue][0], blend), lerp(from[1], HUES[hue][1], blend)], light }
74}
75
76// The nebula: uneven contour lines, each wavy by its own few sines, and dust on slow orbits.
77const RINGS = 15, MOTES = 150, TIMER_RING = 7
78const seed = (n => () => (n = (n * 16807) % 2147483647) / 2147483647)(9301)
79const rings = Array.from({ length: RINGS }, () => ({
80 waves: [1, 2, 3, 5, 7].map((k, i) => ({ k, amp: [0.55, 0.5, 0.35, 0.22, 0.12][i]! * (0.6 + 0.8 * seed()),
81 phase: seed() * Math.PI * 2, speed: (seed() - 0.5) * (0.5 + 0.25 * k) })),
82 tilt: seed() * Math.PI * 2,
83}))
84const motes = Array.from({ length: MOTES }, () => ({
85 angle: seed() * Math.PI * 2, reach: 1.15 + seed() ** 1.6 * 1.6,
86 twinkle: 0.6 + seed() * 2.2, phase: seed() * Math.PI * 2,
87}))
88// Far stars for a pane wider than the nebula: a fixed field over the whole pane, lit only outside the dust.
89const STARS = 900, STAR_AREA = 110 // one star per this many dots
90const stars = Array.from({ length: STARS }, () => ({ x: seed(), y: seed(), twinkle: 0.3 + seed() * 1.4, phase: seed() * Math.PI * 2 }))
91const wobble = (ring: typeof rings[number], a: number, time: number) => {
92 let w = 0
93 for (const v of ring.waves) w += v.amp * Math.sin(v.k * (a + ring.tilt) + v.phase + time * v.speed)
94 return w
95}
96
97// Braille: each cell is 2 dots across and 4 down, so the dot grid is about square on a 1:2 cell.
98const DOT = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]
99const DEFAULT = 0x01000000
100// The terminal shows 1024 colour pairs at once: steps of 6 keep a frame well under that, banded on purpose.
101const pack = (c: RGB, k = 1) =>
102 (Math.min(255, Math.round(c[0] * k / 6) * 6) << 16) | (Math.min(255, Math.round(c[1] * k / 6) * 6) << 8) | Math.min(255, Math.round(c[2] * k / 6) * 6)
103
104// One frame, `columns` x `rows` cells, row-major [codePoint, foreground, background] (RasterProps).
105export function paint(m: Moment, time: number, columns: number, rows: number): Uint32Array {
106 const W = columns * 2, H = rows * 4, cx = W / 2, cy = H / 2
107 const S = Math.min(W, H) / 2 * 1.05 // the page's half-size; the outermost line reaches about 0.93 S
108 const R = S * (0.3 + 0.36 * m.b)
109 // A wide pane (past 1.6:1 in dots, fully at 4:1) stretches the outer lines, glow and dust sideways.
110 const wide = Math.max(0, Math.min(1, (W / H - 1.6) / 2.4))
111 const [core, edge] = m.colors
112 const dots = new Uint8Array(columns * rows), ink = new Float32Array(columns * rows * 3), inkA = new Float32Array(columns * rows)
113 const plot = (x: number, y: number, c: RGB, a: number) => {
114 const xi = Math.floor(x), yi = Math.floor(y)
115 if (xi < 0 || yi < 0 || xi >= W || yi >= H) return
116 const cell = (yi >> 2) * columns + (xi >> 1)
117 dots[cell]! |= DOT[yi & 3]![xi & 1]!
118 if (a > inkA[cell]!) { inkA[cell] = a; ink[cell * 3] = c[0]; ink[cell * 3 + 1] = c[1]; ink[cell * 3 + 2] = c[2] }
119 }
120 // Each line drifts off-centre on its own slow orbit, so the set never settles into neat circles.
121 const line = (j: number) => {
122 const f = j / (RINGS - 1), off = R * (0.03 + 0.06 * f), spin = time * 0.21 * (1 + (j % 3) * 0.4) + j * 1.7
123 return { x: cx + off * Math.cos(spin), y: cy + off * Math.sin(spin * 1.3), r: R * (0.55 + 0.85 * f),
124 amp: (0.045 + 0.1 * f ** 1.2) * (0.7 + 0.9 * m.motion), sx: 1 + 0.45 * wide * f }
125 }
126 const trace = (j: number, c: RGB, a: number, upTo = 1) => {
127 const { x, y, r, amp, sx } = line(j), steps = Math.ceil(r * 2 * Math.PI * 1.5 * upTo * sx)
128 for (let i = 0; i <= steps; i++) {
129 const ang = (i / steps) * upTo * Math.PI * 2 - Math.PI / 2, rr = r * (1 + amp * wobble(rings[j]!, ang, time))
130 plot(x + rr * Math.cos(ang) * sx, y + rr * Math.sin(ang), c, a)
131 }
132 }
133 for (let j = 1; j < RINGS; j += 2) { // every other line: at a few dots per line all 15 run together
134 const f = j / (RINGS - 1)
135 trace(j, lerp(core, edge, f), 0.18 + 0.82 * (1 - f) ** 1.5)
136 }
137 if (m.progress > 0) trace(TIMER_RING, lerp(core, WHITE, 0.25), 1.6, m.progress) // the phase's timer
138 for (const d of motes) { // gathered close when the lungs are full, scattered when empty
139 const angle = d.angle + (0.042 * time + 0.24 * m.stirred) / d.reach
140 const dist = S * (0.62 + (d.reach - 0.55) * (1.15 - 0.55 * m.b))
141 const a = (0.18 + 0.32 * (0.5 + 0.5 * Math.sin(time * d.twinkle + d.phase))) * (0.5 + 0.5 * m.b)
142 plot(cx + dist * Math.cos(angle) * (1 + 0.7 * wide), cy + dist * Math.sin(angle) * 0.92, lerp(core, WHITE, 0.5), a * 1.6)
143 }
144 const far = Math.min(STARS, Math.round(W * H / STAR_AREA))
145 for (let i = 0; i < far; i++) { // fade in past the dust's reach, so a pane the nebula fills shows none
146 const st = stars[i]!, x = st.x * W, y = st.y * H
147 const reach = Math.hypot((x - cx) / (1 + 0.7 * wide), y - cy) / S
148 const a = Math.min(1, Math.max(0, (reach - 1.9) / 0.8)) * (0.35 + 0.3 * Math.sin(time * st.twinkle + st.phase)) * (0.6 + 0.4 * m.b)
149 if (a > 0.05) plot(x, y, lerp(edge, WHITE, 0.6), a)
150 }
151
152 // The light under the lines: the sky, the glow, and the body inside the innermost line, all added.
153 // No vignette: at these levels a step of 6 shows as a ring.
154 const glowR = R * 2.2, glowX = 1 + 0.35 * wide, scale = (c: RGB, k: number): RGB => [c[0] * k, c[1] * k, c[2] * k]
155 const field = (x: number, y: number): RGB => {
156 const d = Math.hypot(x, y), c: RGB = [...SKY], g = Math.hypot(x / glowX, y) / glowR
157 const add = (l: RGB) => { c[0] += l[0]; c[1] += l[1]; c[2] += l[2] }
158 if (g < 1) add(g < 0.35 ? lerp(scale(core, 0.32), scale(edge, 0.14), g / 0.35) : scale(edge, 0.14 * (1 - (g - 0.35) / 0.65)))
159 const inner = R * (1 + (0.05 + 0.05 * m.motion) * wobble(rings[0]!, Math.atan2(y, x), time * 0.6))
160 if (d < inner) {
161 const t = Math.hypot(x + R * 0.15, y + R * 0.2) / (R * 1.05)
162 add(t < 0.6 ? lerp(scale(lerp(core, WHITE, 0.35), 0.5), scale(core, 0.2), t / 0.6)
163 : lerp(scale(core, 0.2), scale(edge, 0.04), Math.min(1, (t - 0.6) / 0.4)))
164 }
165 return c
166 }
167 // A cell with dots is braille over its light; one without is a half block, its two halves lit apart.
168 const out = new Uint32Array(columns * rows * 3)
169 for (let row = 0; row < rows; row++) {
170 for (let col = 0; col < columns; col++) {
171 const cell = row * columns + col, x = col * 2 + 1 - cx
172 const top = pack(field(x, row * 4 + 1 - cy), m.light), bottom = pack(field(x, row * 4 + 3 - cy), m.light)
173 const bits = dots[cell]!, a = inkA[cell]!
174 if (bits) {
175 const bg = field(x, row * 4 + 2 - cy)
176 out[cell * 3] = 0x2800 + bits
177 out[cell * 3 + 1] = pack([bg[0] + ink[cell * 3]! * a, bg[1] + ink[cell * 3 + 1]! * a, bg[2] + ink[cell * 3 + 2]! * a], m.light)
178 out[cell * 3 + 2] = pack(bg, m.light)
179 } else {
180 out[cell * 3] = top === bottom ? 0x20 : 0x2580
181 out[cell * 3 + 1] = top === bottom ? DEFAULT : top
182 out[cell * 3 + 2] = bottom
183 }
184 }
185 }
186 return out
187}
188
189// The engine has Uint8Array.toBase64 (its own examples use it); TypeScript's es2023 lib does not declare it yet.
190export const encode = (cells: Uint32Array) => (new Uint8Array(cells.buffer) as Uint8Array & { toBase64(): string }).toBase64()
191types/index.d.ts 11 lines1// A breath in the pane: its pattern, how many breaths, and when it started (ms since the epoch).
2export type Run = { pattern: string; cycles: number; startedAt: number }
3// What the pane shows under the picture, changed about once a second.
4export type Words = { label: string; count: string; done: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 breathe: { run: Run | null; words: Words }
9 }
10}
11