A tall building cross-section pane: every finished turn adds a floor; at 20 floors the tower tops out with fireworks and a new one starts beside it.

<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 543 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Tower } from '../types'
5
6const PANE = 'tower-cut'
7const STRINGS = {
8 en: {
9 title: 'Tower',
10 alt: 'A cross-section of a building where you can see into every room. A floor goes up with each finished turn, and at 20 floors it tops out with fireworks.',
11 working: (clock: string, tools: number, floor: number) => `Working ${clock} · tools ${tools} · building floor ${floor}`,
12 topped: (tower: number, max: number) => `Topped out! · tower ${tower} ${max}/${max} floors · completed ${tower}`,
13 idle: (tower: number, floors: number, max: number, done: number) => `Idle · tower ${tower} ${floors}/${max} floors · completed ${done}`,
14 opened: 'Opened the tower pane.',
15 note: 'The animation shows in the desktop app.',
16 },
17 ko: {
18 title: '빌딩',
19 alt: '층마다 방 안이 보이는 빌딩 단면. 턴이 끝날 때마다 한 층씩 올라가고, 20층이 되면 불꽃놀이와 함께 준공됩니다.',
20 working: (clock: string, tools: number, floor: number) => `작업 중 ${clock} · 도구 ${tools}회 · ${floor}층 공사`,
21 topped: (tower: number, max: number) => `준공! · ${tower}번째 빌딩 ${max}/${max}층 · 준공 ${tower}채`,
22 idle: (tower: number, floors: number, max: number, done: number) => `쉬는 중 · ${tower}번째 빌딩 ${floors}/${max}층 · 준공 ${done}채`,
23 opened: '빌딩을 열었습니다.',
24 note: '그림 움직임은 데스크톱 앱에서 보입니다.',
25 },
26}
27
28type Words = (typeof STRINGS)['en']
29
30// ---scene-start
31const W = 400
32const H = 1500
33const GROUND = 1380
34const FH = 56 // one floor: a 4 px slab on top of a 52 px room
35const MAX = 20
36const BX = 80 // the three rooms start here, 80 wide each
37
38const n = (v: number) => String(Math.round(v * 10) / 10)
39const r3 = (v: number) => String(Math.round(v * 1000) / 1000)
40const EASE = '.45 0 .55 1'
41const pick = <T,>(list: T[], s: number): T => list[((s % list.length) + list.length) % list.length]
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
48const glow = (x: number, y: number, r: number, s: number) =>
49 `<circle cx="${x}" cy="${y}" r="${r}" fill="#ffe9a8" opacity=".3"><animate attributeName="opacity" values=".16;.42;.16" dur="${3 + (s % 4)}s" begin="${-(s % 5)}s" repeatCount="indefinite"/></circle>`
50
51const frame = (x: number, y: number, w: number, ht: number, c: string) =>
52 `<rect x="${x - 1.5}" y="${y - 1.5}" width="${w + 3}" height="${ht + 3}" fill="#8b6b45"/><rect x="${x}" y="${y}" width="${w}" height="${ht}" fill="${c}"/>` +
53 `<path d="M${x} ${y + ht} l${w * 0.35} ${-ht * 0.5} l${w * 0.25} ${ht * 0.3} l${w * 0.2} ${-ht * 0.35} l${w * 0.2} ${ht * 0.55}Z" fill="#fff" opacity=".35"/>`
54
55const base = (wall: string) => `<rect width="80" height="52" fill="${wall}"/><rect y="48" width="80" height="4" fill="#000" opacity=".1"/>`
56
57// ---- rooms, drawn at 0,0 over 80 x 52 ---------------------------------------------------------
58const bedroom = (s: number) =>
59 base('#f6e7d6') +
60 frame(10, 8, 18, 14, pick(['#9fc7e8', '#f0b5a8', '#b9d9a5'], s)) +
61 `<rect x="4" y="28" width="5" height="20" rx="2" fill="#8b5a2b"/><rect x="6" y="38" width="46" height="10" rx="2" fill="${pick(['#6f95d1', '#d98a7b', '#7bb08a'], s)}"/><ellipse cx="15" cy="37" rx="8" ry="3.6" fill="#fff"/>` +
62 '<rect x="58" y="38" width="12" height="10" fill="#8b5a2b"/><rect x="61" y="30" width="6" height="8" rx="1" fill="#f1d27a"/>' +
63 glow(64, 32, 13, s)
64
65const office = (s: number) =>
66 base('#dfe9f0') +
67 '<rect x="8" y="36" width="44" height="3" fill="#6d5036"/><rect x="10" y="39" width="3" height="9" fill="#6d5036"/><rect x="47" y="39" width="3" height="9" fill="#6d5036"/>' +
68 '<rect x="22" y="21" width="20" height="14" rx="1" fill="#26323f"/>' +
69 `<rect x="24" y="23" width="16" height="10" fill="#7fd0ff"><animate attributeName="opacity" values=".55;1;.55" dur="${3 + (s % 3)}s" begin="${-(s % 4)}s" repeatCount="indefinite"/></rect>` +
70 '<rect x="30" y="35" width="4" height="2" fill="#26323f"/><rect x="12" y="43" width="9" height="3" rx="1.5" fill="#556"/>' +
71 '<rect x="60" y="8" width="16" height="40" fill="#8b6b45"/>' +
72 [0, 1, 2]
73 .map(r =>
74 [0, 1, 2, 3]
75 .map(c => `<rect x="${61.5 + c * 3.6}" y="${11 + r * 12}" width="3" height="9" fill="${pick(['#c9503f', '#3f7bc9', '#e0b43a', '#4aa26a'], r * 4 + c + s)}"/>`)
76 .join(''),
77 )
78 .join('')
79
80const living = (s: number) =>
81 base('#f2ecd4') +
82 frame(26, 8, 24, 12, pick(['#e8c36a', '#8fb7d9', '#c9a0d0'], s)) +
83 '<rect x="6" y="34" width="38" height="12" rx="3" fill="#c0695a"/><rect x="6" y="26" width="38" height="10" rx="3" fill="#a9564a"/><rect x="9" y="30" width="10" height="8" rx="2" fill="#e8a35a"/>' +
84 '<rect x="52" y="26" width="22" height="14" fill="#202833"/>' +
85 '<rect x="54" y="28" width="18" height="10" fill="#4aa3df"><animate attributeName="fill" values="#4aa3df;#e8a14a;#6ccf8a;#4aa3df" dur="9s" repeatCount="indefinite"/></rect>' +
86 '<rect x="50" y="40" width="26" height="8" fill="#8b6b45"/>' +
87 '<g><animateTransform attributeName="transform" type="rotate" values="-3 71 40;3 71 40;-3 71 40" dur="4s" repeatCount="indefinite"/><ellipse cx="71" cy="36" rx="3" ry="6" fill="#3f9a5a"/></g>'
88
89const cafe = (s: number) =>
90 base('#f7e2c6') +
91 '<path d="M20 0 V10 M52 0 V10" stroke="#555" stroke-width=".8"/><path d="M15 10 h10 l-2 6 ht-6Z M47 10 h10 l-2 6 ht-6Z" fill="#d94f4f"/>' +
92 glow(20, 18, 11, s) +
93 '<rect x="6" y="36" width="50" height="12" fill="#8b5a2b"/><rect x="4" y="34" width="54" height="3" fill="#c79a64"/>' +
94 '<rect x="14" y="30" width="5" height="4" fill="#fff"/><rect x="30" y="30" width="5" height="4" fill="#fff"/>' +
95 `<path d="M16 28 q-3 -4 0 -8 M32 28 q3 -4 0 -8" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round"><animate attributeName="opacity" values=".15;.8;.15" dur="2.6s" begin="${-(s % 3)}s" repeatCount="indefinite"/></path>` +
96 '<rect x="62" y="10" width="14" height="18" fill="#2f4a3a"/><path d="M64 15 h10 M64 19 h8 M64 23 h10" stroke="#fff" stroke-width="1.2" stroke-dasharray="3 1.5" opacity=".8"/>'
97
98const studio = (s: number) =>
99 base('#e4eee0') +
100 '<path d="M14 48 L22 22 L30 48 M22 22 V48" stroke="#8b5a2b" stroke-width="2" fill="none" stroke-linecap="round"/>' +
101 `<rect x="12" y="19" width="20" height="16" fill="#fff" stroke="#8b5a2b" stroke-width="1.2"/><circle cx="18" cy="26" r="4" fill="${pick(['#e8693c', '#3f7bc9', '#e0b43a'], s)}"/><path d="M14 33 l7 -5 l8 5Z" fill="#4aa26a"/>` +
102 '<rect x="50" y="40" width="10" height="8" fill="#b5651d"/>' +
103 '<g><animateTransform attributeName="transform" type="rotate" values="-4 55 40;4 55 40;-4 55 40" dur="3.6s" repeatCount="indefinite"/><ellipse cx="52" cy="33" rx="3" ry="8" fill="#4aa26a"/><ellipse cx="58" cy="33" rx="3" ry="8" fill="#3f9a5a"/><ellipse cx="55" cy="30" rx="2.6" ry="9" fill="#58b06b"/></g>' +
104 '<path d="M68 0 V12 M72 0 V18" stroke="#3f9a5a" stroke-width="1.6"/><circle cx="68" cy="13" r="2.4" fill="#58b06b"/><circle cx="72" cy="19" r="2.4" fill="#4aa26a"/>' +
105 '<rect x="66" y="38" width="10" height="10" fill="#8b6b45"/>'
106
107const lobby = (s: number) =>
108 base('#e9eef2') +
109 '<rect x="26" y="6" width="28" height="6" fill="#2f7fd0"/>' +
110 '<rect x="22" y="14" width="36" height="34" fill="#bfe3f2" stroke="#6b7782" stroke-width="1.2"/>' +
111 '<rect x="22" y="14" width="18" height="34" fill="#9ed0e6" stroke="#6b7782" stroke-width="1"><animateTransform attributeName="transform" type="translate" values="0 0;0 0;-13 0;-13 0;0 0" keyTimes="0;.2;.3;.7;.8" dur="9s" repeatCount="indefinite"/></rect>' +
112 '<rect x="40" y="14" width="18" height="34" fill="#9ed0e6" stroke="#6b7782" stroke-width="1"><animateTransform attributeName="transform" type="translate" values="0 0;0 0;13 0;13 0;0 0" keyTimes="0;.2;.3;.7;.8" dur="9s" repeatCount="indefinite"/></rect>' +
113 '<rect x="60" y="38" width="16" height="10" fill="#8b6b45"/><circle cx="68" cy="18" r="5" fill="#fff" stroke="#556" stroke-width="1"/><path d="M68 18 V14.5 M68 18 L70.5 19" stroke="#556" stroke-width=".9"/>' +
114 glow(10, 12, 8, s)
115
116const ROOMS = [bedroom, office, living, cafe, studio, lobby]
117
118const person = (x: number, s: number) =>
119 `<g transform="translate(${x} 48)"><g><animateTransform attributeName="transform" type="translate" values="0 0;24 0;0 0" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="${9 + (s % 5) * 2}s" begin="${-(s % 7)}s" repeatCount="indefinite"/>` +
120 `<circle cy="-22" r="3.6" fill="#f3c9a0"/><rect x="-3.6" y="-18" width="7.2" height="13" rx="3" fill="${pick(['#d9534f', '#3f7bc9', '#e0b43a', '#4aa26a', '#8e5fc1'], s)}"/>` +
121 '<rect x="-3" y="-6" width="2.6" height="6" fill="#39414d"/><rect x=".4" y="-6" width="2.6" height="6" fill="#39414d"/></g></g>'
122
123// One finished floor: rooms, walls, the slab above them, and someone walking about.
124const floor = (i: number) => {
125 const y0 = GROUND - (i + 1) * FH
126 let out = ''
127 for (let j = 0; j < 3; j++) {
128 const s = i * 3 + j
129 const type = i === 0 ? [3, 5, 4][j] : (i * 7 + j * 3 + (i >> 1)) % 5
130 const who = i > 0 && (i + j * 2) % 3 === 1 ? person(18 + (s % 3) * 8, s) : i === 0 && j === 1 ? person(10, s) : ''
131 out += `<g transform="translate(${BX + j * 80} ${y0 + 4})">${ROOMS[type](s)}${who}</g>`
132 }
133
134 return (
135 out +
136 `<rect x="76" y="${y0}" width="4" height="${FH}" fill="#7d858d"/><rect x="320" y="${y0}" width="4" height="${FH}" fill="#7d858d"/>` +
137 `<rect x="159" y="${y0 + 4}" width="2" height="52" fill="#aab2ba"/><rect x="239" y="${y0 + 4}" width="2" height="52" fill="#aab2ba"/>` +
138 `<rect x="72" y="${y0}" width="256" height="4" fill="#5f6872"/>`
139 )
140}
141
142// The floor going up: bare columns, orange scaffolding, a worker and a welder's sparks.
143const SITE = '#f08a1c'
144const site = (i: number) => {
145 const y0 = GROUND - (i + 1) * FH
146 const yb = y0 + FH
147 const cols = [76, 158, 238, 320].map(x => `<rect x="${x}" y="${y0}" width="4" height="${FH}" fill="#9aa1a8"/>`).join('')
148 const xs = [68, 148, 228, 332]
149 const bars =
150 xs.map(x => `M${x} ${y0 - 2} V${yb}`).join(' ') +
151 ` M68 ${y0 + 2} H332 M68 ${y0 + 28} H332` +
152 xs
153 .slice(0, 3)
154 .map((x, k) => (k % 2 === 0 ? `M${x} ${y0 + 28} L${xs[k + 1]} ${y0 + 2}` : `M${x} ${y0 + 2} L${xs[k + 1]} ${y0 + 28}`))
155 .join(' ')
156
157 return (
158 cols +
159 `<path d="${bars}" fill="none" stroke="${SITE}" stroke-width="2.2" stroke-linecap="round"/>` +
160 `<rect x="84" y="${yb - 14}" width="30" height="14" fill="#c9503f" opacity=".85"/><rect x="262" y="${yb - 8}" width="40" height="8" fill="#8a929a"/>` +
161 // worker
162 `<g transform="translate(120 ${yb})"><g><animateTransform attributeName="transform" type="translate" values="0 0;60 0;0 0" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="14s" repeatCount="indefinite"/>` +
163 `<circle cy="-22" r="3.6" fill="#f3c9a0"/><path d="M-4.4 -23 a4.4 4.4 0 0 1 8.8 0Z" fill="#ffd21f"/><rect x="-3.6" y="-18" width="7.2" height="13" rx="3" fill="${SITE}"/><rect x="-3" y="-6" width="2.6" height="6" fill="#39414d"/><rect x=".4" y="-6" width="2.6" height="6" fill="#39414d"/></g></g>` +
164 // sparks
165 `<g transform="translate(206 ${yb - 4})">` +
166 [0, 1, 2, 3]
167 .map(
168 k =>
169 `<circle r="1.3" fill="#ffe27a" opacity="0"><animate attributeName="opacity" values="0;1;0" dur="${0.5 + k * 0.17}s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" values="0 0;${(k - 1.5) * 9} ${-12 - k * 3}" dur="${0.5 + k * 0.17}s" repeatCount="indefinite"/></circle>`,
170 )
171 .join('') +
172 '</g>'
173 )
174}
175
176// ---- sky, skyline, ground ---------------------------------------------------------------
177const SKY = [
178 ['#6ec3ee', '#d3f0fb'],
179 ['#4a5f9e', '#fcd9a0'],
180 ['#070d24', '#243a78'],
181]
182
183const clouds = (y0: number, stage: number) => {
184 const fill = ['#fff', '#ffd2b0', '#3a4c86'][stage]
185 const op = ['.9', '.7', '.45'][stage]
186
187 return [0, 1, 2]
188 .map(
189 k =>
190 `<g transform="translate(0 ${n(y0 + 120 + k * 150 + (k % 2) * 30)})"><g><animateTransform attributeName="transform" type="translate" values="-80 0;480 0" dur="${60 + k * 18}s" begin="${-k * 23}s" repeatCount="indefinite"/>` +
191 `<g fill="${fill}" opacity="${op}"><ellipse rx="34" ry="11"/><ellipse cx="-16" cy="-8" rx="20" ry="11"/><ellipse cx="14" cy="-11" rx="24" ry="13"/></g></g></g>`,
192 )
193 .join('')
194}
195
196const celestial = (y0: number, stage: number) => {
197 if (stage === 0) {
198 return `<circle cx="316" cy="${n(y0 + 90)}" r="42" fill="#fff6b0" opacity=".4"><animate attributeName="r" values="38;48;38" dur="6s" repeatCount="indefinite"/></circle><circle cx="316" cy="${n(y0 + 90)}" r="24" fill="#ffe27a"/>`
199 }
200 if (stage === 1) {
201 return `<circle cx="70" cy="${GROUND - 210}" r="60" fill="#ffb066" opacity=".35"/><circle cx="70" cy="${GROUND - 210}" r="36" fill="#ff9a4a"/>`
202 }
203
204 return `<circle cx="330" cy="${n(y0 + 95)}" r="34" fill="#cfd8ff" opacity=".12"/><circle cx="330" cy="${n(y0 + 95)}" r="20" fill="#f4f1da"/><circle cx="324" cy="${n(y0 + 90)}" r="4" fill="#d6d2b6"/><circle cx="337" cy="${n(y0 + 101)}" r="3" fill="#d6d2b6"/>`
205}
206
207const stars = (y0: number) =>
208 Array.from({ length: 36 }, (_, k) => {
209 const y = y0 + rnd(k * 2 + 1) * (GROUND - y0 - 220)
210
211 return `<circle cx="${n(rnd(k * 2) * W)}" cy="${n(y)}" r="${n(0.7 + rnd(k + 90) * 1)}" fill="#fff" opacity=".3"><animate attributeName="opacity" values=".15;.95;.15" dur="${n(2 + rnd(k + 5) * 4)}s" begin="${n(-rnd(k + 9) * 6)}s" repeatCount="indefinite"/></circle>`
212 }).join('')
213
214// Finished towers stand behind, dark, with a few lit windows.
215const SLOTS = [
216 [0, 46, 640],
217 [352, 48, 720],
218 [40, 34, 480],
219 [326, 34, 560],
220 [96, 60, 780],
221 [236, 70, 900],
222] as const
223
224const skyline = (towers: number, stage: number) => {
225 const col = ['#9fb8cb', '#6c6490', '#16224a'][stage]
226 const win = ['#ffffff', '#ffd68a', '#ffe08a'][stage]
227 const op = ['.45', '.7', '.9'][stage]
228
229 return SLOTS.slice(0, Math.min(SLOTS.length, towers))
230 .map(([x, w, ht]) => {
231 const top = GROUND - ht
232 const lines = [x + 8, x + w / 2 - 1.5, x + w - 12].filter((_, k) => w > 40 || k !== 1)
233
234 return (
235 `<rect x="${x}" y="${top}" width="${w}" height="${ht}" fill="${col}"/><rect x="${x + w / 2 - 1}" y="${top - 10}" width="2" height="10" fill="${col}"/>` +
236 lines.map(lx => `<path d="M${n(lx)} ${top + 14} V${GROUND - 4}" stroke="${win}" stroke-width="3" stroke-dasharray="3 11" opacity="${op}"/>`).join('')
237 )
238 })
239 .join('')
240}
241
242const car = (x: number, color: string) =>
243 `<rect x="${x}" y="1426" width="44" height="12" rx="4" fill="${color}"/><path d="M${x + 9} 1426 l5 -9 h16 l5 9Z" fill="${color}"/><path d="M${x + 15} 1426 l3 -7 h8 l3 7Z" fill="#bfe3f2"/>` +
244 `<circle cx="${x + 10}" cy="1439" r="4.4" fill="#222"/><circle cx="${x + 34}" cy="1439" r="4.4" fill="#222"/>`
245
246const GROUND_SVG =
247 `<rect x="0" y="${GROUND}" width="${W}" height="${H - GROUND}" fill="#5a4636"/>` +
248 `<rect x="0" y="${GROUND}" width="${W}" height="14" fill="#6aa563"/><rect x="0" y="${GROUND + 12}" width="${W}" height="3" fill="#8a929a"/>` +
249 // the basement, cut open: columns and two parked cars
250 `<rect x="72" y="${GROUND + 15}" width="256" height="80" fill="#3d4450"/><rect x="72" y="${GROUND + 15}" width="256" height="4" fill="#5f6872"/>` +
251 [100, 160, 240, 300].map(x => `<rect x="${x}" y="${GROUND + 19}" width="6" height="76" fill="#59616c"/>`).join('') +
252 car(112, '#d94f4f') +
253 car(252, '#4f86d9') +
254 `<rect x="76" y="${GROUND + 24}" width="14" height="12" rx="2" fill="#2f7fd0"/><path d="M80 ${GROUND + 34} V${GROUND + 27} h3.4 a2.2 2.2 0 0 1 0 4.4 H80" fill="none" stroke="#fff" stroke-width="1.3"/>` +
255 `<rect x="0" y="${GROUND + 95}" width="${W}" height="${H - GROUND - 95}" fill="#4a3a2d"/>`
256
257const trees =
258 '<rect x="33" y="1344" width="6" height="36" fill="#6b4a2a"/><circle cx="36" cy="1334" r="18" fill="#3f9a5a"/><circle cx="28" cy="1342" r="11" fill="#58b06b"/>' +
259 '<rect x="365" y="1350" width="5" height="30" fill="#6b4a2a"/><circle cx="367" cy="1342" r="15" fill="#3f9a5a"/>' +
260 '<rect x="52" y="1330" width="2.4" height="50" fill="#555"/><path d="M53 1332 h12" stroke="#555" stroke-width="2.4"/><circle cx="66" cy="1334" r="3" fill="#ffe9a8"/>'
261
262// ---- the crane ---------------------------------------------------------------------
263const crane = (ct: number, working: boolean) => {
264 let zig = ''
265 for (let y = GROUND; y - 24 > ct; y -= 24) zig += `M346 ${y} L354 ${y - 24} M354 ${y} L346 ${y - 24} `
266
267 const a = 52
268 const b = 120
269 const swing = working
270 ? '<animateTransform attributeName="transform" type="scale" values="1 1;.82 1;1 1" keyTimes="0;.5;1" calcMode="spline" keySplines="' + EASE + ';' + EASE + '" dur="18s" repeatCount="indefinite"/>'
271 : ''
272 const cableTop = ct + 6
273 const y2 = working
274 ? `<animate attributeName="y2" values="${cableTop + a};${cableTop + b};${cableTop + a}" dur="9s" repeatCount="indefinite"/>`
275 : ''
276 const beamY = working
277 ? `<animate attributeName="y" values="${cableTop + a};${cableTop + b};${cableTop + a}" dur="9s" repeatCount="indefinite"/>`
278 : ''
279
280 return (
281 `<path d="M346 ${GROUND} V${ct} M354 ${GROUND} V${ct} ${zig}" stroke="#f2b01e" stroke-width="2" fill="none"/>` +
282 `<g transform="translate(350 0)"><g>${swing}` +
283 `<rect x="-115" y="${ct}" width="160" height="6" fill="#f2b01e"/><path d="M-115 ${ct + 6} L-60 ${ct + 6} L-2 ${ct} M-115 ${ct + 6} L-90 ${ct}" stroke="#d99a10" stroke-width="1.4" fill="none"/>` +
284 `<path d="M0 ${ct - 22} L-112 ${ct} M0 ${ct - 22} L42 ${ct}" stroke="#555" stroke-width="1"/><polygon points="-6,${ct} 6,${ct} 0,${ct - 22}" fill="#f2b01e"/>` +
285 `<rect x="26" y="${ct + 6}" width="18" height="14" fill="#6b747d"/><rect x="2" y="${ct + 6}" width="12" height="10" rx="1" fill="#e8f3fa" stroke="#8a929a"/>` +
286 `<line x1="-78" y1="${cableTop}" x2="-78" y2="${cableTop + a}" stroke="#333" stroke-width="1.2">${y2}</line>` +
287 `<rect x="-90" y="${cableTop + a}" width="24" height="6" fill="#d94f4f" stroke="#7a2a2a" stroke-width=".8">${beamY}</rect>` +
288 `</g></g>` +
289 `<circle cx="350" cy="${ct - 24}" r="2.2" fill="#ff4040"><animate attributeName="opacity" values="1;.1;1" dur="1.6s" repeatCount="indefinite"/></circle>`
290 )
291}
292
293// ---- topping out: helipad, flag and fireworks ----------------------------------------------
294const FIREWORK = ['#ff5a5f', '#ffd23f', '#5ad0ff', '#7dff9a', '#ff8ad8']
295const burst = (x: number, y: number, r: number, color: string, begin: number, dur: number) => {
296 const rays = Array.from({ length: 12 }, (_, k) => {
297 const a = (Math.PI * 2 * k) / 12
298
299 return `M${n(Math.cos(a) * r * 0.4)} ${n(Math.sin(a) * r * 0.4)} L${n(Math.cos(a) * r)} ${n(Math.sin(a) * r)}`
300 }).join(' ')
301
302 return (
303 `<g transform="translate(${x} ${y})" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" keyTimes="0;.06;.45;.75;1" dur="${dur}s" begin="${-begin}s" repeatCount="indefinite"/>` +
304 `<g><animateTransform attributeName="transform" type="scale" values=".1;1;1.12;1.12" keyTimes="0;.3;.75;1" dur="${dur}s" begin="${-begin}s" repeatCount="indefinite"/>` +
305 `<path d="${rays}" stroke="${color}" stroke-width="2.6" stroke-linecap="round" fill="none"/><circle r="3" fill="#fff"/></g></g>`
306 )
307}
308
309const topOut = (ry: number) =>
310 `<ellipse cx="200" cy="${ry - 3}" rx="40" ry="5" fill="#3a424b"/><ellipse cx="200" cy="${ry - 3.4}" rx="34" ry="3.6" fill="none" stroke="#fff" stroke-width="1"/>` +
311 `<path d="M192 ${ry - 5} v3.6 M208 ${ry - 5} v3.6 M192 ${ry - 3.2} h16" stroke="#fff" stroke-width="1.4"/>` +
312 `<rect x="300" y="${ry - 44}" width="2" height="44" fill="#ccc"/><rect x="302" y="${ry - 44}" width="20" height="12" fill="#e24a4a"><animateTransform attributeName="transform" type="scale" values="1 1;.82 1;1 1" dur="1.8s" repeatCount="indefinite"/></rect>` +
313 `<rect x="109" y="${ry - 38}" width="2" height="38" fill="#999"/><circle cx="110" cy="${ry - 40}" r="2.4" fill="#ff4040"><animate attributeName="opacity" values="1;.1;1" dur="1.4s" repeatCount="indefinite"/></circle>`
314
315const fireworks = (ry: number) =>
316 `<g data-fx="1">` +
317 [
318 [120, ry - 110, 38, 0, 3.6],
319 [292, ry - 140, 44, 1.4, 4],
320 [206, ry - 190, 50, 2.6, 4.4],
321 [60, ry - 190, 34, 0.8, 3.8],
322 [350, ry - 80, 30, 2, 3.4],
323 ]
324 .map(([x, y, r, b, d], k) => burst(x, y, r, FIREWORK[k % FIREWORK.length], b, d))
325 .join('') +
326 '</g>'
327
328// ---- the whole picture -------------------------------------------------------------------------
329// Looping motion shifts back by the time already elapsed, so redrawing the picture never makes it jump.
330// Tags marked data-once (a floor popping in, the sky darkening) are left alone and play from the start.
331const retime = (svg: string, elapsed: number) =>
332 svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
333 if (tag.includes('data-once')) {
334 return tag
335 }
336 const found = / begin="(-?[\d.]+)s"/.exec(tag)
337 const shifted = ` begin="${n((found ? parseFloat(found[1]) : 0) - elapsed)}s"`
338
339 return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
340 })
341
342const WORK_VEIL = 0.3
343
344const buildScene = (w: Tower, elapsed: number, vh: number) => {
345 const stage = w.floors <= 6 ? 0 : w.floors <= 13 ? 1 : 2
346 const celebrate = w.floors >= MAX && !w.working
347 const building = w.working && w.floors < MAX ? w.floors + 1 : w.floors
348 const topY = GROUND - building * FH
349 const ct = Math.max(70, topY - 90)
350 const view = Math.min(H, Math.max(vh, 240))
351 let y0 = H - view
352 const need = ct - 30 - (celebrate ? 120 : 0)
353 if (need < y0) y0 = Math.max(0, need)
354
355 const [skyTop, skyBottom] = SKY[stage]
356 const defs =
357 `<defs><linearGradient id="sky" gradientUnits="userSpaceOnUse" x1="0" y1="${n(y0)}" x2="0" y2="${GROUND}"><stop offset="0" stop-color="${skyTop}"/><stop offset="1" stop-color="${skyBottom}"/></linearGradient></defs>`
358
359 let floors = ''
360 for (let i = 0; i < w.floors; i++) {
361 const body = floor(i)
362 floors +=
363 w.fresh && i === w.floors - 1
364 ? `<g opacity="0"><animate data-once="1" attributeName="opacity" values="0;1" dur=".9s" fill="freeze"/><g><animateTransform data-once="1" attributeName="transform" type="translate" values="0 40;0 -4;0 0" keyTimes="0;.7;1" dur=".9s" fill="freeze"/>${body}</g></g>`
365 : body
366 }
367 if (w.floors > 0) {
368 const ry = GROUND - w.floors * FH
369 floors += `<rect x="68" y="${ry - 4}" width="264" height="4" fill="#59626c"/>` + (celebrate ? topOut(ry - 4) : '')
370 }
371
372 let veil = ''
373 if (w.working) {
374 veil = `<rect x="0" y="${n(y0)}" width="${W}" height="${n(GROUND - y0)}" fill="#06112a" opacity="${WORK_VEIL}"><animate data-once="1" attributeName="opacity" values="0;${WORK_VEIL}" dur="1.4s" fill="freeze"/></rect>`
375 } else if (w.prev) {
376 veil = `<rect x="0" y="${n(y0)}" width="${W}" height="${n(GROUND - y0)}" fill="#06112a" opacity="0"><animate data-once="1" attributeName="opacity" values="${WORK_VEIL};0" dur="1.4s" fill="freeze"/></rect>`
377 }
378
379 const scene =
380 `<rect x="0" y="${n(y0)}" width="${W}" height="${n(GROUND - y0 + 2)}" fill="url(#sky)"/>` +
381 (stage === 2 ? stars(y0) : '') +
382 celestial(y0, stage) +
383 clouds(y0, stage) +
384 skyline(w.towers, stage) +
385 GROUND_SVG +
386 crane(ct, w.working && w.floors < MAX) +
387 floors +
388 (w.working && w.floors < MAX ? site(w.floors) : '') +
389 (celebrate ? fireworks(GROUND - MAX * FH - 4) : '') +
390 trees
391
392 return (
393 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${n(y0)} ${W} ${n(view)}" width="${W}" height="${n(view)}" preserveAspectRatio="xMidYMax slice">${defs}` +
394 `<rect x="0" y="${n(y0)}" width="${W}" height="${n(view)}" fill="#1b242c"/>` +
395 retime(scene, elapsed) +
396 veil +
397 '</svg>'
398 )
399}
400// ---scene-end
401
402const TERMINAL_ART: [string, string][] = [
403 [' ┌──┐', '#8a929a'],
404 [' ┌─┤▒▒├─┐', '#8a929a'],
405 [' │▒│▒▒│▒│', '#e0b43a'],
406 [' │▒│▒▒│▒│', '#e0b43a'],
407 [' ├─┴──┴─┤', '#8a929a'],
408 [' ──┴──────┴──', '#6aa563'],
409]
410
411const clock = (ms: number) => {
412 const s = Math.max(0, Math.floor(ms / 1000))
413
414 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
415}
416
417const label = (w: Tower, now: number, T: Words) => {
418 if (w.working) {
419 return T.working(clock(now - w.startedAt), w.tools, Math.min(MAX, w.floors + 1))
420 }
421 if (w.floors >= MAX) {
422 return T.topped(w.towers + 1, MAX)
423 }
424
425 return T.idle(w.towers + 1, w.floors, MAX, w.towers)
426}
427
428const tower = atom(
429 { plugin: 'tower-cut', key: 'tower' } as const,
430 { working: false, prev: false, floors: 0, towers: 0, fresh: false, tools: 0, startedAt: 0, tick: 0 } as Tower,
431)
432
433// The picture is built again only when the state it draws changes; the same text keeps it running.
434let t0 = 0
435let cache = { key: '', svg: '' }
436
437export const register: Register = (on, options) => {
438 // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
439 const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
440
441 on('session.start', async ($, e, next) => {
442 t0 = await $.clock.now()
443 await $.command.register({
444 name: 'tower',
445 description: 'Open the building pane',
446 })
447 // Wakes the pane each second while Claude works, for the stopwatch in its top line.
448 $.clock.every(1000, () => {
449 void read($, tower)
450 .then(r => (r.working ? update($, tower, v => ({ ...v, tick: v.tick + 1 })) : undefined))
451 .catch(() => {})
452 })
453
454 return next(e)
455 })
456
457 on('command.run', { command: 'tower' }, async $ => {
458 await $.ui.open({ id: PANE, title: T.title })
459
460 return { text: T.opened }
461 })
462
463 // The main loop's turns only: a subagent's run is not Claude at work.
464 // A tower that topped out stays standing until the next turn, then it joins the skyline.
465 on('turn.start', async ($, e, next) => {
466 if (e.agentId === undefined) {
467 const now = await $.clock.now()
468 await update($, tower, v => ({
469 ...v,
470 prev: false,
471 working: true,
472 fresh: false,
473 tools: 0,
474 startedAt: now,
475 floors: v.floors >= MAX ? 0 : v.floors,
476 towers: v.floors >= MAX ? v.towers + 1 : v.towers,
477 }))
478 }
479
480 return next(e)
481 }).catch(($, e, next) => next(e))
482
483 on('turn.complete', async ($, e, next) => {
484 if (e.agentId === undefined) {
485 const isAnswer = e.reason === 'answer'
486 await update($, tower, v => ({ ...v, prev: true, working: false, fresh: isAnswer, floors: Math.min(MAX, v.floors + (isAnswer ? 1 : 0)) }))
487 }
488
489 return next(e)
490 }).catch(($, e, next) => next(e))
491
492 on('tool.call', async ($, e, next) => {
493 await update($, tower, v => ({ ...v, tools: v.tools + 1 }))
494
495 return next(e)
496 }).catch(($, e, next) => next(e))
497
498 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
499 const els = $.ui.resolve(e)
500 const w = await read($, tower)
501 const now = await $.clock.now()
502 if (t0 === 0) t0 = now
503 const cols = e.props?.bodyColumns ?? 40
504 const rows = e.props?.scroll?.bodyRows ?? 20
505 const line = label(w, now, T)
506
507 if (e.surface !== 'terminal') {
508 const { Box, Text, Svg } = els as ElementTable<'desktop'>
509 // The pane's px: its cells over the code font's advance (about 7.8 px) and line height
510 // (about 19.5 px). Short of the whole picture the view keeps the ground, and climbs with the crane.
511 const width = Math.max(160, Math.round(cols * 7.8))
512 const full = Math.round((width * H) / W)
513 const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
514 const height = Math.min(full, tall)
515 const key = `${w.working}|${w.prev}|${w.fresh}|${w.floors}|${w.towers}|${w.startedAt}|${height}|${width}`
516 if (cache.key !== key) cache = { key, svg: buildScene(w, (now - t0) / 1000, (height * W) / width) }
517
518 return (
519 <Box flexDirection="column" justifyContent="flex-end" minHeight={rows}>
520 <Box justifyContent="center">
521 <Text bold={w.working} dimColor={!w.working}>
522 {line}
523 </Text>
524 </Box>
525 <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
526 </Box>
527 )
528 }
529
530 const { Box, Text } = els as ElementTable<'terminal'>
531
532 return (
533 <Box flexDirection="column">
534 {TERMINAL_ART.map(([row, color]) => (
535 <Text color={color}>{row}</Text>
536 ))}
537 <Text>{line}</Text>
538 <Text dimColor>{T.note}</Text>
539 </Box>
540 )
541 })
542}
543types/index.d.ts 17 lines1export type Tower = {
2 working: boolean
3 prev: boolean
4 floors: number
5 towers: number
6 fresh: boolean
7 tools: number
8 startedAt: number
9 tick: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'tower-cut': { tower: Tower }
15 }
16}
17