A tall night-train window pane: at rest the train waits at a lamp-lit station; while Claude works it runs through the night, hills, trees and poles streaming…


A tall night-train window pane. At rest the train waits at a lamp-lit station. While Claude works it runs through the night with hills, trees and poles streaming past, sometimes through a long tunnel, with migrating birds crossing the sky. When the turn ends it pulls back into the station.
This is a mod for Claude Code: a decorative panel drawn as a hand-written SVG animation. It changes between an idle look and a working look so you have something calm to watch while Claude runs a long task.
claude plugin marketplace add Gnosis333/mod_gallery
claude plugin install night-train@mod-gallery --scope user
Then run /reload-plugins or start a new session. The panel opens only when you run /train, so it costs nothing until you ask for it.
Labels are English by default. For Korean, run echo '{"language":"ko"}' | claude plugin configure night-train@mod-gallery --values-stdin and restart. Use en to switch back.
It only draws an animated SVG pane and one slash command (/train), and it reads Claude's idle or working state to switch the scene. It makes no network requests, reads or writes no files, runs no processes, and sends no data anywhere.
Part of mod_gallery. MIT licensed.
hooks/register.tsx 469 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Train } from '../types'
5
6const PANE = 'night-train'
7const STRINGS = {
8 en: {
9 title: 'Night train',
10 alt: 'A night-train window. At rest it waits at a lamp-lit station; while Claude works, hills, trees and poles stream past the glass.',
11 working: (clock: string, tools: number) => `Working ${clock} · tools ${tools}`,
12 idle: 'Idle',
13 opened: 'Opened the night-train 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 Ride = { working: boolean; startedAt: number; endedAt: number; travelled: number }
30
31const W = 400
32const H = 960
33const GL = 40 // the glass: left, right, top, bottom
34const GR = 360
35const GT = 40
36const GB = 820
37const TABLE = 862
38const T = 400 // every moving layer repeats every T px
39const DEPART = 2.5 // seconds to pull out of the station
40const ARRIVE = 3 // and to pull in
41
42const n = (v: number) => String(Math.round(v * 10) / 10)
43const EASE_IN = '.5 0 .9 .6'
44const EASE_OUT = '.1 .4 .5 1'
45const rnd = (s: number) => {
46 const v = Math.sin(s * 127.1 + 311.7) * 43758.5453
47
48 return v - Math.floor(v)
49}
50
51// Looping motion shifts back by the time already elapsed, so redrawing the picture never makes it jump.
52// Tags marked data-once (the scenery, the station sliding in or out) are timed from now and left alone.
53const retime = (svg: string, elapsed: number) =>
54 svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
55 if (tag.includes('data-once')) {
56 return tag
57 }
58 const found = / begin="(-?[\d.]+)s"/.exec(tag)
59 const shifted = ` begin="${n((found ? parseFloat(found[1]) : 0) - elapsed)}s"`
60
61 return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
62 })
63
64// ---- the sky, which moves with nothing ----------------------------------------------------------------
65const STARS = Array.from({ length: 34 }, (_, k) => {
66 const x = GL + 6 + rnd(k) * (GR - GL - 12)
67 const y = GT + 14 + rnd(k + 60) * 430
68 const r = 0.7 + rnd(k + 120) * 1.1
69 const twinkle = k % 4 === 0 ? `<animate attributeName="opacity" values=".9;.2;.9" dur="${n(2.6 + rnd(k + 7) * 3)}s" begin="${n(-rnd(k + 9) * 4)}s" repeatCount="indefinite"/>` : ''
70
71 return `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="#fff6d8" opacity=".9">${twinkle}</circle>`
72}).join('')
73
74const MOON =
75 '<circle cx="282" cy="150" r="46" fill="#f6efc4" opacity=".1"/><circle cx="282" cy="150" r="24" fill="#f6efc4"/>' +
76 '<circle cx="274" cy="144" r="4.4" fill="#e3d9a8"/><circle cx="290" cy="158" r="3" fill="#e3d9a8"/><circle cx="286" cy="139" r="2" fill="#e3d9a8"/>'
77
78const CLOUDS = [0, 1]
79 .map(
80 k =>
81 `<g opacity=".18"><animateTransform attributeName="transform" type="translate" values="${k ? 420 : -160} 0;${k ? -160 : 420} 0" dur="${k ? 150 : 190}s" begin="${-60 - k * 70}s" repeatCount="indefinite"/>` +
82 `<ellipse cx="0" cy="${k ? 300 : 230}" rx="70" ry="9" fill="#c9d4f0"/><ellipse cx="30" cy="${k ? 294 : 224}" rx="40" ry="8" fill="#c9d4f0"/></g>`,
83 )
84 .join('')
85
86const SHOOTING =
87 '<path d="M0 0 L46 -22" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity="0">' +
88 '<animateTransform attributeName="transform" type="translate" values="330 110;200 172;200 172" keyTimes="0;.05;1" dur="27s" begin="-8s" repeatCount="indefinite"/>' +
89 '<animate attributeName="opacity" values="0;1;0;0" keyTimes="0;.01;.05;1" dur="27s" begin="-8s" repeatCount="indefinite"/></path>'
90
91// now and then a flock of migrating birds crosses the sky in a V, wings beating out of step
92const FLOCK = (() => {
93 const birds = Array.from({ length: 9 }, (_, k) => {
94 const side = k === 0 ? 0 : k % 2 ? 1 : -1
95 const rank = Math.ceil(k / 2)
96 const x = rank * 15
97 const y = side * rank * 11
98
99 return `<path d="M-6 0 Q-3 -4 0 0 Q3 -4 6 0" fill="none" stroke="#aab6d8" stroke-width="1.8" stroke-linecap="round" transform="translate(${x} ${y})"><animate attributeName="d" values="M-6 0 Q-3 -4 0 0 Q3 -4 6 0;M-6 -2 Q-3 2 0 0 Q3 2 6 -2;M-6 0 Q-3 -4 0 0 Q3 -4 6 0" dur=".7s" begin="${n(-k * 0.11)}s" repeatCount="indefinite"/></path>`
100 }).join('')
101 const fly = (y: number, dur: number, begin: number) =>
102 `<g><animateTransform attributeName="transform" type="translate" values="420 ${y};420 ${y};-80 ${y - 40};-80 ${y - 40}" keyTimes="0;.62;.92;1" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>${birds}</g>`
103
104 return fly(250, 70, -30) + fly(380, 97, -80)
105})()
106
107const SKY =
108 `<rect x="${GL}" y="${GT}" width="${GR - GL}" height="${GB - GT}" fill="url(#night)"/>` + STARS + MOON + CLOUDS + SHOOTING + FLOCK
109
110// ---- the scenery, in layers that run at their own speeds ----------------------------------------------
111// far mountains, hills with a village, trees by the line, the telegraph poles, and speed streaks
112const far = (dx: number) =>
113 `<path d="M${dx} 600 L${dx + 40} 548 L${dx + 90} 578 L${dx + 150} 500 L${dx + 210} 562 L${dx + 262} 524 L${dx + 330} 586 L${dx + 400} 600 V720 H${dx}Z" fill="#1f2c52"/>` +
114 `<path d="M${dx + 136} 518 L${dx + 150} 500 L${dx + 166} 520 L${dx + 156} 516 L${dx + 150} 522Z M${dx + 252} 534 L${dx + 262} 524 L${dx + 274} 536Z" fill="#4a5a8a"/>`
115
116const HOUSES = Array.from({ length: 6 }, (_, k) => {
117 const x = 20 + k * 64 + rnd(k + 300) * 20
118 const y = 618 + Math.sin((x / T) * Math.PI * 2) * 8
119 const lit = rnd(k + 330) < 0.75
120
121 return [x, y, lit] as const
122})
123
124const mid = (dx: number) =>
125 `<path d="M${dx} 640 Q${dx + 100} 604 ${dx + 200} 632 T${dx + 400} 640 V740 H${dx}Z" fill="#152040"/>` +
126 HOUSES.map(
127 ([x, y, lit]) =>
128 `<path d="M${n(dx + x - 9)} ${n(y + 10)} V${n(y)} L${n(dx + x)} ${n(y - 8)} L${n(dx + x + 9)} ${n(y)} V${n(y + 10)}Z" fill="#101830"/>` +
129 (lit ? `<rect x="${n(dx + x - 3)}" y="${n(y + 1)}" width="5" height="4" fill="#ffd27a"/>` : ''),
130 ).join('')
131
132const TREES = [30, 92, 150, 214, 270, 336].map((x, k) => [x, 58 + rnd(k + 400) * 56] as const)
133
134const near = (dx: number) =>
135 `<rect x="${dx}" y="700" width="${T}" height="${GB - 700}" fill="#0a0f1e"/>` +
136 TREES.map(([x, ht]) => `<path d="M${dx + x - 16} 712 L${dx + x} ${n(712 - ht)} L${dx + x + 16} 712Z" fill="#0b1226"/>`).join('')
137
138const poles = (dx: number) =>
139 [100, 300].map(x => `<rect x="${dx + x - 3}" y="586" width="6" height="${GB - 586}" fill="#05080f"/><rect x="${dx + x - 16}" y="596" width="32" height="4" fill="#05080f"/>`).join('') +
140 `<path d="M${dx} 622 Q${dx + 50} 632 ${dx + 100} 600 Q${dx + 200} 646 ${dx + 300} 600 Q${dx + 350} 632 ${dx + 400} 622" fill="none" stroke="#05080f" stroke-width="1.4"/>`
141
142const STREAKS = Array.from({ length: 6 }, (_, k) => {
143 const y = 728 + k * 15
144 const len = 60 + rnd(k + 500) * 90
145 const d = 0.5 + rnd(k + 520) * 0.6
146
147 return `<path d="M0 ${y} H${n(len)}" stroke="${k % 3 === 0 ? '#ffd27a' : '#cfe0ff'}" stroke-width="1.6" stroke-linecap="round" opacity=".35"><animateTransform attributeName="transform" type="translate" values="${GR} 0;${n(GL - len)} 0" dur="${n(d)}s" begin="${n(-rnd(k + 540) * d)}s" repeatCount="indefinite"/></path>`
148}).join('')
149
150// now and then a tunnel swallows the window, its lamps rushing past
151// a long one: the window stays dark for about four and a half seconds
152const TUNNEL_LEN = 3000
153const TUNNEL =
154 `<g><animateTransform attributeName="transform" type="translate" values="420 0;420 0;${-TUNNEL_LEN - 20} 0;${-TUNNEL_LEN - 20} 0" keyTimes="0;.6;.72;1" dur="48s" begin="-12s" repeatCount="indefinite"/>` +
155 `<rect x="0" y="${GT}" width="${TUNNEL_LEN}" height="${GB - GT}" fill="#06070c"/><rect x="0" y="${GT}" width="14" height="${GB - GT}" fill="#20222c"/><rect x="${TUNNEL_LEN - 14}" y="${GT}" width="14" height="${GB - GT}" fill="#20222c"/>` +
156 Array.from({ length: 16 }, (_, k) => 120 + k * 180).map(x => `<circle cx="${x}" cy="120" r="5" fill="#ffb347"/><circle cx="${x}" cy="120" r="16" fill="#ffb347" opacity=".2"/>`).join('') +
157 Array.from({ length: 19 }, (_, k) => 60 + k * 150).map(x => `<rect x="${x}" y="660" width="40" height="4" rx="2" fill="#ffb347" opacity=".8"/>`).join('') +
158 '</g>'
159
160// a layer that runs (its place taken from how far the train has gone) or stands where it stopped
161const layer = (draw: (dx: number) => string, period: number, run: number, moving: boolean) => {
162 const body = draw(0) + draw(T)
163 if (!moving) {
164 return `<g transform="translate(${n(-((run % period) / period) * T)} 0)">${body}</g>`
165 }
166
167 return `<g><animateTransform data-once="1" attributeName="transform" type="translate" values="0 0;${-T} 0" dur="${period}s" begin="${n(-(run % period))}s" repeatCount="indefinite"/>${body}</g>`
168}
169
170// ---- the station --------------------------------------------------------------------------------------
171const lamp = (x: number) =>
172 `<rect x="${x - 2}" y="606" width="4" height="86" fill="#1c1c26"/><path d="M${x - 9} 606 H${x + 9} L${x + 5} 596 H${x - 5}Z" fill="#2a2a36"/>` +
173 `<circle cx="${x}" cy="610" r="34" fill="#ffd27a" opacity=".16"><animate attributeName="opacity" values=".14;.2;.14" dur="5s" repeatCount="indefinite"/></circle><circle cx="${x}" cy="610" r="6" fill="#ffe6a8"/>` +
174 `<ellipse cx="${x}" cy="702" rx="40" ry="6" fill="#ffd27a" opacity=".12"/>`
175
176const person = (body: string, x: number, y: number) =>
177 `<g transform="translate(${x} ${y})"><circle cy="-34" r="6" fill="#161620"/><rect x="-6" y="-28" width="12" height="22" rx="4" fill="${body}"/><rect x="-5" y="-7" width="4" height="10" fill="#161620"/><rect x="1" y="-7" width="4" height="10" fill="#161620"/></g>`
178
179// one passenger strolls along the platform; another waits with a suitcase
180const WALKER =
181 `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" keyTimes="0;.04;.36;.4;1" dur="40s" begin="-6s" repeatCount="indefinite"/>` +
182 `<g><animateTransform attributeName="transform" type="translate" values="30 0;380 0;380 0" keyTimes="0;.4;1" dur="40s" begin="-6s" repeatCount="indefinite"/>` +
183 `<g><animateTransform attributeName="transform" type="translate" values="0 0;0 -1.6;0 0" dur=".6s" repeatCount="indefinite"/>${person('#5a6a8a', 0, 712)}</g></g></g>`
184
185// now and then another train rolls by on the far track, all its windows lit
186const PASSING =
187 '<g><animateTransform attributeName="transform" type="translate" values="-760 0;-760 0;440 0;440 0" keyTimes="0;.74;.84;1" dur="36s" begin="-4s" repeatCount="indefinite"/>' +
188 '<rect x="0" y="618" width="720" height="58" rx="10" fill="#38415a"/><rect x="0" y="664" width="720" height="6" fill="#c94f4f"/>' +
189 Array.from({ length: 18 }, (_, k) => `<rect x="${16 + k * 39}" y="630" width="24" height="18" rx="3" fill="#ffd9a0"/>`).join('') +
190 '</g>'
191
192const STATION =
193 // the station house and its sign
194 '<rect x="64" y="560" width="148" height="132" fill="#4a3b4a"/><path d="M54 562 L138 520 L222 562Z" fill="#6a4a52"/>' +
195 [80, 118, 156, 188].map(x => `<rect x="${x}" y="584" width="18" height="26" fill="#ffd27a"/><rect x="${x + 8}" y="584" width="2" height="26" fill="#4a3b4a"/>`).join('') +
196 '<rect x="122" y="640" width="32" height="52" fill="#2c2230"/><rect x="110" y="532" width="56" height="20" rx="4" fill="#f6efc4"/>' +
197 '<text x="138" y="547" text-anchor="middle" font-size="13" font-weight="bold" letter-spacing="1" font-family="sans-serif" fill="#3a2a2a">STATION</text>' +
198 PASSING +
199 // the platform, its yellow line, the bench and the lamps
200 `<rect x="0" y="690" width="${W}" height="72" fill="#3a4152"/><rect x="0" y="690" width="${W}" height="7" fill="#525a70"/><rect x="0" y="702" width="${W}" height="3" fill="#c9b46a"/>` +
201 // the platform's edge, then our own track: gravel, sleepers and two rails
202 `<rect x="0" y="756" width="${W}" height="6" fill="#565e74"/><rect x="0" y="762" width="${W}" height="${GB - 762}" fill="#24262e"/>` +
203 Array.from({ length: 14 }, (_, k) => `<rect x="${k * 30}" y="784" width="18" height="30" fill="#3b2f26"/>`).join('') +
204 `<rect x="0" y="788" width="${W}" height="4" fill="#8a909c"/><rect x="0" y="806" width="${W}" height="4" fill="#8a909c"/>` +
205 '<rect x="252" y="672" width="64" height="5" fill="#6b4a32"/><rect x="256" y="677" width="3" height="14" fill="#6b4a32"/><rect x="309" y="677" width="3" height="14" fill="#6b4a32"/>' +
206 lamp(236) +
207 lamp(344) +
208 person('#7a4a5a', 222, 712) +
209 '<rect x="230" y="696" width="14" height="12" rx="2" fill="#8a6244"/>' +
210 WALKER
211
212// ---- the carriage -------------------------------------------------------------------------------------
213const WALL =
214 `<path d="M0 0 H${W} V${H} H0Z M${GL + 12} ${GT} H${GR - 12} Q${GR} ${GT} ${GR} ${GT + 12} V${GB - 12} Q${GR} ${GB} ${GR - 12} ${GB} H${GL + 12} Q${GL} ${GB} ${GL} ${GB - 12} V${GT + 12} Q${GL} ${GT} ${GL + 12} ${GT}Z" fill="#2a2230" fill-rule="evenodd"/>` +
215 `<rect x="${GL - 4}" y="${GT - 4}" width="${GR - GL + 8}" height="${GB - GT + 8}" rx="15" fill="none" stroke="#6b4a32" stroke-width="12"/>` +
216 `<rect x="${GL + 2}" y="${GT + 2}" width="${GR - GL - 4}" height="${GB - GT - 4}" rx="11" fill="none" stroke="#8a6244" stroke-width="2"/>`
217
218const curtain = (sway: boolean) =>
219 [1, -1]
220 .map(s => {
221 const g = s === 1 ? '' : `transform="translate(${W} 0) scale(-1 1)"`
222 const move = sway ? '<animateTransform attributeName="transform" type="rotate" values="-.6 30 20;.6 30 20;-.6 30 20" dur="1.1s" repeatCount="indefinite"/>' : ''
223
224 return (
225 `<g ${g}><g>${move}<path d="M0 20 H60 Q50 240 40 470 Q34 500 46 540 Q30 700 38 ${GB + 20} H0Z" fill="#7a2e3a"/>` +
226 '<path d="M18 30 Q16 260 14 470 M32 30 Q29 260 26 470" stroke="#5e2230" stroke-width="2" fill="none"/>' +
227 '<rect x="22" y="492" width="30" height="12" rx="5" fill="#c9a24a"/></g></g>'
228 )
229 })
230 .join('')
231
232const VALANCE = `<rect x="0" y="0" width="${W}" height="30" fill="#5a2030"/>` + Array.from({ length: 10 }, (_, k) => `<circle cx="${20 + k * 40}" cy="30" r="20" fill="#5a2030"/>`).join('')
233
234const TABLETOP =
235 `<rect x="0" y="${TABLE}" width="${W}" height="${H - TABLE}" fill="#6b4a32"/><rect x="0" y="${TABLE}" width="${W}" height="6" fill="#8a6244"/>` +
236 `<rect x="0" y="${TABLE + 34}" width="${W}" height="${H - TABLE - 34}" fill="#4a3222"/>` +
237 // a book and a ticket
238 `<rect x="54" y="${TABLE - 12}" width="64" height="12" rx="2" fill="#2f5a7a"/><rect x="58" y="${TABLE - 22}" width="56" height="10" rx="2" fill="#8a3a3a"/>` +
239 `<g transform="translate(230 ${TABLE - 3}) rotate(-8)"><rect x="0" y="-12" width="44" height="14" rx="2" fill="#efe4c8"/><path d="M6 -7 H30 M6 -3 H22" stroke="#a88a5a" stroke-width="1.4"/><circle cx="37" cy="-5" r="3" fill="none" stroke="#c94f4f" stroke-width="1"/></g>`
240
241const cup = (rocking: boolean) => {
242 const shake = rocking ? '<animateTransform attributeName="transform" type="translate" values="0 0;.8 -.6;0 0;-.7 .4;0 0" dur=".42s" repeatCount="indefinite"/>' : ''
243 // long wisps of steam: straight up at rest, swept sideways by the draught while the train runs
244 const steam = [0, 1, 2]
245 .map(k => {
246 const x = 150 + k * 9
247 const d = rocking
248 ? `M${x} ${TABLE - 34} q-8 -14 -22 -12 q-14 2 -22 -10 q-8 -12 -24 -10 q-14 2 -22 -8`
249 : `M${x} ${TABLE - 34} q-7 -14 0 -26 q7 -12 0 -24 q-7 -12 0 -24 q7 -12 0 -22`
250 const dur = rocking ? 2 : 5.4
251
252 return `<path d="${d}" fill="none" stroke="#f1ecdf" stroke-width="2.4" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;.6;.4;0" keyTimes="0;.3;.7;1" dur="${dur}s" begin="${n(-k * dur / 3)}s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" values="0 6;${rocking ? '-10 -14' : '0 -26'}" dur="${dur}s" begin="${n(-k * dur / 3)}s" repeatCount="indefinite"/></path>`
253 })
254 .join('')
255 const tea = rocking
256 ? `<ellipse cx="164" cy="${TABLE - 32}" rx="18" ry="3" fill="#a8763c"><animate attributeName="ry" values="3;1.6;3" dur=".42s" repeatCount="indefinite"/></ellipse>`
257 : `<ellipse cx="164" cy="${TABLE - 32}" rx="18" ry="3" fill="#a8763c"/>`
258
259 return (
260 `<g><ellipse cx="164" cy="${TABLE}" rx="34" ry="5" fill="#e9e2d0"/><g>${shake}` +
261 `<path d="M144 ${TABLE - 33} H184 L180 ${TABLE - 4} Q164 ${TABLE + 2} 148 ${TABLE - 4}Z" fill="#f4efe2"/><path d="M184 ${TABLE - 26} q16 2 2 18" fill="none" stroke="#f4efe2" stroke-width="4"/>` +
262 `<ellipse cx="164" cy="${TABLE - 33}" rx="20" ry="4" fill="#e1d8c4"/>${tea}</g>${steam}</g>`
263 )
264}
265
266const tableLamp = (rocking: boolean) => {
267 const sway = rocking ? '<animateTransform attributeName="transform" type="rotate" values="-1.4 320 862;1.4 320 862;-1.4 320 862" dur=".9s" repeatCount="indefinite"/>' : ''
268
269 return (
270 `<g>${sway}<rect x="318" y="${TABLE - 52}" width="4" height="52" fill="#2a2a36"/><ellipse cx="320" cy="${TABLE}" rx="16" ry="4" fill="#2a2a36"/>` +
271 `<path d="M300 ${TABLE - 52} H340 L330 ${TABLE - 78} H310Z" fill="#ffcf88"/><ellipse cx="320" cy="${TABLE - 52}" rx="20" ry="3.6" fill="#ffe3b3"/></g>` +
272 `<circle cx="320" cy="${TABLE - 52}" r="56" fill="#ffd9a0" opacity=".06"/>`
273 )
274}
275
276// ---- the whole picture --------------------------------------------------------------------------------
277const fade = (from: number, to: number, dur: number, since: number, inner: string) =>
278 since >= dur
279 ? to > 0
280 ? inner
281 : ''
282 : `<g opacity="${from}"><animate data-once="1" attributeName="opacity" values="${from};${to}" dur="${dur}s" begin="${n(-since)}s" fill="freeze"/>${inner}</g>`
283
284const buildScene = (w: Ride, elapsed: number, vh: number, now: number) => {
285 const view = Math.min(H, Math.max(vh, 240))
286 const y0 = H - view
287 const since = (now - w.startedAt) / 1000
288 const sinceEnd = (now - w.endedAt) / 1000
289 const arriving = !w.working && w.endedAt > 0 && sinceEnd < ARRIVE
290 // how far the train has gone (counting the roll into the station); while it runs, or still rolls in,
291 // the scenery runs from there
292 const run = w.working ? w.travelled + since : arriving ? w.travelled - ARRIVE + sinceEnd : w.travelled
293 const moving = w.working || arriving
294
295 const defs =
296 '<defs>' +
297 '<linearGradient id="night" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0b1130"/><stop offset=".6" stop-color="#1c2a55"/><stop offset="1" stop-color="#3a3a6a"/></linearGradient>' +
298 `<clipPath id="glass"><rect x="${GL}" y="${GT}" width="${GR - GL}" height="${GB - GT}" rx="12"/></clipPath>` +
299 '</defs>'
300
301 const running =
302 layer(mid, 30, run, true) +
303 layer(near, 7, run, true) +
304 layer(poles, 1.4, run, true) +
305 (w.working ? STREAKS + TUNNEL : '')
306
307 // the station slides away as the train leaves, and back in as it arrives
308 const station = w.working
309 ? since < DEPART
310 ? `<g><animateTransform data-once="1" attributeName="transform" type="translate" values="0 0;-440 0" keySplines="${EASE_IN}" calcMode="spline" keyTimes="0;1" dur="${DEPART}s" begin="${n(-since)}s" fill="freeze"/>${STATION}</g>`
311 : ''
312 : arriving
313 ? `<g><animateTransform data-once="1" attributeName="transform" type="translate" values="440 0;0 0" keySplines="${EASE_OUT}" calcMode="spline" keyTimes="0;1" dur="${ARRIVE}s" begin="${n(-sinceEnd)}s" fill="freeze"/>${STATION}</g>`
314 : STATION
315
316 const outside =
317 SKY +
318 layer(far, 90, run, moving) +
319 (w.working ? fade(0, 1, DEPART, since, running) : arriving ? fade(1, 0, ARRIVE, sinceEnd, running) : '') +
320 station
321
322 // the carriage rocks a little while it runs
323 const rock = w.working ? '<animateTransform attributeName="transform" type="translate" values="0 0;0 1.2;0 0;0 -.6;0 0" dur=".7s" repeatCount="indefinite"/>' : ''
324 const glass = `<g clip-path="url(#glass)"><g>${rock}${outside}</g><polygon points="70,${GT} 120,${GT} 40,${GB} ${GL - 10},${GB}" fill="#fff" opacity=".04"/></g>`
325
326 const room = WALL + curtain(w.working) + VALANCE + TABLETOP + cup(w.working) + tableLamp(w.working)
327
328 return (
329 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${n(y0)} ${W} ${n(view)}" width="${W}" height="${n(view)}" preserveAspectRatio="xMidYMax slice">${defs}` +
330 `<rect x="0" y="${n(y0)}" width="${W}" height="${n(view)}" fill="#1b1624"/>` +
331 retime(glass + room, elapsed) +
332 '</svg>'
333 )
334}
335// ---scene-end
336
337const TERMINAL_ART: [string, string][] = [
338 [' ┌───────────────┐', '#c9a24a'],
339 [' │ ✦ ☾ ✦ │', '#fff6d8'],
340 [' │ /\\ /\\/\\ │', '#8fa8d8'],
341 [' │ ▲ ▲ ▲ ▲ ▲ ▲ │', '#4a6a8a'],
342 [' └───────────────┘', '#c9a24a'],
343 [' ━━━━ ☕ ━━━━━━━', '#a88a5a'],
344]
345
346const clock = (ms: number) => {
347 const s = Math.max(0, Math.floor(ms / 1000))
348
349 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
350}
351
352const label = (w: Train, now: number, T: Words) => (w.working ? T.working(clock(now - w.startedAt), w.tools) : T.idle)
353
354const ride = atom(
355 { plugin: 'night-train', key: 'ride' } as const,
356 { working: false, tools: 0, startedAt: 0, endedAt: 0, travelled: 0, tick: 0 } as Train,
357)
358
359// The picture is built again only when the state it draws changes; the same text keeps it running.
360let t0 = 0
361let cache = { key: '', svg: '' }
362
363export const register: Register = (on, options) => {
364 // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
365 const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
366
367 on('session.start', async ($, e, next) => {
368 t0 = await $.clock.now()
369 await $.command.register({
370 name: 'train',
371 description: 'Open the night-train window pane',
372 })
373 // Wakes the pane each second while Claude works (the stopwatch) and while the train pulls in.
374 $.clock.every(1000, () => {
375 void Promise.all([read($, ride), $.clock.now()])
376 .then(([r, now]) =>
377 r.working || (r.endedAt > 0 && now - r.endedAt < (ARRIVE + 1) * 1000) ? update($, ride, v => ({ ...v, tick: v.tick + 1 })) : undefined,
378 )
379 .catch(() => {})
380 })
381
382 return next(e)
383 })
384
385 on('command.run', { command: 'train' }, async $ => {
386 await $.ui.open({ id: PANE, title: T.title })
387
388 return { text: T.opened }
389 })
390
391 // The main loop's turns only: a subagent's run is not Claude at work.
392 on('turn.start', async ($, e, next) => {
393 if (e.agentId === undefined) {
394 const now = await $.clock.now()
395 await update($, ride, v => {
396 // a turn that starts while the train still rolls in counts only the distance it rolled so far
397 const short = v.endedAt > 0 ? Math.max(0, ARRIVE - (now - v.endedAt) / 1000) : 0
398
399 return v.working ? v : { ...v, working: true, tools: 0, startedAt: now, travelled: v.travelled - short }
400 })
401 }
402
403 return next(e)
404 }).catch(($, e, next) => next(e))
405
406 on('turn.complete', async ($, e, next) => {
407 if (e.agentId === undefined) {
408 const now = await $.clock.now()
409 await update($, ride, v => (v.working ? { ...v, working: false, endedAt: now, travelled: v.travelled + (now - v.startedAt) / 1000 + ARRIVE } : v))
410 }
411
412 return next(e)
413 }).catch(($, e, next) => next(e))
414
415 on('tool.call', async ($, e, next) => {
416 await update($, ride, v => ({ ...v, tools: v.tools + 1 }))
417
418 return next(e)
419 }).catch(($, e, next) => next(e))
420
421 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
422 const els = $.ui.resolve(e)
423 const w = await read($, ride)
424 const now = await $.clock.now()
425 if (t0 === 0) t0 = now
426 const cols = e.props?.bodyColumns ?? 40
427 const rows = e.props?.scroll?.bodyRows ?? 20
428 const line = label(w, now, T)
429
430 if (e.surface !== 'terminal') {
431 const { Box, Text, Svg } = els as ElementTable<'desktop'>
432 // The pane's px: its cells over the code font's advance (about 7.8 px) and line height
433 // (about 19.5 px). Short of the whole picture, the top of the window is cut, never the table.
434 const width = Math.max(160, Math.round(cols * 7.8))
435 const full = Math.round((width * H) / W)
436 const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
437 const height = Math.min(full, tall)
438 // leaving and arriving each draw the station sliding; once that is over the picture settles
439 const leaving = w.working && (now - w.startedAt) / 1000 < DEPART
440 const arriving = !w.working && w.endedAt > 0 && (now - w.endedAt) / 1000 < ARRIVE
441 const key = `${w.working}|${leaving}|${arriving}|${w.startedAt}|${w.endedAt}|${height}|${width}`
442 if (cache.key !== key) cache = { key, svg: buildScene(w, (now - t0) / 1000, (height * W) / width, now) }
443
444 return (
445 <Box flexDirection="column" justifyContent="center" minHeight={rows}>
446 <Box justifyContent="center">
447 <Text bold={w.working} dimColor={!w.working}>
448 {line}
449 </Text>
450 </Box>
451 <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
452 </Box>
453 )
454 }
455
456 const { Box, Text } = els as ElementTable<'terminal'>
457
458 return (
459 <Box flexDirection="column">
460 {TERMINAL_ART.map(([row, color]) => (
461 <Text color={color}>{row}</Text>
462 ))}
463 <Text>{line}</Text>
464 <Text dimColor>{T.note}</Text>
465 </Box>
466 )
467 })
468}
469types/index.d.ts 16 lines1export type Train = {
2 working: boolean
3 tools: number
4 startedAt: number
5 endedAt: number
6 // seconds the train has run in all, so the scenery stops and starts where it was
7 travelled: number
8 tick: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'night-train': { ride: Train }
14 }
15}
16