A calm Everforest tree above your Claude Code prompt

A calm Everforest tree above your Claude Code prompt.

/plugin install everforest-zen --marketplace MarDevMint/everforest-zen
Needs Claude Code with mods (2.1.287+). Sounds play on Linux with PipeWire or PulseAudio.
Sounds, icons, the breathe reminder and the bloom threshold are in /config.
Everything lives in hooks/register.tsx: the palette, the tree, the words. Fork it, edit, and run claude --plugin-dir ./everforest-zen to try your changes.
MIT
hooks/register.tsx 553 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4import type { Limits } from '../types'
5
6const tick = atom({ plugin: 'everforest-zen', key: 'tick' } as const, 0)
7const limits = atom({ plugin: 'everforest-zen', key: 'limits' } as const, null)
8const files = atom({ plugin: 'everforest-zen', key: 'files' } as const, [])
9const startedAt = atom({ plugin: 'everforest-zen', key: 'startedAt' } as const, null)
10const agents = atom({ plugin: 'everforest-zen', key: 'agents' } as const, 0)
11const activity = atom({ plugin: 'everforest-zen', key: 'activity' } as const, 0)
12const turnStart = atom({ plugin: 'everforest-zen', key: 'turnStart' } as const, 0)
13const lastBreath = atom({ plugin: 'everforest-zen', key: 'lastBreath' } as const, 0)
14const restUntil = atom({ plugin: 'everforest-zen', key: 'restUntil' } as const, 0)
15const bloomNoted = atom({ plugin: 'everforest-zen', key: 'bloomNoted' } as const, false)
16const context = atom({ plugin: 'everforest-zen', key: 'context' } as const, null)
17
18// settings, from the plugin's userConfig (see plugin.json); set in register
19let NERD = true
20let SOUND = true
21let BLOOM_AT = 80
22let BREATHE_EVERY = 50
23const REST_MS = 60_000
24
25// Everforest dark palette
26const C = {
27 far: '#3f5245',
28 back: '#56705a',
29 deep: '#4a6350',
30 mid: '#7d9a6b',
31 light: '#a7c080',
32 aqua: '#83c092',
33 trunk: '#7a6d5a',
34 bark: '#9a8a70',
35 grass: '#859289',
36 fg: '#d3c6aa',
37 yellow: '#dbbc7f',
38 blue: '#7fbbb3',
39 pink: '#d699b6',
40 moss: '#b5b27a',
41 brown: '#a68b5b',
42 earth: '#8c6a4a',
43 empty: '#4f585e',
44 hint: '#7a8478',
45 firefly: '#dbbc7f',
46 glowLeaf: '#c9d9a0',
47}
48
49const ROWS = 8
50const TW = 27
51const GAP = 3
52
53type Cell = { ch: string; color: string; glow?: boolean }
54type Seg = { text: string; color: string }
55
56const noise = (x: number, y: number, salt = 0) => {
57 let n = Math.imul(x + 11, 73856093) ^ Math.imul(y + 7, 19349663) ^ Math.imul(salt + 3, 83492791)
58 n = Math.imul(n ^ (n >>> 13), 1274126177)
59 return ((n ^ (n >>> 16)) >>> 0) % 1000 / 1000
60}
61
62// 1 by day, 0.4 at night, ramping through dawn (5-8h) and dusk (18-21h)
63const brightness = (h: number): number => {
64 if (h >= 8 && h < 18) return 1
65 if (h >= 18 && h < 21) return 1 - ((h - 18) / 3) * 0.6
66 if (h >= 5 && h < 8) return 0.4 + ((h - 5) / 3) * 0.6
67 return 0.4
68}
69
70// darken a colour toward the Everforest bg, so night stays green, only deeper
71const dim = (hex: string, f: number): string => {
72 if (f >= 1) return hex
73 const bg = [43, 51, 57]
74 const out = [1, 3, 5].map((i, k) => {
75 const c = parseInt(hex.slice(i, i + 2), 16)
76 return Math.round(bg[k]! + (c - bg[k]!) * f).toString(16).padStart(2, '0')
77 })
78 return `#${out.join('')}`
79}
80
81const put = (grid: Cell[][], row: number, col: number, ch: string, color: string, glow = false) => {
82 if (row < 0 || row >= grid.length || col < 0 || col >= TW || ch === ' ') return
83 grid[row]![col] = { ch, color, glow }
84}
85
86// shimmer: a stirred leaf takes the next shade
87const STIR: Record<string, string> = { '@': '&', '#': '@', '%': '#', '&': '%' }
88
89export type Mode = 'idle' | 'work' | 'agents' | 'web' | 'rest'
90
91// the crown's three shades per mode: green at rest, cyan with company, blue on the web
92// cherry blossom, in Everforest's own pink
93const SAKURA: [string, string, string] = ['#e8b8cc', '#d699b6', '#a97a92']
94
95const CROWN: Record<Mode, [string, string, string]> = {
96 idle: ['#a7c080', '#7d9a6b', '#56705a'],
97 work: ['#a7c080', '#7d9a6b', '#56705a'],
98 agents: ['#83c092', '#5f9a84', '#3f6b5e'],
99 web: ['#7fbbb3', '#5b8f8a', '#3e6466'],
100 rest: ['#d8c48a', '#b5a46e', '#8f8158'],
101}
102
103/**
104 * One detailed tree, always full. At rest it sways; while Claude works a band
105 * of light sweeps through the crown; with subagents or on the web, a wave of
106 * cyan or blue flows through the leaves.
107 */
108const tree = (t: number, mode: Mode, hour: number, workSecs: number, blooming: boolean): Cell[][] => {
109 const grid: Cell[][] = Array.from({ length: ROWS }, () =>
110 Array.from({ length: TW }, () => ({ ch: ' ', color: C.grass })),
111 )
112 const light = brightness(hour)
113 const busy = mode !== 'idle'
114 const fill = 0.97
115 const cx = 13
116 const cy = 2.1
117 const rx = 12.2
118 const ry = 2.75
119 // once the context runs high the tree blooms: pink blossom among the green
120 const green = CROWN.idle
121 const tinted = CROWN[mode]
122
123 for (let y = 0; y < 5; y++) {
124 for (let x = 0; x < TW; x++) {
125 const dx = (x - cx) / rx
126 const dy = (y - cy) / ry
127 const d = dx * dx + dy * dy
128 if (d > 1) continue
129 const edge = d > 0.78
130 if (noise(x, y) > fill + (edge ? 0.04 : 0.1)) continue
131
132 // which palette this leaf wears now: a diagonal wave drifting through the crown
133 const inWave =
134 ((mode === 'agents' || mode === 'web') && (((x + y * 2 - t) % 12) + 12) % 12 < 7) ||
135 // time to rest: a slow warm wave, like late afternoon light
136 (mode === 'rest' && (((x - y - Math.floor(t / 2)) % 10) + 10) % 10 < 6)
137 const blossom = blooming && noise(x, y, 11) < 0.7
138 const pal = inWave ? tinted : blossom ? SAKURA : green
139 // while working, a soft band of light passes left to right
140 const lit = mode === 'work' && (((x - t) % 18) + 18) % 18 < 3
141
142 if (edge) {
143 const side = Math.abs(dx) > 0.82
144 const ch = side ? (dx < 0 ? '(' : ')') : dy < 0 ? (noise(x, y, 1) > 0.5 ? '.' : '-') : "'"
145 put(grid, y, x, ch, lit ? C.glowLeaf : pal[0])
146 continue
147 }
148 // light falls from the top left; leaves grow in clumps
149 const clump = noise(Math.floor(x / 3), Math.floor(y / 2), 2)
150 const shade = -0.6 * dx - 0.5 * dy + (clump - 0.5) * 0.9
151 const [ch, color] = shade > -0.05 ? ['@', pal[0]] : shade > -0.5 ? ['#', pal[1]] : ['%', pal[2]]
152 const stirred = (x + y * 5 + t) % (busy ? 3 : 11) === 0
153 put(grid, y, x, stirred ? (STIR[ch] ?? ch) : ch, lit ? C.glowLeaf : color)
154 }
155 }
156
157 // branches, trunk and roots
158 const bark = C.trunk
159 put(grid, 3, cx - 4, '\\', C.bark)
160 put(grid, 3, cx + 4, '/', C.bark)
161 put(grid, 4, cx - 2, '\\', C.bark)
162 put(grid, 4, cx, '|', C.bark)
163 put(grid, 4, cx + 2, '/', C.bark)
164 put(grid, 5, cx - 1, '\\', bark)
165 put(grid, 5, cx, '|', bark)
166 put(grid, 5, cx + 1, '/', bark)
167 put(grid, 6, cx - 1, '(', bark)
168 put(grid, 6, cx, '|', bark)
169 put(grid, 6, cx + 1, ')', bark)
170 '_,/(|)\\,_'.split('').forEach((ch, i) => put(grid, 7, cx - 4 + i, ch, bark))
171
172 // ground
173 for (let x = 0; x < TW; x++) {
174 if (grid[7]![x]!.ch !== ' ') continue
175 const r = noise(x, 7, 5)
176 put(grid, 7, x, r < 0.55 ? '~' : r < 0.78 ? ',' : r < 0.9 ? "'" : '"', r < 0.15 ? C.light : C.grass)
177 }
178
179 // a sprout grows at the roots while Claude works
180 if (busy) {
181 const sx = cx + 6
182 const stage = workSecs < 3 ? 0 : workSecs < 8 ? 1 : workSecs < 15 ? 2 : 3
183 const sprout = [[','], ['i'], ['|', 'Y'], ['|', 'Y', '.']][stage]!
184 sprout.forEach((ch, i) => put(grid, 7 - i, sx, ch, i === 2 ? C.glowLeaf : C.light))
185 }
186
187 // sun or moon, and fireflies once the light goes
188 const isDay = hour >= 6 && hour < 18
189 put(grid, 0, TW - 1, isDay ? '*' : ')', C.yellow, true)
190 if (light < 0.85) {
191 for (let i = 0; i < 4; i++) {
192 if ((t + i * 3) % 5 >= 3) continue
193 const x = (i * 7 + 2 + Math.floor(t / 3) * (i % 2 === 0 ? 1 : -1) + TW * 8) % TW
194 put(grid, 1 + ((i * 3 + Math.floor(t / 4)) % 5), x, '*', C.firefly, true)
195 }
196 }
197
198 for (const row of grid) {
199 for (const cell of row) {
200 if (!cell.glow) cell.color = dim(cell.color, light)
201 }
202 }
203 return grid
204}
205
206// merge neighbouring cells of one colour so a row is a handful of Text nodes
207const runs = (row: Cell[]): Seg[] => {
208 const out: Seg[] = []
209 for (const cell of row) {
210 const last = out[out.length - 1]
211 if (last && last.color === cell.color) last.text += cell.ch
212 else out.push({ text: cell.ch, color: cell.color })
213 }
214 return out
215}
216
217const toLimits = (rates: readonly SessionRateLimit[]): Limits | null => {
218 const five = rates.find((r) => r.kind === 'five_hour')
219 if (!five) return null
220 const week = rates.find((r) => r.kind === 'seven_day')
221 return { session: five.percentUsed, resetsAt: five.resetsAt ?? null, week: week ? week.percentUsed : null }
222}
223
224const left = (resetsAt: string | null, now: number): string => {
225 if (!resetsAt) return ''
226 const mins = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60000))
227 const h = Math.floor(mins / 60)
228 return h > 0 ? `${h}h ${mins % 60}m` : `${mins}m`
229}
230
231// green while there is plenty, turning to moss and then earth as it runs low
232const tint = (pct: number) => (pct >= 90 ? C.earth : pct >= 75 ? C.brown : pct >= 50 ? C.moss : C.light)
233
234// label colours fade from leaf green down to bark
235const FADE = ['#a7c080', '#93ab76', '#7d9a6b', '#8f8a68', '#9a8a70'] as const
236
237const ICON = { files: '\uf15b', context: '\uf2db', session: '\uf017', time: '\uf253', agents: '\uf1bb' }
238
239const label = (name: keyof typeof ICON, color: string): Seg => ({
240 text: `${NERD ? `${ICON[name]} ` : ''}${name.padEnd(8)}`,
241 color,
242})
243
244const bar = (pct: number, width: number): Seg[] => {
245 const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * width)
246 return [
247 { text: '━'.repeat(filled), color: tint(pct) },
248 { text: '─'.repeat(width - filled), color: C.empty },
249 ]
250}
251
252const baseName = (path: string) => path.split('/').pop() ?? path
253
254const clip = (segs: Seg[], width: number): Seg[] => {
255 const out: Seg[] = []
256 let room = width
257 for (const s of segs) {
258 if (room <= 0) break
259 out.push(s.text.length > room ? { ...s, text: s.text.slice(0, room) } : s)
260 room -= s.text.length
261 }
262 return out
263}
264
265type Facts = {
266 lim: Limits | null
267 recent: string[]
268 ctx: number | null
269 upMins: number | null
270 running: number
271 now: number
272}
273
274const span = (mins: number) => {
275 const h = Math.floor(mins / 60)
276 return h > 0 ? `${h}h ${mins % 60}m` : `${mins}m`
277}
278
279const info = (f: Facts, width: number): Seg[][] => {
280 const barW = Math.max(4, Math.min(8, width - 26))
281 const rows: Seg[][] = []
282
283 const shown = f.recent.slice(0, 2).map(baseName)
284 const more = f.recent.length - shown.length
285 // a row shows only once it has something to say
286 if (shown.length) {
287 rows.push([
288 label('files', FADE[0]),
289 { text: shown.join(' · '), color: C.fg },
290 ...(more > 0 ? [{ text: ` +${more}`, color: C.hint }] : []),
291 ])
292 }
293
294 if (f.ctx !== null) {
295 rows.push([label('context', FADE[1]), ...bar(f.ctx, barW), { text: ` ${Math.round(f.ctx)}%`, color: tint(f.ctx) }])
296 }
297
298 if (f.lim) {
299 rows.push([
300 label('session', FADE[2]),
301 ...bar(f.lim.session, barW),
302 { text: ` ${Math.round(f.lim.session)}%`, color: tint(f.lim.session) },
303 { text: f.lim.resetsAt ? ` ${left(f.lim.resetsAt, f.now)}` : '', color: C.hint },
304 ])
305 }
306
307 if (f.upMins !== null) rows.push([label('time', FADE[3]), { text: span(f.upMins), color: C.fg }])
308
309 if (f.running > 0) rows.push([label('agents', FADE[4]), { text: String(f.running), color: C.fg }])
310 return rows.map((r) => clip(r, width))
311}
312
313const WORDS = [
314 'Breathing',
315 'Rooting',
316 'Sprouting',
317 'Blooming',
318 'Meditating',
319 'Growing',
320 'Listening',
321 'Resting',
322 'Flowing',
323 'Unfolding',
324 'Settling',
325 'Drifting',
326]
327
328// bubbles either side of the words: a slow wave swells them in turn, mirrored,
329// so the two sides rise and settle together
330const BUBBLE = [' ', '.', '°', 'o', 'O'] as const
331const BUBBLE_SHADE = ['#4a6350', '#56705a', '#6b8a62', '#7d9a6b', '#a7c080'] as const
332const BUBBLES = 7
333
334const bubbles = (t: number): { ch: string; color: string }[] =>
335 Array.from({ length: BUBBLES }, (_, i) => {
336 // i = 0 sits by the words; the swell travels outward
337 const v = (Math.sin(t * 0.5 - i * 0.7) + 1) / 2
338 const level = Math.min(BUBBLE.length - 1, Math.floor(v * BUBBLE.length))
339 return { ch: BUBBLE[level]!, color: BUBBLE_SHADE[level]! }
340 })
341
342const hash = (s: string) => [...s].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 9973, 7)
343
344export const register: Register = (on, options) => {
345 NERD = options.nerdIcons !== false
346 SOUND = options.sound !== false
347 BLOOM_AT = typeof options.bloomAt === 'number' ? options.bloomAt : 80
348 BREATHE_EVERY = typeof options.breatheEvery === 'number' ? options.breatheEvery : 50
349
350 on('session.start', async ($, e, next) => {
351 $.clock.every(700, () => {
352 void update($, tick, (n) => n + 1)
353 })
354 // every so often, a gentle nudge to breathe: a toast, the wind, warm light in the crown
355 $.clock.every(60_000, async () => {
356 if (BREATHE_EVERY <= 0) return
357 const now = await $.clock.now()
358 const last = await read($, lastBreath)
359 if (last === 0) {
360 await update($, lastBreath, () => now)
361 return
362 }
363 if (now - last < BREATHE_EVERY * 60_000) return
364 await update($, lastBreath, () => now)
365 await update($, restUntil, () => now + REST_MS)
366 $.ui.toast('🌿 take a breath · stretch a little', { timeoutMs: 12_000 })
367 if (SOUND) {
368 const file = `${$.plugin.root}/sounds/wind.wav`
369 void $.process
370 .run(['pw-play', '--volume', '0.7', file])
371 .then((r) => (r.exitCode === 0 ? r : $.process.run(['paplay', file])))
372 .catch(() => undefined)
373 }
374 })
375 $.clock.every(2000, async () => {
376 const list = await $.agent.list()
377 const running = list.filter((a) => a.status === 'running' || a.status === 'pending').length
378 await update($, agents, () => running)
379 })
380 const usage = await $.session.usage()
381 await update($, limits, () => toLimits(usage.rateLimits))
382 await update($, context, () => usage.context.percent ?? null)
383 await update($, startedAt, () => usage.startedAt)
384 return next(e)
385 })
386
387 on('session.measure', async ($, e, next) => {
388 if (e.changed.includes('rateLimits')) await update($, limits, () => toLimits(e.rateLimits))
389 if (e.changed.includes('context')) {
390 const pct = e.context.percent ?? null
391 await update($, context, () => pct)
392 // one quiet word when the tree first blooms; again only after it settles
393 const noted = await read($, bloomNoted)
394 if (pct !== null && pct >= BLOOM_AT && !noted) {
395 await update($, bloomNoted, () => true)
396 $.ui.toast('🌸 the tree is in bloom · /compact when you are ready', { timeoutMs: 10_000 })
397 } else if (pct !== null && pct < BLOOM_AT && noted) {
398 await update($, bloomNoted, () => false)
399 }
400 }
401 return next(e)
402 })
403
404 // web tools in flight: the tree turns blue while any runs
405 on('tool.call', { tool: 'WebSearch' }, async ($, e, next) => {
406 await update($, activity, (n) => n + 1)
407 try {
408 return await next(e)
409 } finally {
410 await update($, activity, (n) => Math.max(0, n - 1))
411 }
412 })
413
414 on('tool.call', { tool: 'WebFetch' }, async ($, e, next) => {
415 await update($, activity, (n) => n + 1)
416 try {
417 return await next(e)
418 } finally {
419 await update($, activity, (n) => Math.max(0, n - 1))
420 }
421 })
422
423 on('tool.call', async ($, e, next) => {
424 // bookkeeping only: it must never get in the way of the call
425 try {
426 if (e.tool === 'Edit' || e.tool === 'Write') {
427 const path = e.file_path
428 await update($, files, (list) => [path, ...list.filter((p) => p !== path)].slice(0, 20))
429 }
430 } catch {}
431 return next(e)
432 })
433
434 // Claude is waiting on you: a permission prompt or a dialog. One soft call of a bird.
435 on('classic.Notification', async ($, e, next) => {
436 if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
437 if (SOUND) {
438 const file = `${$.plugin.root}/sounds/call.wav`
439 void $.process
440 .run(['pw-play', '--volume', '0.7', file])
441 .then((r) => (r.exitCode === 0 ? r : $.process.run(['paplay', file])))
442 .catch(() => undefined)
443 }
444 }
445 return next(e)
446 })
447
448 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
449 if (SOUND) {
450 const file = `${$.plugin.root}/sounds/call.wav`
451 void $.process
452 .run(['pw-play', '--volume', '0.7', file])
453 .then((r) => (r.exitCode === 0 ? r : $.process.run(['paplay', file])))
454 .catch(() => undefined)
455 }
456 return next(e)
457 })
458
459 // a soft gust of wind when a turn ends. The engine's own player is
460 // macOS-only, so on Linux PipeWire plays the file; elsewhere it stays quiet.
461 on('turn.complete', async ($, e, next) => {
462 if (SOUND && e.agentId === undefined) {
463 const file = `${$.plugin.root}/sounds/wind.wav`
464 void $.process
465 .run(['pw-play', '--volume', '0.7', file])
466 .then((r) => (r.exitCode === 0 ? r : $.process.run(['paplay', file])))
467 .catch(() => undefined)
468 }
469 return next(e)
470 })
471
472 on('prompt.submit', async ($, e, next) => {
473 const now = await $.clock.now()
474 await update($, turnStart, () => now)
475 return next(e)
476 })
477
478 // the spinner line drawn in green, the words floating between two rows of bubbles
479 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
480 const { Box, Text } = $.ui.resolve(e)
481 const t = await read($, tick)
482 const began = await read($, turnStart)
483 const secs = began ? Math.max(0, Math.round(((await $.clock.now()) - began) / 1000)) : 0
484 const word = e.props.message ?? WORDS[hash(e.props.word) % WORDS.length] ?? WORDS[0]!
485 const side = bubbles(t)
486 const left = [...side].reverse()
487 return (
488 <Box>
489 {left.map((b, i) => (
490 <Text key={`l${i}`} color={b.color}>
491 {b.ch}
492 </Text>
493 ))}
494 <Text color={C.mid}> {word} </Text>
495 {side.map((b, i) => (
496 <Text key={`r${i}`} color={b.color}>
497 {b.ch}
498 </Text>
499 ))}
500 <Text color={C.hint}> ({secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`})</Text>
501 </Box>
502 )
503 })
504
505 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
506 if (e.props.hasSurvey) return next(e)
507
508 const t = await read($, tick)
509 const now = await $.clock.now()
510 const lim = await read($, limits)
511 const began = await read($, startedAt)
512 const facts: Facts = {
513 lim,
514 recent: await read($, files),
515 ctx: await read($, context),
516 running: await read($, agents),
517 upMins: began === null ? null : Math.max(0, Math.round((now - began) / 60000)),
518 now,
519 }
520 const clock = new Date(now)
521 const web = await read($, activity)
522 const resting = now < (await read($, restUntil))
523 const mode: Mode = resting ? 'rest' : web > 0 ? 'web' : facts.running > 0 ? 'agents' : e.props.isWorking ? 'work' : 'idle'
524 const began2 = await read($, turnStart)
525 const workSecs = began2 ? (now - began2) / 1000 : 0
526 const grid = tree(t, mode, clock.getHours() + clock.getMinutes() / 60, workSecs, (facts.ctx ?? 0) >= BLOOM_AT)
527 const infoWidth = Math.max(0, Math.min(e.props.bodyColumns, 100) - TW - GAP)
528 const side = info(facts, infoWidth)
529 const top = Math.max(0, Math.floor((ROWS - side.length) / 2))
530 const { Box, Text } = $.ui.resolve(e)
531
532 return (
533 <Box flexDirection="column">
534 {grid.map((row, y) => (
535 <Box key={`r${y}`}>
536 {runs(row).map((seg, i) => (
537 <Text key={`t${i}`} color={seg.color}>
538 {seg.text}
539 </Text>
540 ))}
541 {infoWidth > 0 && side[y - top] ? <Text>{' '.repeat(GAP)}</Text> : null}
542 {(side[y - top] ?? []).map((seg, i) => (
543 <Text key={`i${i}`} color={seg.color}>
544 {seg.text}
545 </Text>
546 ))}
547 </Box>
548 ))}
549 </Box>
550 )
551 })
552}
553types/index.d.ts 20 lines1export type Limits = { session: number; resetsAt: string | null; week: number | null }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'everforest-zen': {
6 tick: number
7 limits: Limits | null
8 files: string[]
9 startedAt: number | null
10 context: number | null
11 agents: number
12 activity: number
13 turnStart: number
14 lastBreath: number
15 restUntil: number
16 bloomNoted: boolean
17 }
18 }
19}
20