SLOPSHOPPER

night-train

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…

newpaneguardcommandtimer
v0.1.1MITupdated 2026-10-24Gnosis333/mod_gallery/mods/night-train
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · night-train
│ ┃ Night train ✕ › fix the failing auth test and add an audit log call │ ┃ ┌───────────────┐ │ ┃ │ ✦ ☾ ✦ │ ⏺ Read(src/auth.ts) │ ┃ │ /\ /\/\ │ ⎿ Read 6 lines │ ┃ │ ▲ ▲ ▲ ▲ ▲ ▲ │ ⏺ Update(src/auth.ts) │ ┃ └───────────────┘ ⎿ Added 2 lines, removed 1 line │ ┃ ━━━━ ☕ ━━━━━━━ ⏺ Bash(bun test) │ ┃ Idle ⎿ 3 pass, 1 fail │ ┃ The animation shows in the desktop app. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /train │ ⎿ night-train: Opened the night-train pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Night train
┌───────────────┐ │ ✦ ☾ ✦ │ │ /\ /\/\ │ │ ▲ ▲ ▲ ▲ ▲ ▲ │ └───────────────┘ ━━━━ ☕ ━━━━━━━ Idle The animation shows in the desktop app.
README

night-train

night-train

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.

Install

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.

Language

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.

What it does and does not do

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.

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