SLOPSHOPPER

campfire

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…

newpaneguardcommandtimer
v0.1.0MITupdated 2026-10-24Gnosis333/mod_gallery/mods/campfire
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · campfire
│ ┃ Campfire ✕ › 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 │ ┃ Idle ⏺ Bash(bun test) │ ┃ The animation shows in the desktop app. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fire │ ⎿ campfire: Opened the campfire pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Campfire
✦ ▲▲ ✦ ▲ ✦ ▲▲ ▲▲▲ ▲▲▲ ▲▲ ▲▲ ✿ ·: · ❀ ❀ ( )( ) ✿ ✿ ═╳═╳═ ❀ Idle The animation shows in the desktop app.
README

campfire

campfire

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.

Install

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.

Language

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.

What it does and does not do

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.

Source 2 files
hooks/register.tsx 640 lines
1import { 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}
640
types/index.d.ts 14 lines
1export 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