SLOPSHOPPER

breathe

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

newpanecommandstatuspromptprocess
★ 1v0.3.2MITupdated 2026-10-24asfbr/claude-code-vibe-mods/breathe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · breathe
│ ┃ Breathe ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /breathe │ ┃ ⎿ breathe: Breathe. Esc at an empty prompt closes it. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Breathe
settle in · · ·
README

breathe

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.

The pane mid-breath: a blue glow ringed by drifting contour lines and dust, "breathe in 2" under it

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.
  • Hot prompts: each prompt you type is scored locally, in English and Portuguese: "again?", "still broken", "quantas vezes", insults, ALL CAPS; code in backticks ignored. A swear counts when it is aimed: at Claude ("fuck you", "vai se foder"), at what broke ("the build is fucking broken", "porra, de novo"), or as a bare outburst ("fuck."). Any other swear, and !!!/??, 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.
  • Schedule: off by default. A pane Claude Code opens unasked waits below 144 terminal columns (110 once you have opened it yourself), so on a narrower terminal a scheduled break opens the window instead.

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.

Setup (once per machine)

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" }

Options (/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%).

How it's built

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.

Source 4 files
hooks/register.tsx 233 lines
1import { 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}
233
hooks/anger.ts 63 lines
1// 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}
63
hooks/sky.ts 191 lines
1// 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()
191
types/index.d.ts 11 lines
1// 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