A tall rainy-window pane: drops run down the glass and merge, blurred city lights glow outside and a cat dozes on the sill. While Claude works the rain pours…

<img src="images/hero-en.gif" alt="Claude Code on the left works through a task while the reef-tank pane on the right dims and a submarine comes down" width="720">
Hand Claude Code a task, and zone out at the fish while it works. (The chat window here is a mock-up; the tank is the real mod.)
Animated side panels for Claude Code that change when Claude is idle vs. working, plus two small prompt bands. I'm learning Claude Code mods by building things, and this is what came out so far.
Every scene is a hand-written SVG animation (no image files). The previews below are the real pictures, captured with the idle state on the left and the working state on the right (an 8-second loop).
한국어 설명은 아래에 있습니다.
This repository is a marketplace. Inside Claude Code:
/plugin marketplace add Gnosis333/mod_gallery
/plugin install night-train@mod-gallery
Or from a shell:
claude plugin marketplace add Gnosis333/mod_gallery
claude plugin install night-train@mod-gallery --scope user
Swap night-train for any mod below. Panels open only when you run their slash command (they don't open by themselves, to save CPU). After installing, run /reload-plugins or start a new session.
To try one mod without installing: claude --plugin-dir mods/night-train.
Labels (the status line, descriptions, replies) are English by default. Every mod also has a Korean (한국어) mode through its language option:
echo '{"language":"ko"}' | claude plugin configure night-train@mod-gallery --values-stdin
Then restart Claude Code. You can also run /plugin configure night-train@mod-gallery inside Claude Code, and claude plugin configure night-train@mod-gallery shows whether it is set. Use en to switch back.
| Mod | Command | What it is |
|---|---|---|
night-train | /train | A night-train window |
beach-day | /beach | A top-down beach |
snow-cabin | /snow | A snowy cabin, day and night |
deep-sub | /deep | A deep-sea submersible |
campfire | /fire | A campfire to stare at |
jungle-falls | /falls | A jungle waterfall with 24 kinds of animals |
rain-window | /rain | A rainy window |
moon-view | /moon | The universe seen from the Moon |
tower-cut | /tower | A building cross-section |
desk-garden | /garden | A desk plant and a workshop |
reef-tank | /reef | An aquarium |
usage-band | automatic | Context, limits and cost on one line |
haiku-warning | automatic | A warning while a Haiku model is selected |
<img src="images/night-train.gif" alt="night-train" width="460">
A tall train window. At rest the train waits at STATION under lit lamp posts; when work starts the station slides away and hills, trees and poles stream past at different speeds. Now and then it runs through a long tunnel, and flocks of migrating birds cross the sky. The steam from the cup blows sideways, and when the turn ends the train rolls back into the station.
<img src="images/beach-day.gif" alt="beach-day" width="460">
A beach seen from above. Gentle waves wash the shore, and people in sunglasses lie on sunbeds under parasols (bikinis, one-pieces and trunks). Out at sea swimmers float on all kinds of rings, a pink unicorn among them. While Claude works a jet ski and a banana boat go by, and crabs scuttle across the sand.
<img src="images/snow-cabin.gif" alt="snow-cabin" width="460">
Snow falls softly over a white forest, snowy peaks and a log cabin. It's daytime at rest; while Claude works night falls, the windows glow, the chimney smokes, and a snowman is built every 15 seconds. A white rabbit, a white fox, a magpie and a husky come by.
<img src="images/deep-sub.gif" alt="deep-sub" width="460">
A yellow submersible drifts through the dark among glowing jellyfish, an anglerfish, lanternfish, a dumbo octopus and a gulper eel, while something vast and unknowable stirs faintly far below. While Claude works the searchlight comes on, sonar pings, and now and then the shadow of a giant squid sweeps past.
<img src="images/campfire.gif" alt="campfire" width="460">
A campfire in a dense forest clearing under the stars. Flames lick, sparks crackle up, and a ring of logs and six kinds of flowers surround the fire. A fox, an owl (it swivels its head), a deer, a raccoon and a hedgehog drop by. The fire roars up while Claude works and dies back afterwards.
<img src="images/jungle-falls.gif" alt="jungle-falls" width="460">
A terraced waterfall winds down into a lily pool. 24 kinds of animals visit at random (birds, monkeys, a jaguar, a crocodile, frogs, otters and more) and fish leap up the falls. It's day at rest and night while Claude works. This is the heaviest mod on CPU.
<img src="images/rain-window.gif" alt="rain-window" width="460">
A rainy window: drops run down the glass and merge, city lights blur outside and a cat dozes on the sill. While Claude works a storm comes in, and when lightning strikes the cat opens its eyes and the desk lamp flickers. When the turn ends a rainbow comes out and fades away.
<img src="images/moon-view.gif" alt="moon-view" width="460">
The Earth seen from the Moon. The lander's "eyes" flash and an astronaut bounces about (and tumbles around while Claude works). Stars, supernovae, a black hole, rockets, satellites, UFOs, planets and a few Star Wars-like ships pass at random.
<img src="images/tower-cut.gif" alt="tower-cut" width="460">
A building cross-section. One floor goes up with every finished turn, and at 20 floors the tower tops out with fireworks. A new session starts over. While Claude works the crane moves and raises the frame of the next floor.
<img src="images/desk-garden.gif" alt="desk-garden" width="460">
Three pictures stacked: a flowerpot by a window (one more flower blooms with every finished turn), a dozing cat by a fish tank, and a workshop where Claude's character works. While Claude works the character types at the keyboard.
<img src="images/reef-tank.gif" alt="reef-tank" width="460">
A tall aquarium with fish, a seahorse and jellyfish. While Claude works the lights dim and a yellow submarine dives; every finished turn adds a pearl to the treasure chest.
<img src="images/usage-band.png" alt="usage-band" width="720">
One line above the prompt: context window fill, the 5-hour and 7-day plan limits, and session cost. It turns amber at 70% and brick red at 85%, and each limit shows when it resets (↻). The image is an illustration of the look.
<img src="images/haiku-warning.png" alt="haiku-warning" width="720">
Shows a red warning above the prompt while a Haiku model is selected, as a reminder to switch before complex work. The image is an illustration of the look.
jungle-falls, snow-cabin and campfire are larger pictures and relatively heavier.mods/<name>/ with .claude-plugin/plugin.json, hooks/register.tsx (the behaviour), a test file and a state type. The part of register.tsx between // ---scene-start and // ---scene-end is what draws the picture.language option (see Language). The pictures themselves contain no text, except a few English words such as STATION and the speech bubble in desk-garden.Built with Claude Code.
MIT. Use, change and share freely.
<img src="images/hero-ko.gif" alt="왼쪽 Claude Code가 일하는 동안 오른쪽 어항 패널이 어두워지고 잠수함이 내려오는 모습" width="720">
Claude Code에 일을 맡기고, 일하는 동안엔 어항 보며 멍~ (왼쪽 대화창은 비슷하게 그린 화면이고, 어항은 실제 모드입니다.)
Claude Code 옆에 띄워 두는 그림 패널과 입력창 위 상태 띠를 모았습니다. 패널은 Claude가 쉬고 있을 때와 일하고 있을 때 모습이 달라집니다. Claude Code의 모드를 공부하면서 이것저것 만들어 본 결과물입니다. 위 영어 부분의 그림(왼쪽 쉬는 중, 오른쪽 작업 중)이 그대로 해당합니다.
/plugin marketplace add Gnosis333/mod_gallery
/plugin install night-train@mod-gallery
night-train 자리에 위 표의 모드 이름을 넣으면 됩니다. 패널은 슬래시 명령으로 열 때만 열립니다(CPU를 아끼려고 자동으로 열지 않습니다). 설치 후 /reload-plugins를 입력하거나 새 세션을 여세요.
한글 라벨로 보려면 모드마다 language 옵션을 ko로 정하고 Claude Code를 다시 시작하세요(기본은 영어).
echo '{"language":"ko"}' | claude plugin configure night-train@mod-gallery --values-stdin
/plugin configure night-train@mod-gallery로도 정할 수 있습니다. 영어로 되돌리려면 en을 쓰세요.
/train): 야간열차 창가. 쉴 때는 STATION에 정차해 가로등이 켜져 있고, 작업 중에는 산·나무·전신주가 서로 다른 속도로 흘러가며 가끔 긴 터널을 지납니다. 하늘에는 철새떼가 날아가고, 턴이 끝나면 다시 역으로 들어옵니다./beach): 위에서 내려다본 해변. 파라솔 아래 썬베드에 선글라스를 낀 사람들이 누워 있고, 바다에는 핑크 유니콘 튜브를 비롯한 여러 튜브가 떠 있습니다. 작업 중에는 제트스키, 바나나보트, 꽃게가 나옵니다./snow): 눈이 소복소복 내리는 흰 숲과 설산, 통나무 오두막. 쉴 때는 낮, 작업 중에는 밤이 되어 창에 불이 켜지고 눈사람이 15초마다 하나씩 생깁니다. 흰 토끼, 흰 여우, 까치, 허스키가 찾아옵니다./deep): 어둠 속 노란 잠수정과 야광 해파리, 아귀, 덤보 문어, 풍선장어, 그리고 저 아래 희미하게 움직이는 정체불명의 거대한 것. 작업 중에는 탐조등과 소나가 켜지고 가끔 거대 오징어 그림자가 스칩니다./fire): 별이 가득한 밤 숲속 공터의 모닥불. 불꽃과 불티, 통나무 원, 꽃 여섯 종류, 여우·올빼미(머리를 돌립니다)·사슴·너구리·고슴도치. 작업 중에는 불이 커지고 끝나면 잦아듭니다./falls): 계단식 폭포와 연꽃 연못, 동물 24종이 무작위로 방문. 쉴 때는 낮, 작업 중에는 밤. CPU를 가장 많이 씁니다./rain): 비 오는 창가. 작업 중에는 폭풍, 번개가 치면 고양이가 눈을 뜨고 스탠드가 깜빡이며, 끝나면 무지개가 뜹니다./moon): 달에서 본 지구. 통통 뛰는 우주인(작업 중에는 빙빙 돕니다), 무작위로 지나가는 우주 물체와 스타워즈풍 우주선./tower): 빌딩 단면. 턴이 끝날 때마다 한 층씩 올라가고 20층이면 준공 불꽃놀이./garden): 창가 화분(턴마다 꽃이 핍니다), 졸고 있는 고양이와 수조, Claude가 일하는 작업실./reef): 수족관. 작업 중에는 조명이 어두워지고 노란 잠수함이 내려오며, 턴이 끝날 때마다 진주가 쌓입니다.↻ 포함), 비용을 보여줍니다.jungle-falls, snow-cabin, campfire는 그림이 커서 상대적으로 무겁습니다.language 옵션을 ko로 하면 한글입니다. 그림 속 글자는 STATION과 desk-garden의 말풍선 정도입니다(말풍선은 옵션을 따릅니다).모두 Claude Code로 만들었습니다.
hooks/register.tsx 499 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Rain } from '../types'
5
6const PANE = 'rain-window'
7const STRINGS = {
8 en: {
9 title: 'Rainy window',
10 alt: 'A rainy window. Drops run down the glass and merge, city lights blur outside, and a cat dozes on the sill.',
11 working: (clock: string, tools: number) => `Working ${clock} · tools ${tools}`,
12 idle: 'Idle',
13 opened: 'Opened the rainy-window 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
29const W = 400
30const H = 1400
31const GL = 30 // the glass: left, right, top, bottom
32const GR = 370
33const GT = 30
34const GB = 1010 // and the sill it sits on
35
36const n = (v: number) => String(Math.round(v * 10) / 10)
37const r3 = (v: number) => String(Math.round(v * 1000) / 1000)
38const EASE = '.45 0 .55 1'
39const pick = <T,>(list: T[], s: number): T => list[((s % list.length) + list.length) % list.length]
40const rnd = (s: number) => {
41 const v = Math.sin(s * 127.1 + 311.7) * 43758.5453
42
43 return v - Math.floor(v)
44}
45
46// Looping motion shifts back by the time already elapsed, so redrawing the picture never makes it jump.
47// Tags marked data-once (a fade for work, the rainbow coming out) are left alone and play from the start.
48const retime = (svg: string, elapsed: number) =>
49 svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
50 if (tag.includes('data-once')) {
51 return tag
52 }
53 const found = / begin="(-?[\d.]+)s"/.exec(tag)
54 const shifted = ` begin="${n((found ? parseFloat(found[1]) : 0) - elapsed)}s"`
55
56 return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
57 })
58
59// ---- outside: the sky, the city and its blurred lights -----------------------------------------
60const SKY =
61 `<rect x="${GL}" y="${GT}" width="${GR - GL}" height="${GB - GT}" fill="url(#night)"/>` +
62 `<rect x="${GL}" y="700" width="${GR - GL}" height="${GB - 700}" fill="url(#glow)"/>`
63
64const skyline = (fill: string, base: number, minH: number, maxH: number, seed: number, lights: string) => {
65 let x = GL - 10
66 let out = ''
67 for (let k = 0; x < GR; k++) {
68 const w = 22 + rnd(seed + k) * 30
69 const ht = minH + rnd(seed + k + 100) * (maxH - minH)
70 out += `<rect x="${n(x)}" y="${n(base - ht)}" width="${n(w)}" height="${n(ht + 2)}" fill="${fill}"/>`
71 if (lights) {
72 out += `<path d="M${n(x + 5)} ${n(base - ht + 10)} V${base}" stroke="${lights}" stroke-width="3" stroke-dasharray="3 ${n(7 + rnd(seed + k + 50) * 6)}" opacity=".55"/>`
73 }
74 x += w + 2 + rnd(seed + k + 200) * 4
75 }
76
77 return out
78}
79
80const CITY = skyline('#2a2150', 970, 120, 300, 10, '#ffd68a') + skyline('#171233', 1010, 70, 190, 60, '#ffb878')
81
82const BOKEH_COLORS = ['#ffb347', '#ff6f91', '#6fd3ff', '#fff3a8', '#ff9a5c', '#b58cff']
83const BOKEH = Array.from({ length: 30 }, (_, k) => {
84 const x = GL + 10 + rnd(k + 300) * (GR - GL - 20)
85 const y = 760 + rnd(k + 320) * 240
86 const r = 7 + rnd(k + 340) * 16
87 const c = pick(BOKEH_COLORS, k + Math.floor(rnd(k) * 5))
88 const twinkle = k % 2 === 0 ? `<animate attributeName="opacity" values=".18;.34;.18" dur="${n(3 + rnd(k + 5) * 4)}s" begin="${n(-rnd(k + 6) * 6)}s" repeatCount="indefinite"/>` : ''
89
90 return `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="${c}" opacity=".24">${twinkle}</circle><circle cx="${n(x)}" cy="${n(y)}" r="${n(r * 0.5)}" fill="${c}" opacity=".3"/>`
91}).join('')
92
93// the light that falls on the glass from the room's lamp
94const lamplight = (storm: boolean) => flicker(storm, LAMPLIGHT)
95
96const LAMPLIGHT_CIRCLES = [150, 100, 60]
97 .map((r, k) => `<circle cx="345" cy="985" r="${r}" fill="#ffd9a0" opacity="${[0.05, 0.07, 0.09][k]}"/>`)
98 .join('')
99const LAMPLIGHT = LAMPLIGHT_CIRCLES
100
101// ---- the rain outside, drops on the glass --------------------------------------------------------------
102const streaks = (count: number, fast: boolean) =>
103 Array.from({ length: count }, (_, k) => {
104 const x = GL - 20 + rnd(k + 400) * (GR - GL + 60)
105 const dur = (fast ? 0.42 : 0.7) + rnd(k + 410) * 0.3
106
107 return `<path d="M${n(x)} ${GT - 40} l-4 ${fast ? 22 : 16}" stroke="#bcd4ff" stroke-width="${fast ? 1.4 : 1.1}" stroke-linecap="round" opacity="${fast ? 0.55 : 0.4}"><animateTransform attributeName="transform" type="translate" values="0 0;-${n((GB - GT) * 0.14)} ${GB - GT + 60}" dur="${n(dur)}s" begin="${n(-rnd(k + 420))}s" repeatCount="indefinite"/></path>`
108 }).join('')
109
110const still = Array.from({ length: 34 }, (_, k) => {
111 const x = GL + 6 + rnd(k + 500) * (GR - GL - 12)
112 const y = GT + 20 + rnd(k + 520) * (GB - GT - 40)
113 const rx = 1.4 + rnd(k + 540) * 2
114 const ry = rx * (1.2 + rnd(k + 560) * 0.5)
115
116 return `<ellipse cx="${n(x)}" cy="${n(y)}" rx="${n(rx)}" ry="${n(ry)}" fill="#dbe9ff" opacity=".32"/><circle cx="${n(x - rx * 0.3)}" cy="${n(y - ry * 0.3)}" r="${n(rx * 0.35)}" fill="#fff" opacity=".7"/>`
117}).join('')
118
119const drop = (size: number) =>
120 `<ellipse cy="${n(-size * 2.4)}" rx=".8" ry="${n(size * 2.6)}" fill="#dbe9ff" opacity=".22"/>` +
121 `<ellipse rx="${n(size * 0.8)}" ry="${n(size)}" fill="#dbe9ff" opacity=".6" stroke="#9fb6e0" stroke-width=".4"/><circle cx="${n(-size * 0.25)}" cy="${n(-size * 0.3)}" r="${n(size * 0.28)}" fill="#fff" opacity=".85"/>`
122
123// A drop runs down the glass in fits and starts; a smaller one slides in from above, joins it, and it grows.
124const runner = (k: number, fast: boolean) => {
125 const x = GL + 26 + rnd(k + 600) * (GR - GL - 52)
126 const p0 = GT + 30 + rnd(k + 610) * 320
127 const total = GB - 10 - p0
128 const shares = [0.12, 0.2, 0.24, 0.44]
129 const ps = [p0]
130 for (const s of shares) ps.push(ps[ps.length - 1] + total * s)
131 const xs = ps.map((_, i) => x + (rnd(k * 7 + i) - 0.5) * 9)
132 const dur = (fast ? 0.55 : 1) * (12 + rnd(k + 620) * 8)
133 const begin = n(-rnd(k + 630) * dur)
134 const t = '0;.1;.18;.3;.4;.52;.6;.7;.9;1'
135 const A = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4].map(i => `${n(xs[i])} ${n(ps[i])}`).join(';')
136 const rep = `dur="${n(dur)}s" begin="${begin}s" repeatCount="indefinite"`
137 const size = 3.4 + rnd(k + 640) * 1.6
138 // the small one waits above the place where the big one stops second, then slides down into it
139 const bx = xs[2] + (rnd(k + 650) - 0.5) * 22
140 const B = `${n(bx)} ${n(ps[2] - 70)};${n(bx)} ${n(ps[2] - 70)};${n(xs[2])} ${n(ps[2])}`
141
142 return (
143 `<g><animateTransform attributeName="transform" type="translate" values="${A}" keyTimes="${t}" calcMode="linear" ${rep}/>` +
144 `<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.03;.96;1" ${rep}/>` +
145 `<g><animateTransform attributeName="transform" type="scale" values="1 1;1 1;1.5 1.5;1.5 1.5" keyTimes="0;.4;.45;1" ${rep}/>${drop(size)}</g></g>` +
146 `<g opacity="0"><animateTransform attributeName="transform" type="translate" values="${B}" keyTimes="0;.28;.4" calcMode="linear" fill="freeze" ${rep}/>` +
147 `<animate attributeName="opacity" values="0;1;1;0;0" keyTimes="0;.03;.4;.41;1" ${rep}/>${drop(size * 0.6)}</g>`
148 )
149}
150
151// ---- the sky for a storm and for a rainbow -------------------------------------------------------------------------
152// the two lightning strokes: where, how often, and when the first one falls
153const BOLTS: [number, number, number][] = [
154 [130, 9, 2],
155 [280, 13, 7],
156]
157const FLASH_TIMES = '0;.5;.52;.55;.58;.62;.66;1'
158
159const bolt = (x: number, period: number, begin: number) => {
160 const pts = [[x, GT], [x - 14, 190], [x + 4, 200], [x - 22, 420], [x - 8, 430], [x - 36, 640]]
161 const d = pts.map(([px, py], i) => `${i ? 'L' : 'M'}${px} ${py}`).join(' ')
162 const times = FLASH_TIMES
163
164 return (
165 `<path d="${d}" fill="none" stroke="#f4f0ff" stroke-width="3.4" stroke-linejoin="round" opacity="0"><animate attributeName="opacity" values="0;0;1;.2;1;.3;0;0" keyTimes="${times}" dur="${period}s" begin="${n(-begin)}s" repeatCount="indefinite"/></path>` +
166 `<rect x="${GL}" y="${GT}" width="${GR - GL}" height="${GB - GT}" fill="#eaf0ff" opacity="0"><animate attributeName="opacity" values="0;0;.55;.1;.4;.1;0;0" keyTimes="${times}" dur="${period}s" begin="${n(-begin)}s" repeatCount="indefinite"/></rect>`
167 )
168}
169
170const RAINBOW = ['#ff4d4d', '#ff944d', '#ffd84d', '#6ed36e', '#4db8ff', '#5a6bff', '#a05aff']
171const RAINBOW_LIFE = 15 // seconds from coming out to gone: it fades in, hangs, then fades away
172
173// `age` is how long ago it began, so a picture drawn again carries on from there instead of starting over.
174const rainbow = (age: number) =>
175 `<g opacity="0"><animate data-once="1" attributeName="opacity" values="0;1;1;0" keyTimes="0;.2;.75;1" dur="${RAINBOW_LIFE}s" begin="${n(-age)}s" fill="freeze"/>` +
176 RAINBOW.map((c, k) => {
177 const r = 170 - k * 8
178
179 return `<path d="M${200 - r} 420 A${r} ${r} 0 0 1 ${200 + r} 420" fill="none" stroke="${c}" stroke-width="9" opacity=".55"><animate attributeName="opacity" values=".45;.68;.45" dur="${n(5 + k * 0.3)}s" repeatCount="indefinite"/></path>`
180 }).join('') +
181 '</g>'
182
183// ---- the room: the sill, the cat and what stands on it ---------------------------------------------------------------
184// A flash of lightning wakes the cat for a moment: yellow-green eyes, slit pupils, then it dozes off again.
185const openEye = (x: number) =>
186 `<ellipse cx="${x}" cy="-17" rx="4.6" ry="4.8" fill="#8d8f9b"/><circle cx="${x}" cy="-17" r="3.5" fill="#c6e04a"/><ellipse cx="${x}" cy="-17" rx="1" ry="3.2" fill="#1a1a1a"/><circle cx="${x - 1.2}" cy="-18.4" r=".9" fill="#fff"/>`
187
188const openEyes = () =>
189 BOLTS.map(
190 ([, period, begin]) =>
191 `<g opacity="0"><animate attributeName="opacity" values="0;0;1;1;1;1;0;0" keyTimes="${FLASH_TIMES}" dur="${period}s" begin="${n(-begin)}s" repeatCount="indefinite"/>${openEye(27)}${openEye(40)}</g>`,
192 ).join('')
193
194// The lamp's light stutters out and back while the lightning lasts, for each of the two strokes in turn.
195// `only` shows the thing just during the flicker (the dark shade); otherwise it is the light that dips.
196const FLICKER_DIP = '1;1;.1;.9;.2;.8;1;1'
197const FLICKER_SHOW = '0;0;1;.2;1;.3;0;0'
198const flicker = (storm: boolean, inner: string, only = false) => {
199 if (!storm) {
200 return inner
201 }
202 const wrapFor = ([, period, begin]: [number, number, number], what: string) =>
203 `<g><animate attributeName="opacity" values="${only ? FLICKER_SHOW : FLICKER_DIP}" keyTimes="${FLASH_TIMES}" dur="${period}s" begin="${n(-begin)}s" repeatCount="indefinite"/>${what}</g>`
204
205 // dips multiply when nested; things that only show during a stroke must sit side by side instead
206 return only ? BOLTS.map(bolt => wrapFor(bolt, inner)).join('') : BOLTS.reduce((acc, bolt) => wrapFor(bolt, acc), inner)
207}
208
209// the z's of its sleep
210const ZZZ =
211 [0, 1, 2]
212 .map(
213 k =>
214 `<path d="M${52 + k * 8} -50 h6 l-6 7 h6" fill="none" stroke="#cfd8ff" stroke-width="1.4" stroke-linejoin="round" opacity="0"><animateTransform attributeName="transform" type="translate" values="0 0;${6 + k * 2} -26" dur="4.2s" begin="${n(-k * 1.4)}s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;.8;0" dur="4.2s" begin="${n(-k * 1.4)}s" repeatCount="indefinite"/></path>`,
215 )
216 .join('')
217
218// hidden for as long as the lightning lasts, for each of the two strokes in turn
219const quiet = (inner: string) =>
220 BOLTS.reduce(
221 (acc, [, period, begin]) => `<g><animate attributeName="opacity" values="1;1;0;0;0;0;1;1" keyTimes="${FLASH_TIMES}" dur="${period}s" begin="${n(-begin)}s" repeatCount="indefinite"/>${acc}</g>`,
222 inner,
223 )
224
225const catArt = (storm: boolean) =>
226 // tail
227 `<g><animateTransform attributeName="transform" type="rotate" values="-5 -34 -8;7 -34 -8;-5 -34 -8" dur="5s" repeatCount="indefinite"/>` +
228 '<path d="M-36 -8 Q-62 -8 -52 10 Q-44 18 -10 12" fill="none" stroke="#7d7f8c" stroke-width="9" stroke-linecap="round"/><path d="M-52 10 Q-44 18 -26 15" fill="none" stroke="#595b68" stroke-width="9" stroke-linecap="round"/></g>' +
229 // the loaf of a body, breathing
230 `<g><animateTransform attributeName="transform" type="scale" values="1 1;1 1.04;1 1" dur="3.6s" repeatCount="indefinite"/>` +
231 '<ellipse cx="0" cy="-22" rx="40" ry="22" fill="#8d8f9b"/><ellipse cx="4" cy="-8" rx="32" ry="9" fill="#a9abb6" opacity=".6"/>' +
232 '<path d="M-20 -40 q4 10 0 20 M-6 -43 q4 10 0 22 M8 -43 q4 10 0 22 M20 -39 q4 10 0 18" fill="none" stroke="#6d6f7c" stroke-width="3" stroke-linecap="round"/>' +
233 '<ellipse cx="36" cy="-3" rx="10" ry="4.4" fill="#9a9ca8"/>' +
234 // the head, resting on its paws
235 '<circle cx="30" cy="-17" r="15" fill="#8d8f9b"/>' +
236 '<path d="M17 -27 L20 -43 L31 -31Z M43 -27 L40 -43 L29 -31Z" fill="#8d8f9b"/><path d="M20 -30 L21 -38 L27 -32Z M40 -30 L39 -38 L33 -32Z" fill="#e8a9b0"/>' +
237 `<g><animateTransform attributeName="transform" type="rotate" values="0 40 -40;0 40 -40;-14 40 -40;0 40 -40;0 40 -40" keyTimes="0;.86;.9;.94;1" dur="9s" repeatCount="indefinite"/><path d="M43 -27 L40 -43 L29 -31Z" fill="#8d8f9b"/></g>` +
238 '<path d="M23 -17 q4 4 8 0 M36 -17 q4 4 8 0" fill="none" stroke="#2a2a33" stroke-width="1.6" stroke-linecap="round"/><path d="M32 -11 l3 0 l-1.5 2.4Z" fill="#e8a9b0"/>' +
239 (storm ? openEyes() : '') +
240 '<path d="M40 -10 l14 -3 M40 -8 l14 1 M24 -10 l-12 -3 M24 -8 l-12 1" stroke="#d9dbe6" stroke-width=".7" opacity=".8"/>' +
241 '</g>' +
242 // sleep, drifting up; a flash of lightning wakes the cat, so the z's go with it
243 (storm ? quiet(ZZZ) : ZZZ)
244
245const MUG =
246 '<rect x="-9" y="-18" width="18" height="18" rx="3" fill="#e8e2d4"/><path d="M9 -13 q8 0 8 6 q0 6 -8 6" fill="none" stroke="#e8e2d4" stroke-width="3"/><ellipse cy="-18" rx="9" ry="2.4" fill="#6b4a2e"/>' +
247 [0, 1]
248 .map(
249 k =>
250 `<path d="M${k * 6 - 3} -22 q-4 -8 0 -14 q4 -6 0 -12" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity="0"><animateTransform attributeName="transform" type="translate" values="0 0;0 -10" dur="3s" begin="${n(-k * 1.5)}s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;.5;0" dur="3s" begin="${n(-k * 1.5)}s" repeatCount="indefinite"/></path>`,
251 )
252 .join('')
253
254const PLANT =
255 '<path d="M-13 -22 H13 L10 0 H-10Z" fill="#b5651d"/><rect x="-14" y="-26" width="28" height="6" rx="2" fill="#c97b2d"/>' +
256 `<g><animateTransform attributeName="transform" type="rotate" values="-2 0 -26;2 0 -26;-2 0 -26" dur="4.5s" repeatCount="indefinite"/>` +
257 [-60, -30, 0, 30, 60]
258 .map((a, k) => `<path d="M0 -26 Q${a * 0.5} -${46 + (k % 2) * 8} ${a * 0.9} -${58 + (k % 3) * 6} Q${a * 0.3} -${42 + (k % 2) * 8} 0 -26Z" fill="${pick(['#3f9a55', '#58b06b', '#2f7d46'], k)}"/>`)
259 .join('') +
260 '</g>'
261
262const lampArt = (storm: boolean) =>
263 '<rect x="-3" y="-60" width="6" height="60" fill="#3b2f26"/><ellipse cy="-1" rx="16" ry="4" fill="#3b2f26"/>' +
264 '<path d="M-20 -60 L-12 -92 H12 L20 -60Z" fill="#ffcf88"/><ellipse cy="-60" rx="20" ry="4" fill="#ffe3b3"/>' +
265 // in a storm the shade goes dark with each stroke, as if the power flickered
266 (storm ? flicker(true, '<path d="M-20 -60 L-12 -92 H12 L20 -60Z" fill="#3b2f26" opacity=".78"/><ellipse cy="-60" rx="20" ry="4" fill="#5a4636" opacity=".78"/>', true) : '') +
267 flicker(
268 storm,
269 `<ellipse cy="-76" rx="26" ry="38" fill="#ffd9a0" opacity=".2"><animate attributeName="opacity" values=".16;.26;.16" dur="4.4s" repeatCount="indefinite"/></ellipse>`,
270 )
271
272const BOOKS = [
273 [-18, 11, '#c0503f'],
274 [-16, 9, '#3f6fb0'],
275 [-14, 10, '#e0b43a'],
276]
277 .map(([dx, ht, c], i) => `<rect x="${dx}" y="${-(i + 1) * 11}" width="${36 - i * 4}" height="10" rx="1.4" fill="${c}"/>`)
278 .join('')
279
280const ROOM =
281 // the wall and floor below the sill
282 `<rect x="0" y="${GB + 32}" width="${W}" height="${H - GB - 32}" fill="url(#wall)"/>` +
283 Array.from({ length: 11 }, (_, k) => `<rect x="${k * 40 + 10}" y="${GB + 32}" width="16" height="${H - GB - 32 - 70}" fill="#fff" opacity=".025"/>`).join('') +
284 // two shelves with books and a few things
285 [1118, 1232]
286 .map((y, row) => {
287 let books = ''
288 let x = 52
289 for (let k = 0; x < 340; k++) {
290 const w = 8 + rnd(k + row * 30 + 700) * 8
291 const ht = 34 + rnd(k + row * 30 + 720) * 22
292 books += `<rect x="${n(x)}" y="${n(y - ht)}" width="${n(w)}" height="${n(ht)}" fill="${pick(['#8e3f3a', '#35577d', '#b58a35', '#4a6b4c', '#6c4a7a', '#a8645a'], k + row * 3)}"/>`
293 x += w + 1
294 }
295
296 return `${books}<rect x="38" y="${y}" width="324" height="8" fill="#5a3f28"/>`
297 })
298 .join('') +
299 // the floor, and a rug
300 `<rect x="0" y="1334" width="${W}" height="66" fill="#4a3322"/><rect x="0" y="1330" width="${W}" height="6" fill="#2a1d14"/>` +
301 '<ellipse cx="210" cy="1372" rx="150" ry="20" fill="#7a3a4a"/><ellipse cx="210" cy="1372" rx="120" ry="14" fill="none" stroke="#e8c9a0" stroke-width="2" opacity=".6"/>'
302
303const SILL =
304 `<rect x="0" y="${GB}" width="${W}" height="14" fill="#8a6440"/><rect x="0" y="${GB + 14}" width="${W}" height="18" fill="#5e4129"/>` +
305 `<rect x="0" y="${GB}" width="${W}" height="3" fill="#b88a5a" opacity=".7"/>`
306
307const FRAME =
308 `<rect x="0" y="0" width="${GL}" height="${GB}" fill="#e8e2d4"/><rect x="${GR}" y="0" width="${W - GR}" height="${GB}" fill="#e8e2d4"/>` +
309 `<rect x="0" y="0" width="${W}" height="${GT}" fill="#e8e2d4"/><rect x="${GL}" y="${GT}" width="${GR - GL}" height="4" fill="#bfb8a6"/>` +
310 `<rect x="197" y="${GT}" width="6" height="${GB - GT}" fill="#e8e2d4"/><rect x="${GL}" y="500" width="${GR - GL}" height="6" fill="#e8e2d4"/>` +
311 `<rect x="${GL - 3}" y="${GT}" width="3" height="${GB - GT}" fill="#bfb8a6"/><rect x="${GR}" y="${GT}" width="3" height="${GB - GT}" fill="#bfb8a6"/>` +
312 // the latch
313 `<rect x="188" y="740" width="24" height="8" rx="3" fill="#a89a7a"/>`
314
315const WORK_DARK = 0.3
316
317const buildScene = (w: Rain, elapsed: number, vh: number, now: number) => {
318 const view = Math.min(H, Math.max(vh, 240))
319 const y0 = H - view
320 const storm = w.working
321 const sinceEnd = (now - w.endedAt) / 1000
322 const done = !w.working && w.endedAt > 0 && sinceEnd < RAINBOW_LIFE
323 const since = (now - w.startedAt) / 1000
324 const darkFrom = storm ? Math.min(1, since / 1.4) * WORK_DARK : 0
325
326 const defs =
327 '<defs>' +
328 '<linearGradient id="night" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0b1130"/><stop offset=".55" stop-color="#2b2255"/><stop offset="1" stop-color="#5a2f5a"/></linearGradient>' +
329 '<linearGradient id="glow" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff9a5c" stop-opacity="0"/><stop offset="1" stop-color="#ff9a5c" stop-opacity=".5"/></linearGradient>' +
330 '<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3b2c3a"/><stop offset="1" stop-color="#241a24"/></linearGradient>' +
331 '<linearGradient id="fog" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfe0ff" stop-opacity="0"/><stop offset="1" stop-color="#cfe0ff" stop-opacity=".16"/></linearGradient>' +
332 `<clipPath id="glass"><rect x="${GL}" y="${GT}" width="${GR - GL}" height="${GB - GT}"/></clipPath>` +
333 '</defs>'
334
335 const drops = Array.from({ length: 10 }, (_, k) => runner(k, storm)).join('')
336 const outside =
337 SKY +
338 CITY +
339 BOKEH +
340 (done ? rainbow(Math.max(0, sinceEnd)) : '') +
341 // a storm darkens the sky and then flashes; at rest the rain is light
342 (storm
343 ? `<rect x="${GL}" y="${GT}" width="${GR - GL}" height="${GB - GT}" fill="#06112a" opacity="${r3(darkFrom)}"><animate data-once="1" attributeName="opacity" values="${r3(darkFrom)};${WORK_DARK}" dur="${n(1.4 * (1 - darkFrom / WORK_DARK))}s" fill="freeze"/></rect>` + BOLTS.map(([x, period, begin]) => bolt(x, period, begin)).join('')
344 : '') +
345 streaks(storm ? 46 : 18, storm)
346
347 const glass =
348 `<g clip-path="url(#glass)">${outside}` +
349 `<rect x="${GL}" y="700" width="${GR - GL}" height="${GB - 700}" fill="url(#fog)"/>` +
350 `<polygon points="60,${GT} 100,${GT} 20,${GB} -20,${GB}" fill="#fff" opacity=".04"/>` +
351 still +
352 drops +
353 '</g>'
354
355 const room =
356 ROOM +
357 `<g transform="translate(335 ${GB})">${lampArt(storm)}</g>` +
358 SILL +
359 `<g transform="translate(150 ${GB})">${catArt(storm)}</g>` +
360 `<g transform="translate(64 ${GB})">${MUG}</g>` +
361 `<g transform="translate(250 ${GB})">${BOOKS}</g>` +
362 `<g transform="translate(306 ${GB})">${PLANT}</g>` +
363 lamplight(storm)
364
365 return (
366 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${n(y0)} ${W} ${n(view)}" width="${W}" height="${n(view)}" preserveAspectRatio="xMidYMax slice">${defs}` +
367 `<rect x="0" y="${n(y0)}" width="${W}" height="${n(view)}" fill="#1b1624"/>` +
368 retime(glass + FRAME + room, elapsed) +
369 '</svg>'
370 )
371}
372// ---scene-end
373
374const TERMINAL_ART: [string, string][] = [
375 [' ┌───────┬───────┐', '#e8e2d4'],
376 [' │ ╲ ╲ ╲ │ ╲ ╲ ╲ │', '#8fb4ff'],
377 [' │ ╲ ╲ │ ╲ ╲ │', '#8fb4ff'],
378 [' ├───────┼───────┤', '#e8e2d4'],
379 [' │ ✦ ✦ ✦│ ✦ ✦ │', '#ffb347'],
380 [' └───────┴───────┘', '#e8e2d4'],
381 [' ━━━━ ᓚᘏᗢ ━━━━━━', '#a9abb6'],
382]
383
384const clock = (ms: number) => {
385 const s = Math.max(0, Math.floor(ms / 1000))
386
387 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
388}
389
390const label = (w: Rain, now: number, T: Words) => (w.working ? T.working(clock(now - w.startedAt), w.tools) : T.idle)
391
392const rain = atom(
393 { plugin: 'rain-window', key: 'window' } as const,
394 { working: false, prev: false, tools: 0, startedAt: 0, endedAt: 0, tick: 0 } as Rain,
395)
396
397// The picture is built again only when the state it draws changes; the same text keeps it running.
398let t0 = 0
399let cache = { key: '', svg: '' }
400
401export const register: Register = (on, options) => {
402 // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
403 const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
404
405 on('session.start', async ($, e, next) => {
406 t0 = await $.clock.now()
407 await $.command.register({
408 name: 'rain',
409 description: 'Open the rainy window pane',
410 })
411 // Wakes the pane each second while Claude works, for the stopwatch in its top line.
412 $.clock.every(1000, () => {
413 void read($, rain)
414 .then(r => (r.working ? update($, rain, v => ({ ...v, tick: v.tick + 1 })) : undefined))
415 .catch(() => {})
416 })
417
418 return next(e)
419 })
420
421 on('command.run', { command: 'rain' }, async $ => {
422 await $.ui.open({ id: PANE, title: T.title })
423
424 return { text: T.opened }
425 })
426
427 // The main loop's turns only: a subagent's run is not Claude at work.
428 on('turn.start', async ($, e, next) => {
429 if (e.agentId === undefined) {
430 const now = await $.clock.now()
431 await update($, rain, v => ({ ...v, prev: false, working: true, tools: 0, startedAt: now }))
432 }
433
434 return next(e)
435 }).catch(($, e, next) => next(e))
436
437 on('turn.complete', async ($, e, next) => {
438 if (e.agentId === undefined) {
439 const now = await $.clock.now()
440 await update($, rain, v => ({ ...v, prev: true, working: false, endedAt: now }))
441 }
442
443 return next(e)
444 }).catch(($, e, next) => next(e))
445
446 on('tool.call', async ($, e, next) => {
447 await update($, rain, v => ({ ...v, tools: v.tools + 1 }))
448
449 return next(e)
450 }).catch(($, e, next) => next(e))
451
452 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
453 const els = $.ui.resolve(e)
454 const w = await read($, rain)
455 const now = await $.clock.now()
456 if (t0 === 0) t0 = now
457 const cols = e.props?.bodyColumns ?? 40
458 const rows = e.props?.scroll?.bodyRows ?? 20
459 const line = label(w, now, T)
460
461 if (e.surface !== 'terminal') {
462 const { Box, Text, Svg } = els as ElementTable<'desktop'>
463 // The pane's px: its cells over the code font's advance (about 7.8 px) and line height
464 // (about 19.5 px). Short of the whole picture, the top of the window is cut, never the sill.
465 const width = Math.max(160, Math.round(cols * 7.8))
466 const full = Math.round((width * H) / W)
467 const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
468 const height = Math.min(full, tall)
469 // the fade into the storm lasts 1.4 s; a rebuild after that draws it settled
470 const settling = w.working && (now - w.startedAt) / 1000 < 1.4
471 const key = `${w.working}|${settling}|${w.startedAt}|${w.endedAt}|${height}|${width}`
472 if (cache.key !== key) cache = { key, svg: buildScene(w, (now - t0) / 1000, (height * W) / width, now) }
473
474 return (
475 <Box flexDirection="column" justifyContent="flex-end" minHeight={rows}>
476 <Box justifyContent="center">
477 <Text bold={w.working} dimColor={!w.working}>
478 {line}
479 </Text>
480 </Box>
481 <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
482 </Box>
483 )
484 }
485
486 const { Box, Text } = els as ElementTable<'terminal'>
487
488 return (
489 <Box flexDirection="column">
490 {TERMINAL_ART.map(([row, color]) => (
491 <Text color={color}>{row}</Text>
492 ))}
493 <Text>{line}</Text>
494 <Text dimColor>{T.note}</Text>
495 </Box>
496 )
497 })
498}
499types/index.d.ts 15 lines1export type Rain = {
2 working: boolean
3 prev: boolean
4 tools: number
5 startedAt: number
6 endedAt: number
7 tick: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'rain-window': { window: Rain }
13 }
14}
15