A tall campfire pane in a deep forest under the stars: flames lick and sparks crackle up, flowers ring the clearing, forested ridges rise behind, and now and…


A tall campfire pane in a deep forest under the stars. Flames lick upward and sparks crackle off the logs, flowers ring the clearing, forested ridges rise behind, and now and then a fox trots by or an owl perches and swivels its head. While Claude works the fire roars up; when the turn ends it settles back down.
This is a mod for Claude Code: a decorative panel drawn as a hand-written SVG animation. It changes between an idle look and a working look so you have something calm to watch while Claude runs a long task.
claude plugin marketplace add Gnosis333/mod_gallery
claude plugin install campfire@mod-gallery --scope user
Then run /reload-plugins or start a new session. The panel opens only when you run /fire, so it costs nothing until you ask for it.
Labels are English by default. For Korean, run echo '{"language":"ko"}' | claude plugin configure campfire@mod-gallery --values-stdin and restart. Use en to switch back.
It only draws an animated SVG pane and one slash command (/fire), and it reads Claude's idle or working state to switch the scene. It makes no network requests, reads or writes no files, runs no processes, and sends no data anywhere.
Part of mod_gallery. MIT licensed.
hooks/register.tsx 640 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Camp } from '../types'
5
6const PANE = 'campfire'
7const STRINGS = {
8 en: {
9 title: 'Campfire',
10 alt: 'A campfire in a dense forest clearing on a starry night. Flames lick, sparks crackle up, flowers ring the clearing, and now and then a fox trots by or an owl perches and swivels its head.',
11 working: (clock: string, tools: number) => `Working ${clock} · tools ${tools}`,
12 idle: 'Idle',
13 opened: 'Opened the campfire pane.',
14 note: 'The animation shows in the desktop app.',
15 },
16 ko: {
17 title: '모닥불',
18 alt: '별이 가득한 밤, 빽빽한 숲속 공터의 모닥불. 불꽃이 일렁이고 불티가 탁탁 튀어 오르며, 꽃들이 둘레에 피어 있고, 가끔 여우가 지나가거나 올빼미가 앉아 고개를 돌립니다.',
19 working: (clock: string, tools: number) => `작업 중 ${clock} · 도구 ${tools}회`,
20 idle: '쉬는 중',
21 opened: '모닥불을 열었습니다.',
22 note: '그림 움직임은 데스크톱 앱에서 보입니다.',
23 },
24}
25
26type Words = (typeof STRINGS)['en']
27
28// ---scene-start
29type Fire = { working: boolean; startedAt: number; endedAt: number }
30type Key = [number, number, number] // seconds into the loop, x, y
31
32const W = 400
33const H = 1200
34const FX = 200 // the fire
35const FY = 968
36const RISE = 2.5 // seconds for the fire to build up
37const FALL = 4 // and to die back
38
39const n = (v: number) => String(Math.round(v * 10) / 10)
40const r3 = (v: number) => String(Math.round(v * 1000) / 1000)
41const EASE = '.45 0 .55 1'
42const rnd = (s: number) => {
43 const v = Math.sin(s * 127.1 + 311.7) * 43758.5453
44
45 return v - Math.floor(v)
46}
47
48// Looping motion shifts back by the time already elapsed, so redrawing the picture never makes it jump.
49// Tags marked data-once (the fire building up or dying back) are timed from now and left alone.
50const retime = (svg: string, elapsed: number) =>
51 svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
52 if (tag.includes('data-once')) {
53 return tag
54 }
55 const found = / begin="(-?[\d.]+)s"/.exec(tag)
56 const shifted = ` begin="${n((found ? parseFloat(found[1] ?? '0') : 0) - elapsed)}s"`
57
58 return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
59 })
60
61const track = (keys: Key[], period: number, begin: number) =>
62 `<animateTransform attributeName="transform" type="translate" values="${keys.map(([, x, y]) => `${n(x)} ${n(y)}`).join(';')}" keyTimes="${keys.map(([t]) => r3(t / period)).join(';')}" dur="${period}s" begin="${begin}s" repeatCount="indefinite"/>`
63
64// ---- the night sky ---------------------------------------------------------------------------------------
65const STARS = Array.from({ length: 70 }, (_, k) => {
66 const x = rnd(k) * 400
67 const y = rnd(k + 100) * 560
68 const r = 0.5 + rnd(k + 200) * 1.2
69 const tw = k % 3 === 0 ? `<animate attributeName="opacity" values=".9;.25;.9" dur="${n(2 + rnd(k + 300) * 4)}s" begin="${n(-rnd(k + 400) * 5)}s" repeatCount="indefinite"/>` : ''
70
71 return `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="#fff8e0" opacity=".85">${tw}</circle>`
72}).join('')
73
74const SKY =
75 `<rect x="0" y="0" width="${W}" height="${H}" fill="url(#sky)"/>` +
76 // the milky way, a soft band across the sky
77 '<path d="M-40 120 Q160 220 440 80 L440 150 Q170 300 -40 200Z" fill="#b8c8ff" opacity=".03"/>' +
78 STARS +
79 '<circle cx="316" cy="112" r="44" fill="#fff6d8" opacity=".08"/><circle cx="316" cy="112" r="18" fill="#fff4cc"/><circle cx="324" cy="106" r="16" fill="#0e1430"/>' +
80 // a shooting star now and then
81 '<path d="M0 0 L40 -16" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity="0"><animateTransform attributeName="transform" type="translate" values="300 60;180 110;180 110" keyTimes="0;.05;1" dur="31s" begin="-9s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;1;0;0" keyTimes="0;.01;.05;1" dur="31s" begin="-9s" repeatCount="indefinite"/></path>'
82
83// ---- ridges covered in forest, and the dense woods round the clearing ---------------------------------------
84// a ridge whose skyline is a row of treetops
85const ridge = (base: number, amp: number, phase: number, color: string, k: number, spikes = 9) => {
86 let d = `M-10 ${H}`
87 for (let x = -10; x <= 410; x += 9) {
88 const y = base + Math.sin(x / 70 + phase) * amp + Math.sin(x / 23 + phase * 2) * amp * 0.25
89 const tip = spikes * (0.6 + rnd(x + k) * 0.8)
90 d += ` L${n(x)} ${n(y)} L${n(x + 4.5)} ${n(y - tip)}`
91 }
92
93 return `<path d="${d} L410 ${H}Z" fill="${color}"/>`
94}
95
96const RIDGES = ridge(470, 40, 0.4, '#1d2b48', 1, 7) + ridge(560, 34, 2.1, '#16233c', 2, 9) + ridge(650, 28, 4.2, '#111c30', 3, 11)
97
98const pine = (x: number, base: number, ht: number, color: string) => {
99 const w = ht * 0.36
100 let d = `M${n(x)} ${n(base - ht)}`
101 // a ragged silhouette: tiers stepping out on each side
102 for (let i = 1; i <= 5; i++) {
103 const y = base - ht + (ht * i) / 5
104 const hw = (w * i) / 5
105 d += ` L${n(x + hw)} ${n(y)} L${n(x + hw * 0.55)} ${n(y - 2)}`
106 }
107 d += ` L${n(x - w * 0.55)} ${n(base - 2)}`
108 for (let i = 5; i >= 1; i--) {
109 const y = base - ht + (ht * i) / 5
110 const hw = (w * i) / 5
111 d += ` L${n(x - hw)} ${n(y)} L${n(x - hw * 0.55)} ${n(y - ht / 5 + 2)}`
112 }
113
114 return `<path d="${d}Z" fill="${color}"/><rect x="${n(x - 2)}" y="${n(base - 3)}" width="4" height="10" fill="${color}"/>`
115}
116
117const FOREST =
118 Array.from({ length: 30 }, (_, k) => pine(k * 14 - 6 + rnd(k + 500) * 8, 790 + rnd(k + 510) * 20, 110 + rnd(k + 520) * 70, '#0d1828')).join('') +
119 Array.from({ length: 22 }, (_, k) => pine(k * 19 - 4 + rnd(k + 600) * 10, 836 + rnd(k + 610) * 16, 120 + rnd(k + 620) * 80, '#0a1220')).join('')
120
121// the big trees framing the scene, and the branch the owl sits on
122const FRAME_TREES =
123 pine(8, 960, 360, '#080e1a') +
124 pine(394, 990, 420, '#080e1a') +
125 '<path d="M392 682 Q350 676 296 688" stroke="#1a120c" stroke-width="7" stroke-linecap="round" fill="none"/><path d="M330 684 L318 672 M312 687 L300 680" stroke="#1a120c" stroke-width="3" stroke-linecap="round"/>' +
126 // firelight catching their trunks
127 '<path d="M24 900 V1010 M384 920 V1040" stroke="#ff9a4a" stroke-width="2" opacity=".25"/>'
128
129// ---- the clearing: ground, a tent, a log to sit on, and the flowers around it -----------------------------
130const GROUND =
131 '<path d="M-10 860 Q100 846 200 852 T410 858 V1200 H-10Z" fill="#1a140f"/>' +
132 '<ellipse cx="200" cy="990" rx="210" ry="90" fill="#2a1e14"/>' +
133 // the tent, its open flap glowing faintly
134 '<g transform="translate(78 902)"><path d="M-46 0 L0 -64 L46 0Z" fill="#c8963a"/><path d="M0 -64 L46 0 L20 0Z" fill="#a8762a"/><path d="M-12 0 L0 -36 L12 0Z" fill="#3a2410"/><path d="M-6 0 L0 -20 L6 0Z" fill="#ffb060" opacity=".35"/>' +
135 '<path d="M0 -64 L-4 -72 M-46 0 L-56 6 M46 0 L56 6" stroke="#5a4020" stroke-width="1.6"/></g>'
136
137// six logs to sit on, set in a ring round the fire; seen from above at a slant, the ones at the sides
138// point toward us and look short
139const LOG_RING = [30, 90, 150, 210, 270, 330]
140 .map(deg => {
141 const a = (deg * Math.PI) / 180
142 const x = FX + Math.cos(a) * 116
143 const y = FY + 10 + Math.sin(a) * 50
144 const vx = -Math.sin(a)
145 const vy = Math.cos(a) * 0.43
146 const len = 70 * Math.hypot(vx, vy)
147 const rot = (Math.atan2(vy, vx) * 180) / Math.PI
148
149 return (
150 `<g transform="translate(${n(x)} ${n(y)}) rotate(${n(rot)})"><ellipse cx="3" cy="7" rx="${n(len / 2 + 6)}" ry="5" fill="#000" opacity=".25"/>` +
151 `<rect x="${n(-len / 2)}" y="-8" width="${n(len)}" height="16" rx="8" fill="#5a3c22"/><path d="M${n(-len / 2 + 6)} -3 H${n(len / 2 - 8)} M${n(-len / 2 + 10)} 3 H${n(len / 2 - 14)}" stroke="#4a2e18" stroke-width="1.4"/>` +
152 `<ellipse cx="${n(len / 2)}" cy="0" rx="4.4" ry="8" fill="#b88a5a"/><ellipse cx="${n(len / 2)}" cy="0" rx="2" ry="4.4" fill="none" stroke="#8a6040"/>` +
153 `<path d="M${n(-len / 2 + 4)} -7 H${n(len / 2 - 4)}" stroke="#ff9a4a" stroke-width="1.6" opacity=".3"/></g>`
154 )
155 })
156 .join('')
157
158const sway = (dur: number, k: number) =>
159 `<animateTransform attributeName="transform" type="rotate" values="-4;4;-4" calcMode="spline" keyTimes="0;.5;1" keySplines="${EASE};${EASE}" dur="${dur}s" begin="${n(-k)}s" repeatCount="indefinite"/>`
160
161// flowers by kind, each drawn at its stem's foot, standing ht tall
162const FLOWERS: Record<string, (ht: number) => string> = {
163 daisy: ht =>
164 `<path d="M0 0 V${-ht}" stroke="#3a6a2a" stroke-width="1.6"/><g transform="translate(0 ${-ht})">` +
165 Array.from({ length: 8 }, (_, i) => `<ellipse cx="0" cy="-5" rx="1.8" ry="4" fill="#f4f0e6" transform="rotate(${i * 45})"/>`).join('') +
166 '<circle r="2.6" fill="#f2c13a"/></g>',
167 tulip: ht =>
168 `<path d="M0 0 V${-ht}" stroke="#3a6a2a" stroke-width="2"/><path d="M0 -8 Q-8 -14 -6 -22" stroke="#3a6a2a" stroke-width="3" fill="none"/>` +
169 `<path d="M-6 ${-ht} Q-7 ${-ht - 12} -3 ${-ht - 14} L0 ${-ht - 9} L3 ${-ht - 14} Q7 ${-ht - 12} 6 ${-ht} Q0 ${-ht + 5} -6 ${-ht}Z" fill="#e8405a"/>`,
170 poppy: ht =>
171 `<path d="M0 0 Q2 ${-ht / 2} 0 ${-ht}" stroke="#4a6a2a" stroke-width="1.4" fill="none"/><g transform="translate(0 ${-ht})"><circle r="7" fill="#e83a2a"/><circle cx="-2" cy="-2" r="4" fill="#ff5a3a" opacity=".7"/><circle r="2.2" fill="#1a1010"/></g>`,
172 bluebell: ht =>
173 `<path d="M0 0 Q4 ${-ht * 0.7} ${-6} ${-ht}" stroke="#3a6a2a" stroke-width="1.4" fill="none"/>` +
174 [0.45, 0.65, 0.85].map((f, i) => `<path d="M${n(2 - f * 7)} ${n(-ht * f)} q-3 2 -3 7 l2 -1 l1 2 l1 -2 l2 1 q0 -5 -3 -7Z" fill="#5a6ae8" transform="rotate(${i * 8 - 8} ${n(2 - f * 7)} ${n(-ht * f)})"/>`).join(''),
175 lavender: ht =>
176 `<path d="M0 0 V${-ht}" stroke="#4a6a3a" stroke-width="1.4"/>` + Array.from({ length: 6 }, (_, i) => `<ellipse cx="${i % 2 ? 1.6 : -1.6}" cy="${-ht + i * 3.4}" rx="2" ry="2.6" fill="#9a6ad8"/>`).join(''),
177 forgetmenot: ht =>
178 `<path d="M0 0 V${-ht}" stroke="#3a6a2a" stroke-width="1.2"/>` +
179 [[-4, -ht], [3, -ht - 3], [1, -ht + 5]].map(([x, y]) => `<g transform="translate(${x} ${y})">${[0, 72, 144, 216, 288].map(a => `<circle cx="0" cy="-2" r="1.6" fill="#7ab8ff" transform="rotate(${a})"/>`).join('')}<circle r="1" fill="#f2e05a"/></g>`).join(''),
180}
181
182const BEDS: [number, number, string, number][] = [
183 [30, 1086, 'daisy', 4],
184 [64, 1150, 'tulip', 3],
185 [118, 1110, 'lavender', 4],
186 [150, 1176, 'poppy', 3],
187 [254, 1170, 'bluebell', 3],
188 [300, 1120, 'daisy', 3],
189 [350, 1160, 'forgetmenot', 3],
190 [374, 1090, 'tulip', 3],
191 [36, 960, 'forgetmenot', 3],
192 [350, 980, 'poppy', 3],
193 [150, 900, 'bluebell', 3],
194 [262, 896, 'lavender', 3],
195 [206, 1120, 'daisy', 2],
196]
197
198const FLOWERBEDS = BEDS.map(([x, y, kind, count], b) =>
199 Array.from({ length: count }, (_, i) => {
200 const dx = (i - (count - 1) / 2) * 10 + rnd(b * 10 + i) * 6
201 const ht = 18 + rnd(b * 10 + i + 50) * 14
202
203 return `<g transform="translate(${n(x + dx)} ${n(y + rnd(b * 10 + i + 90) * 8)})"><g>${sway(3 + rnd(b + i) * 2, b + i)}${FLOWERS[kind]!(ht)}</g></g>`
204 }).join(''),
205).join('')
206
207// ---- the fire ----------------------------------------------------------------------------------------------
208// the ring of stones: the far half behind the logs and flames, the near half in front of them
209const stones = (near: boolean) =>
210 Array.from({ length: 11 }, (_, k) => (k / 11) * Math.PI * 2)
211 .map((a, k) => [a, k] as const)
212 .filter(([a]) => Math.sin(a) > 0 === near)
213 .map(([a, k]) => {
214 const x = FX + Math.cos(a) * 44
215 const y = FY + 6 + Math.sin(a) * 15
216
217 return `<ellipse cx="${n(x)}" cy="${n(y)}" rx="${n(9 + rnd(k + 700) * 4)}" ry="${n(6 + rnd(k + 710) * 2)}" fill="${k % 2 ? '#4a4440' : '#5a524c'}"/>` + `<ellipse cx="${n(x - 2)}" cy="${n(y - 3)}" rx="5" ry="2" fill="#ff9a4a" opacity=".3"/>`
218 })
219 .join('')
220
221// crossed logs, their cracks glowing with embers that breathe
222const LOGS =
223 `<g transform="translate(${FX} ${FY})">` +
224 '<g transform="rotate(-14)"><rect x="-38" y="-6" width="76" height="12" rx="6" fill="#3a2414"/><ellipse cx="38" cy="0" rx="4" ry="6" fill="#8a5a30"/></g>' +
225 '<g transform="rotate(16)"><rect x="-38" y="-6" width="76" height="12" rx="6" fill="#4a2c18"/><ellipse cx="-38" cy="0" rx="4" ry="6" fill="#8a5a30"/></g>' +
226 [
227 [-20, -2],
228 [-6, 3],
229 [10, -1],
230 [22, 2],
231 ]
232 .map(([x, y], k) => `<ellipse cx="${x}" cy="${y}" rx="7" ry="2.4" fill="#ff6a1a"><animate attributeName="opacity" values=".5;1;.6;.9;.5" dur="${n(1.6 + k * 0.4)}s" repeatCount="indefinite"/><animate attributeName="fill" values="#ff6a1a;#ffb03a;#ff6a1a" dur="${n(2.2 + k * 0.3)}s" repeatCount="indefinite"/></ellipse>`)
233 .join('') +
234 '</g>'
235
236// one tongue of flame, its tip licking side to side and leaping up and down
237const tongue = (x: number, w: number, ht: number, color: string, dur: number, k: number) => {
238 const shape = (tip: number, hh: number) =>
239 `M${n(x - w)} 0 C${n(x - w)} ${n(-hh * 0.45)} ${n(x - w * 0.15 + tip * 0.3)} ${n(-hh * 0.6)} ${n(x + tip)} ${n(-hh)} C${n(x + w * 0.15 + tip * 0.3)} ${n(-hh * 0.6)} ${n(x + w)} ${n(-hh * 0.45)} ${n(x + w)} 0 Q${n(x)} ${n(w * 0.3)} ${n(x - w)} 0Z`
240 const frames = [0, 1, 2, 3, 0].map(i => shape((rnd(k * 7 + i) - 0.5) * w * 1.4, ht * (0.78 + rnd(k * 7 + i + 3) * 0.36)))
241 frames[4] = frames[0]!
242
243 return `<path d="${frames[0]}" fill="${color}"><animate attributeName="d" values="${frames.join(';')}" dur="${n(dur)}s" begin="${n(-rnd(k + 800) * dur)}s" repeatCount="indefinite"/></path>`
244}
245
246const FLAMES =
247 // the glow it throws round the clearing, breathing unevenly
248 `<circle r="190" fill="url(#glow)"><animate attributeName="opacity" values=".85;1;.8;.95;.75;.9;.85" dur="2.6s" repeatCount="indefinite"/></circle>` +
249 '<ellipse cx="0" cy="8" rx="120" ry="30" fill="#ff8a3a" opacity=".16"><animate attributeName="opacity" values=".12;.2;.14;.18;.12" dur="1.9s" repeatCount="indefinite"/></ellipse>' +
250 // red at the edges, then orange, yellow, and a white heart
251 [-16, 0, 15].map((x, k) => tongue(x, 18, 86 - Math.abs(x) * 1.2, '#e8401a', 0.9 + k * 0.17, k)).join('') +
252 [-11, 2, 12].map((x, k) => tongue(x, 14, 70 - Math.abs(x), '#ff7a1e', 0.75 + k * 0.13, k + 10)).join('') +
253 [-6, 5].map((x, k) => tongue(x, 10, 50, '#ffb52e', 0.6 + k * 0.11, k + 20)).join('') +
254 tongue(0, 7, 30, '#ffe9a0', 0.5, 30) +
255 tongue(0, 4, 16, '#fffbe8', 0.42, 31)
256
257// sparks: a steady stream drifting up, and every so often a crackle that throws a handful out at once
258const spark = (k: number, burst: number | null, g = 0) => {
259 const dur = burst === null ? 1.8 + rnd(k + 900) * 2.2 : 0.9 + rnd(k + 900) * 0.7
260 const dx = (rnd(k + 910) - 0.5) * (burst === null ? 70 : 150)
261 const up = burst === null ? 160 + rnd(k + 920) * 160 : 70 + rnd(k + 930) * 90
262 const sx = (rnd(k + 940) - 0.5) * 30
263 const path = `M${n(sx)} -20 C${n(sx + dx * 0.2)} ${n(-up * 0.4)} ${n(sx + dx * 1.3)} ${n(-up * 0.6)} ${n(sx + dx)} ${n(-up)}`
264 const r = burst === null ? 0.9 + rnd(k + 950) * 1.1 : 1.2 + rnd(k + 950) * 0.8
265 const timing = burst === null ? `dur="${n(dur)}s" begin="${n(-rnd(k + 960) * dur)}s" repeatCount="indefinite"` : `dur="${n(burst)}s" begin="${n(-rnd(k) * 0.12 - g * 1.3)}s" repeatCount="indefinite"`
266 const keys = burst === null ? '' : ` keyPoints="0;1;1" keyTimes="0;${r3(dur / burst)};1" calcMode="linear"`
267 const fade = burst === null ? 'values="1;1;0" keyTimes="0;.6;1"' : `values="1;0;0" keyTimes="0;${r3(dur / burst)};1"`
268
269 return `<circle r="${n(r)}" fill="${k % 4 ? '#ffd27a' : '#fff2c0'}"><animateMotion path="${path}" ${timing}${keys}/><animate attributeName="opacity" ${fade} ${timing}/></circle>`
270}
271
272const sparks = (busy: boolean) =>
273 Array.from({ length: busy ? 34 : 16 }, (_, k) => spark(k, null)).join('') +
274 // crackles: each group of six pops together, at its own interval
275 [3.7, 5.3, 4.4]
276 .slice(0, busy ? 3 : 2)
277 .map((period, g) => `<g>${Array.from({ length: 6 }, (_, i) => spark(100 + g * 10 + i, period, g)).join('')}</g>`)
278 .join('')
279
280const SMOKE = [0, 1, 2]
281 .map(
282 k =>
283 `<circle r="10" fill="#8a8a96" opacity="0"><animateTransform attributeName="transform" type="translate" values="0 -80;-10 -160;-30 -260" dur="7s" begin="${n(-k * 2.3)}s" repeatCount="indefinite"/><animate attributeName="r" values="8;26" dur="7s" begin="${n(-k * 2.3)}s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;.06;0" dur="7s" begin="${n(-k * 2.3)}s" repeatCount="indefinite"/></circle>`,
284 )
285 .join('')
286
287// how big the fire is: [from, to] between small (0) and roaring (1); a rebuilt picture carries on from where it is
288const fireSpan = (w: Fire, now: number): [number, number, number] => {
289 const since = (now - w.startedAt) / 1000
290 const sinceEnd = (now - w.endedAt) / 1000
291 if (w.working) {
292 const v = Math.min(1, since / RISE)
293
294 return [v, 1, RISE * (1 - v)]
295 }
296 if (w.endedAt > 0 && sinceEnd < FALL) {
297 const v = 1 - sinceEnd / FALL
298
299 return [v, 0, FALL * v]
300 }
301
302 return [0, 0, 0]
303}
304
305const size = (v: number) => r3(0.72 + v * 0.55)
306
307// ---- visitors: a fox trotting past, and an owl that drops onto the branch and turns its head about ----------
308const FOX =
309 `<g>${track(
310 [
311 [0, -60, 918],
312 [6, -60, 918],
313 [13, 150, 912],
314 [19, 150, 912],
315 [27, 470, 920],
316 [44, 470, 920],
317 ],
318 44,
319 -8,
320 )}` +
321 '<g><animateTransform attributeName="transform" type="translate" values="0 0;0 -1.6;0 0" dur=".44s" repeatCount="indefinite"/>' +
322 // the brush of a tail, swishing
323 '<g><animateTransform attributeName="transform" type="rotate" values="-8 -14 -12;10 -14 -12;-8 -14 -12" dur="1.4s" repeatCount="indefinite"/><path d="M-14 -12 Q-34 -18 -40 -6 Q-30 -2 -14 -6Z" fill="#c8582a"/><path d="M-40 -6 Q-36 -10 -33 -6 Q-36 -3 -40 -6Z" fill="#f4ece0"/></g>' +
324 '<g><animateTransform attributeName="transform" type="rotate" values="10 0 -6;-10 0 -6;10 0 -6" dur=".44s" repeatCount="indefinite"/><path d="M-8 -6 V6 M12 -6 V6" stroke="#2a1810" stroke-width="3.4" stroke-linecap="round"/></g>' +
325 '<ellipse cx="2" cy="-10" rx="17" ry="8" fill="#d8642a"/><path d="M-6 -6 Q4 -2 16 -6" stroke="#f4ece0" stroke-width="3" fill="none" stroke-linecap="round"/>' +
326 '<path d="M-4 -6 V6 M16 -6 V6" stroke="#3a2010" stroke-width="3.4" stroke-linecap="round"/>' +
327 '<path d="M14 -14 Q20 -24 28 -20 L36 -14 Q28 -8 18 -8Z" fill="#d8642a"/><path d="M28 -14 L36 -14 Q30 -9 24 -10Z" fill="#f4ece0"/>' +
328 '<path d="M18 -20 L19 -29 L24 -21Z M23 -20 L26 -28 L28 -19Z" fill="#c8582a"/><circle cx="36" cy="-14" r="1.4" fill="#1a1010"/><circle cx="26" cy="-17" r="1.3" fill="#1a1010"/>' +
329 '</g></g>'
330
331const OWL_PERIOD = 60
332const OWL_FLYING = [2, 5, 44, 47] // flies in between the first two times, off between the last two
333
334const OWL_HEAD =
335 // the head swivels: a tilt this way and that, a quick look over one shoulder (only the back of the head shows),
336 // and back round again
337 '<g transform="translate(0 -30)"><g><animateTransform attributeName="transform" type="rotate" values="0;-24;-24;0;18;18;0;0" keyTimes="0;.08;.2;.26;.34;.46;.52;1" dur="9s" repeatCount="indefinite"/>' +
338 '<g><animateTransform attributeName="transform" type="scale" values="1 1;1 1;.08 1;1 1;1 1;.08 1;1 1;1 1" keyTimes="0;.58;.62;.66;.84;.88;.92;1" dur="9s" repeatCount="indefinite"/>' +
339 '<ellipse rx="13" ry="11" fill="#7a5a3a"/><path d="M-11 -7 L-14 -17 L-5 -10Z M11 -7 L14 -17 L5 -10Z" fill="#6a4a2c"/>' +
340 // the face, gone while it looks behind
341 '<g><animate attributeName="opacity" values="1;0;1" keyTimes="0;.62;.88" calcMode="discrete" dur="9s" repeatCount="indefinite"/>' +
342 '<path d="M-12 0 Q-12 -9 -5 -9 Q0 -6 5 -9 Q12 -9 12 0 Q6 8 0 6 Q-6 8 -12 0Z" fill="#c8a478"/>' +
343 '<circle cx="-5" cy="-2" r="4.6" fill="#ffc83a"/><circle cx="5" cy="-2" r="4.6" fill="#ffc83a"/>' +
344 '<circle cx="-5" cy="-2" r="2.4" fill="#120c08"/><circle cx="5" cy="-2" r="2.4" fill="#120c08"/><circle cx="-4" cy="-3" r=".8" fill="#fff"/><circle cx="6" cy="-3" r=".8" fill="#fff"/>' +
345 // a blink now and then
346 '<g><animateTransform attributeName="transform" type="scale" values="1 0;1 0;1 1;1 0;1 0" keyTimes="0;.4;.43;.46;1" dur="4s" repeatCount="indefinite"/><path d="M-10 -2 H0 M0 -2 H10" stroke="#7a5a3a" stroke-width="6"/></g>' +
347 '<path d="M-1.6 2 L0 6 L1.6 2Z" fill="#e8b03a"/></g>' +
348 // the back of the head
349 '<path d="M-6 -6 Q0 -2 6 -6 M-8 0 Q0 4 8 0" stroke="#5a4028" stroke-width="1.4" fill="none" opacity=".6"/>' +
350 '</g></g></g>'
351
352// a translate through timed points, each leg eased with its own curve
353const glide = (keys: Key[], splines: string[], period: number, begin: number) =>
354 `<animateTransform attributeName="transform" type="translate" values="${keys.map(([, x, y]) => `${n(x)} ${n(y)}`).join(';')}" keyTimes="${keys.map(([t]) => r3(t / period)).join(';')}" calcMode="spline" keySplines="${splines.join(';')}" dur="${period}s" begin="${begin}s" repeatCount="indefinite"/>`
355
356const LINEAR = '0 0 1 1'
357const LAND = '.2 .5 .35 1' // slowing as it comes in to land or to stop
358const LEAP = '.5 0 .8 .5' // gathering speed as it leaves
359
360const OWL_WINGS = [-1, 1]
361 .map(
362 s =>
363 `<g><animateTransform attributeName="transform" type="rotate" values="${s * 34} ${s * 7} -18;${s * -26} ${s * 7} -18;${s * 34} ${s * 7} -18" calcMode="spline" keyTimes="0;.45;1" keySplines="${EASE};${EASE}" dur=".62s" repeatCount="indefinite"/>` +
364 `<path d="M${s * 7} -20 Q${s * 24} -32 ${s * 44} -24 L${s * 40} -20 L${s * 44} -16 L${s * 38} -14 L${s * 40} -10 Q${s * 22} -12 ${s * 7} -10Z" fill="#6a4a2c"/>` +
365 `<path d="M${s * 12} -20 Q${s * 26} -26 ${s * 38} -22" stroke="#c8a478" stroke-width="1.4" fill="none" opacity=".6"/></g>`,
366 )
367 .join('')
368
369// an eased arc in to the branch, a little hop and climb on the way out, and wings that beat from the shoulder
370const OWL = (() => {
371 const shown = (flying: boolean) => {
372 const [a, b, c, d] = OWL_FLYING.map(t => r3(t / OWL_PERIOD))
373
374 return `<animate attributeName="opacity" values="${flying ? '0;1;0;1;0' : '1;0;1;0;1'}" keyTimes="0;${a};${b};${c};${d}" calcMode="discrete" dur="${OWL_PERIOD}s" begin="-22s" repeatCount="indefinite"/>`
375 }
376
377 return (
378 `<g>${glide(
379 [
380 [0, 470, 610],
381 [2, 470, 610],
382 [3.6, 384, 622],
383 [5, 326, 674],
384 [44, 326, 674],
385 [44.7, 312, 656],
386 [46.4, 170, 600],
387 [48, -60, 590],
388 [OWL_PERIOD, -60, 590],
389 ],
390 [LINEAR, LINEAR, LAND, LINEAR, LEAP, LINEAR, LINEAR, LINEAR],
391 OWL_PERIOD,
392 -22,
393 )}` +
394 // perched: body, folded wings, talons on the branch, and the busy head
395 `<g opacity="1">${shown(false)}<ellipse cx="0" cy="-12" rx="12" ry="17" fill="#7a5a3a"/><ellipse cx="0" cy="-9" rx="8" ry="12" fill="#c8a478"/>` +
396 '<path d="M-4 -16 l2 2 M2 -12 l2 2 M-3 -6 l2 2 M3 -2 l2 2" stroke="#7a5a3a" stroke-width="1.2"/>' +
397 '<path d="M-12 -20 Q-16 -6 -8 4 M12 -20 Q16 -6 8 4" stroke="#5a4028" stroke-width="5" fill="none" stroke-linecap="round"/>' +
398 `<path d="M-5 4 l-2 4 M-3 4 v4 M3 4 v4 M5 4 l2 4" stroke="#e8b03a" stroke-width="1.4"/>${OWL_HEAD}</g>` +
399 // flying: leaning into the flight, wings beating from the shoulders, talons tucked up
400 `<g opacity="0">${shown(true)}<g transform="rotate(-8 0 -12)">${OWL_WINGS}` +
401 '<ellipse cx="0" cy="-12" rx="10" ry="15" fill="#7a5a3a"/><ellipse cx="0" cy="-9" rx="6.4" ry="10" fill="#c8a478"/>' +
402 '<path d="M-3 2 l-1 4 M3 2 l1 4" stroke="#e8b03a" stroke-width="1.4"/>' +
403 '<g transform="translate(0 -30)"><ellipse rx="12" ry="10" fill="#7a5a3a"/><path d="M-10 -6 L-13 -15 L-5 -9Z M10 -6 L13 -15 L5 -9Z" fill="#6a4a2c"/>' +
404 '<path d="M-11 0 Q-11 -8 -5 -8 Q0 -5 5 -8 Q11 -8 11 0 Q6 7 0 5 Q-6 7 -11 0Z" fill="#c8a478"/>' +
405 '<circle cx="-4.6" cy="-2" r="4.2" fill="#ffc83a"/><circle cx="4.6" cy="-2" r="4.2" fill="#ffc83a"/><circle cx="-4.6" cy="-2" r="2.2" fill="#120c08"/><circle cx="4.6" cy="-2" r="2.2" fill="#120c08"/>' +
406 '<path d="M-1.4 2 L0 5.6 L1.4 2Z" fill="#e8b03a"/></g></g></g></g>'
407 )
408})()
409
410// ---- more visitors ----------------------------------------------------------------------------------------
411// a deer steps out of the trees, grazes, lifts its head to look at the fire, and turns back
412const DEER_ART =
413 '<g><animateTransform attributeName="transform" type="rotate" values="0;0;8;0;0" keyTimes="0;.6;.65;.7;1" dur="2.6s" repeatCount="indefinite"/><path d="M26 -30 l6 -4 l-2 6Z" fill="#f4ece0"/></g>' +
414 '<path d="M-20 -18 V6 M-12 -18 V6 M14 -18 V6 M22 -18 V6" stroke="#5a3a20" stroke-width="3.2" stroke-linecap="round"/>' +
415 '<ellipse cx="2" cy="-26" rx="28" ry="12" fill="#9a6a3e"/><ellipse cx="2" cy="-20" rx="22" ry="5" fill="#d8b890" opacity=".7"/>' +
416 // head and neck, lowered to graze and raised again
417 '<g><animateTransform attributeName="transform" type="rotate" values="0 -18 -30;0 -18 -30;-62 -18 -30;-62 -18 -30;0 -18 -30;0 -18 -30" keyTimes="0;.2;.3;.6;.7;1" calcMode="spline" keySplines="' +
418 [EASE, EASE, EASE, EASE, EASE].join(';') +
419 '" dur="9s" repeatCount="indefinite"/>' +
420 '<path d="M-14 -34 L-26 -56 L-36 -54 L-24 -28Z" fill="#9a6a3e"/><path d="M-40 -60 Q-30 -64 -24 -56 L-28 -50 Q-38 -50 -44 -54Z" fill="#9a6a3e"/>' +
421 '<ellipse cx="-24" cy="-60" rx="5" ry="2.4" fill="#8a5a30" transform="rotate(-30 -24 -60)"/><circle cx="-34" cy="-56" r="1.4" fill="#120c08"/><circle cx="-44" cy="-54" r="1.4" fill="#120c08"/>' +
422 // antlers
423 '<path d="M-30 -62 L-32 -78 L-38 -86 M-32 -78 L-26 -84 M-32 -72 L-38 -76 M-26 -62 L-22 -76 L-24 -86 M-22 -76 L-16 -80" stroke="#d8c8a8" stroke-width="1.8" fill="none" stroke-linecap="round"/>' +
424 '</g>'
425
426const DEER_PERIOD = 72
427const DEER =
428 `<g>${glide(
429 [
430 [0, 470, 860],
431 [4, 470, 860],
432 [12, 312, 858],
433 [36, 312, 858],
434 [44, 470, 860],
435 [DEER_PERIOD, 470, 860],
436 ],
437 [LINEAR, LAND, LINEAR, LEAP, LINEAR],
438 DEER_PERIOD,
439 -30,
440 )}` +
441 // facing the way it walks: left on the way in, right on the way back
442 `<g><animateTransform attributeName="transform" type="scale" values="1 1;-1 1;1 1" keyTimes="0;${r3(36 / DEER_PERIOD)};${r3(70 / DEER_PERIOD)}" calcMode="discrete" dur="${DEER_PERIOD}s" begin="-30s" repeatCount="indefinite"/>` +
443 `<g transform="scale(.8)">${DEER_ART}</g></g></g>`
444
445// a raccoon snuffles along the front of the clearing, stops to sniff, and goes on its way
446const RACCOON =
447 `<g>${track(
448 [
449 [0, -50, 1098],
450 [5, -50, 1098],
451 [14, 196, 1092],
452 [21, 196, 1092],
453 [30, 460, 1100],
454 [52, 460, 1100],
455 ],
456 52,
457 -16,
458 )}<g><animateTransform attributeName="transform" type="translate" values="0 0;0 -1.4;0 0" dur=".36s" repeatCount="indefinite"/>` +
459 // the ringed tail
460 '<g><animateTransform attributeName="transform" type="rotate" values="-6 -16 -10;8 -16 -10;-6 -16 -10" dur="1.6s" repeatCount="indefinite"/><path d="M-16 -10 Q-34 -12 -40 -2 Q-30 2 -16 -4Z" fill="#8a8a92"/><path d="M-22 -11 L-24 -3 M-29 -10 L-30 -1 M-35 -7 L-36 -1" stroke="#2a2a30" stroke-width="3"/></g>' +
461 '<path d="M-10 -4 V6 M-2 -4 V6 M8 -4 V6 M14 -4 V6" stroke="#2a2a30" stroke-width="3" stroke-linecap="round"/>' +
462 '<ellipse cx="0" cy="-10" rx="18" ry="10" fill="#9a9aa2"/><ellipse cx="2" cy="-5" rx="13" ry="4" fill="#c8c8d0" opacity=".6"/>' +
463 // a sniffing head with its black mask
464 '<g><animateTransform attributeName="transform" type="rotate" values="0 16 -12;-6 16 -12;0 16 -12;-4 16 -12;0 16 -12" dur="1.2s" repeatCount="indefinite"/>' +
465 '<path d="M12 -18 Q22 -24 30 -16 L36 -10 Q28 -6 16 -8Z" fill="#9a9aa2"/><path d="M18 -18 Q26 -18 30 -14 Q24 -12 18 -13Z" fill="#1a1a20"/><circle cx="25" cy="-15" r="1.4" fill="#f4f4f8"/>' +
466 '<path d="M30 -13 L36 -10 L32 -9Z" fill="#f4f4f8"/><circle cx="36" cy="-10" r="1.4" fill="#120c08"/><path d="M14 -18 L15 -26 L20 -19Z" fill="#7a7a82"/></g>' +
467 '</g></g>'
468
469// a hedgehog waddles slowly along the bottom, through the flowers
470const HEDGEHOG =
471 `<g>${track(
472 [
473 [0, 450, 1176],
474 [3, 450, 1176],
475 [26, -40, 1172],
476 [44, -40, 1172],
477 ],
478 44,
479 -12,
480 )}<g><animateTransform attributeName="transform" type="rotate" values="-4 0 0;4 0 0;-4 0 0" dur=".5s" repeatCount="indefinite"/>` +
481 '<path d="M-4 0 V3 M6 0 V3" stroke="#3a2a1a" stroke-width="2.4" stroke-linecap="round"/>' +
482 '<path d="M-14 0 Q-14 -16 2 -17 Q16 -16 14 -2Z" fill="#6a4a30"/>' +
483 Array.from({ length: 9 }, (_, i) => {
484 const a = Math.PI * (0.95 - i * 0.1)
485 const x = 1 + Math.cos(a) * 13
486 const y = -3 - Math.sin(a) * 13
487
488 return `<path d="M${n(x)} ${n(y)} l${n(Math.cos(a) * 6)} ${n(-Math.sin(a) * 6)}" stroke="#4a3220" stroke-width="2" stroke-linecap="round"/>`
489 }).join('') +
490 '<path d="M-14 -2 Q-22 -4 -24 -1 Q-20 2 -12 1Z" fill="#c8a478"/><circle cx="-24" cy="-1" r="1.4" fill="#120c08"/><circle cx="-17" cy="-4" r="1" fill="#120c08"/>' +
491 '</g></g>'
492
493// ---- the whole picture --------------------------------------------------------------------------------
494const buildScene = (w: Fire, elapsed: number, vh: number, now: number) => {
495 const view = Math.min(H, Math.max(vh, 240))
496 const y0 = H - view
497 const [v0, v1, dur] = fireSpan(w, now)
498 const grow =
499 v0 === v1
500 ? ''
501 : `<animateTransform data-once="1" attributeName="transform" type="scale" values="${size(v0)};${size(v1)}" dur="${n(dur)}s" calcMode="spline" keyTimes="0;1" keySplines="${EASE}" fill="freeze"/>`
502
503 const defs =
504 '<defs>' +
505 '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#070b1e"/><stop offset=".5" stop-color="#121a3a"/><stop offset="1" stop-color="#1c2240"/></linearGradient>' +
506 '<radialGradient id="glow"><stop offset="0" stop-color="#ffb35a" stop-opacity=".55"/><stop offset=".35" stop-color="#ff8a3a" stop-opacity=".22"/><stop offset="1" stop-color="#ff6a2a" stop-opacity="0"/></radialGradient>' +
507 '</defs>'
508
509 const fire = `<g transform="translate(${FX} ${FY})"><g transform="scale(${size(v0)})">${grow}${FLAMES}${SMOKE}${sparks(w.working)}</g></g>`
510
511 return (
512 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${n(y0)} ${W} ${n(view)}" width="${W}" height="${n(view)}" preserveAspectRatio="xMidYMax slice">${defs}` +
513 retime(SKY + RIDGES + FOREST + FRAME_TREES + GROUND + DEER + OWL + FLOWERBEDS + LOG_RING + stones(false) + LOGS + RACCOON + HEDGEHOG + FOX, elapsed) +
514 retime(fire + stones(true), elapsed) +
515 '</svg>'
516 )
517}
518// ---scene-end
519
520const TERMINAL_ART: [string, string][] = [
521 [' ✦ ▲▲ ✦ ▲ ✦', '#fff8e0'],
522 [' ▲▲ ▲▲▲ ▲▲▲ ▲▲ ▲▲', '#16233c'],
523 [' ✿ ·: · ❀ ', '#ffd27a'],
524 [' ❀ ( )( ) ✿ ', '#ff7a1e'],
525 [' ✿ ═╳═╳═ ❀ ', '#8a5a30'],
526]
527
528const clock = (ms: number) => {
529 const s = Math.max(0, Math.floor(ms / 1000))
530
531 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
532}
533
534const label = (w: Camp, now: number, T: Words) => (w.working ? T.working(clock(now - w.startedAt), w.tools) : T.idle)
535
536const fire = atom({ plugin: 'campfire', key: 'fire' } as const, { working: false, tools: 0, startedAt: 0, endedAt: 0, tick: 0 } as Camp)
537
538// The picture is built again only when the state it draws changes; the same text keeps it running.
539let t0 = 0
540let cache = { key: '', svg: '' }
541
542export const register: Register = (on, options) => {
543 // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
544 const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
545
546 on('session.start', async ($, e, next) => {
547 t0 = await $.clock.now()
548 await $.command.register({
549 name: 'fire',
550 description: 'Open the campfire pane',
551 })
552 // Wakes the pane each second while Claude works (the stopwatch) and while the fire dies back.
553 $.clock.every(1000, () => {
554 void Promise.all([read($, fire), $.clock.now()])
555 .then(([r, now]) => (r.working || (r.endedAt > 0 && now - r.endedAt < (FALL + 1) * 1000) ? update($, fire, v => ({ ...v, tick: v.tick + 1 })) : undefined))
556 .catch(() => {})
557 })
558
559 return next(e)
560 })
561
562 on('command.run', { command: 'fire' }, async $ => {
563 await $.ui.open({ id: PANE, title: T.title })
564
565 return { text: T.opened }
566 })
567
568 // The main loop's turns only: a subagent's run is not Claude at work.
569 on('turn.start', async ($, e, next) => {
570 if (e.agentId === undefined) {
571 const now = await $.clock.now()
572 await update($, fire, v => (v.working ? v : { ...v, working: true, tools: 0, startedAt: now }))
573 }
574
575 return next(e)
576 }).catch(($, e, next) => next(e))
577
578 on('turn.complete', async ($, e, next) => {
579 if (e.agentId === undefined) {
580 const now = await $.clock.now()
581 await update($, fire, v => (v.working ? { ...v, working: false, endedAt: now } : v))
582 }
583
584 return next(e)
585 }).catch(($, e, next) => next(e))
586
587 on('tool.call', async ($, e, next) => {
588 await update($, fire, v => ({ ...v, tools: v.tools + 1 }))
589
590 return next(e)
591 }).catch(($, e, next) => next(e))
592
593 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
594 const els = $.ui.resolve(e)
595 const w = await read($, fire)
596 const now = await $.clock.now()
597 if (t0 === 0) t0 = now
598 const cols = e.props?.bodyColumns ?? 40
599 const rows = e.props?.scroll?.bodyRows ?? 20
600 const line = label(w, now, T)
601
602 if (e.surface !== 'terminal') {
603 const { Box, Text, Svg } = els as ElementTable<'desktop'>
604 // The pane's px: its cells over the code font's advance (about 7.8 px) and line height
605 // (about 19.5 px). Short of the whole picture, the sky is cut, never the fire.
606 const width = Math.max(160, Math.round(cols * 7.8))
607 const full = Math.round((width * H) / W)
608 const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
609 const height = Math.min(full, tall)
610 // building up and dying back each draw the change; once that is over the picture settles
611 const changing = w.working ? (now - w.startedAt) / 1000 < RISE : w.endedAt > 0 && (now - w.endedAt) / 1000 < FALL
612 const key = `${w.working}|${changing}|${w.startedAt}|${w.endedAt}|${height}|${width}`
613 if (cache.key !== key) cache = { key, svg: buildScene(w, (now - t0) / 1000, (height * W) / width, now) }
614
615 return (
616 <Box flexDirection="column" justifyContent="center" minHeight={rows}>
617 <Box justifyContent="center">
618 <Text bold={w.working} dimColor={!w.working}>
619 {line}
620 </Text>
621 </Box>
622 <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
623 </Box>
624 )
625 }
626
627 const { Box, Text } = els as ElementTable<'terminal'>
628
629 return (
630 <Box flexDirection="column">
631 {TERMINAL_ART.map(([row, color]) => (
632 <Text color={color}>{row}</Text>
633 ))}
634 <Text>{line}</Text>
635 <Text dimColor>{T.note}</Text>
636 </Box>
637 )
638 })
639}
640types/index.d.ts 14 lines1export type Camp = {
2 working: boolean
3 tools: number
4 startedAt: number
5 endedAt: number
6 tick: number
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 campfire: { fire: Camp }
12 }
13}
14