Truecolor generative visuals: aurora band above the prompt and a /visuals gallery pane

Thirteen mods I wrote for Claude Code in October 2026, the week mods were announced. A mod is a plugin of TypeScript hooks that runs inside Claude Code: it can draw panes and bands in the terminal, add slash commands, give Claude new tools, and block or react to tool calls. I built these to see how far the system goes, and a few of them I now use every day.
The one I use most is interview-coach: it asks a placement interview question aloud, records my spoken answer, transcribes it on the Mac with Whisper, and scores pace, filler words and structure against my own CV stories.
| Mod | How you use it | What it does |
|---|---|---|
| interview-coach | /coach | Spoken mock interview in a side pane. Asks a question aloud, records from the mic, transcribes locally (Whisper large-v3-turbo via whisper.cpp), then shows speaking time against the target, words per minute, filler words, time spent on each step of the answer framework, and two stronger example answers drawn from your profile. Keeps a WPM trend across sessions. |
| nihongo | /nihongo | Japanese tutor pane: kana drill that weights look-alike pairs and brings misses back sooner, 44 N5 word cards, and a sentence builder where Claude Haiku sets a line from the week's grammar and corrects your kana answer. A word band above the prompt shows a new word each turn. |
| joka-pond | /pond | Animated ASCII pond that earns XP for every tool Claude uses and climbs a consulting ladder from Intern to Managing Partner. Storms when a tool fails, keeps a daily streak. |
| aurora | /visuals, /aurora | Truecolor pixel art: an aurora band above the prompt whose colours follow the time of day, plus a gallery (plasma, ocean sunset, warp, a 4-4-6 breathing lotus). |
| avatar | /buddy | A pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight. |
| chart | Claude calls it | Gives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends. |
| heatmap | Claude calls it | Inline truecolor heatmaps with heat, green and diverging palettes. |
| diagram | Claude calls it | Flowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows. |
| animate | Claude calls it | Plays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps. |
| pixel | Claude calls it | Draws a pixel-art image in the chat from a JSON grid of colours. |
| change-ledger | /changes | Pane listing every file Claude created or edited this session. |
| em-dash-guard | automatic | Blocks any write or edit that adds an em dash, my house style for documents. |
| hello-mod | /hi | Starter mod: an instant reply with no model call, a guard that blocks rm -rf, and a toast at the end of each turn. |
statusline/statusline-command.sh is my status line: model, effort, context used, 5-hour and weekly usage with time left, folder and git branch, colour-coded green, yellow and red.
claude --plugin-dir ./mods/chart
Pass --plugin-dir once per mod you want. claude plugin validate ./mods/chart checks a mod before loading it.
interview-coach needs ffmpeg, whisper-cpp (brew install ffmpeg whisper-cpp) and a Whisper model file. Set the paths at the top of mods/interview-coach/hooks/register.tsx, and copy profile.example.md to the profile path with your own experiences.
$ context object can't be stored or passed around. Long-running work has to start from a worker in session.start; buttons only queue jobs for it.$.ui.blit instead.session.start becomes callable only from the next prompt.-progress flag must come before -i, or progress output never arrives.hooks/register.tsx 242 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Scene } from '../types'
5
6const PANE = 'aurora-visuals'
7const scene = atom({ plugin: 'aurora', key: 'scene' } as const, 'aurora')
8const isBandOn = atom({ plugin: 'aurora', key: 'isBandOn' } as const, true)
9
10type RGB = [number, number, number]
11
12const SCENES: { id: Scene; label: string }[] = [
13 { id: 'aurora', label: 'Aurora' },
14 { id: 'plasma', label: 'Plasma' },
15 { id: 'sunset', label: 'Ocean sunset' },
16 { id: 'warp', label: 'Warp' },
17 { id: 'breathe', label: 'Breathe' },
18]
19
20// ---------- colour helpers ----------
21const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
22const mix = (a: RGB, b: RGB, k: number): RGB => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
23const scale = (c: RGB, k: number): RGB => [c[0] * k, c[1] * k, c[2] * k]
24const pack = (c: RGB) => (clamp(c[0]) << 16) | (clamp(c[1]) << 8) | clamp(c[2])
25const ramp = (stops: RGB[], k: number): RGB => {
26 const t = Math.max(0, Math.min(0.9999, k)) * (stops.length - 1)
27 const i = Math.floor(t)
28 return mix(stops[i]!, stops[i + 1]!, t - i)
29}
30const smooth = (e0: number, e1: number, x: number) => {
31 const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0)))
32 return t * t * (3 - 2 * t)
33}
34
35// Palettes follow the clock: night, dawn, day, dusk
36const palette = (): RGB[] => {
37 const h = new Date().getHours()
38 if (h >= 5 && h < 8) return [[255, 120, 150], [255, 180, 120], [180, 120, 255]]
39 if (h >= 8 && h < 17) return [[60, 200, 255], [120, 255, 200], [90, 140, 255]]
40 if (h >= 17 && h < 20) return [[255, 110, 60], [255, 60, 140], [140, 70, 255]]
41 return [[40, 255, 170], [60, 180, 255], [190, 90, 255]]
42}
43
44// ---------- shaders: (x, y in 0..1, t seconds) -> colour ----------
45const aurora = (x: number, y: number, t: number, pal: RGB[], energy: number): RGB => {
46 const sky: RGB = mix([4, 6, 20], [10, 14, 40], y)
47 let col = sky
48 for (let band = 0; band < 3; band++) {
49 const center = 0.5 + 0.28 * Math.sin(x * (3 + band) + t * (0.25 + band * 0.1) * energy + band * 2)
50 + 0.08 * Math.sin(x * 11 - t * 0.7 * energy + band)
51 const d = Math.abs(y - center)
52 const glow = Math.exp(-d * d * 60) * (0.55 + 0.45 * Math.sin(x * 20 + t * 1.5 * energy + band * 1.7))
53 col = mix(col, pal[band % pal.length]!, Math.min(1, glow * (0.5 + 0.5 * energy)))
54 }
55 return col
56}
57
58const plasma = (x: number, y: number, t: number): RGB => {
59 const v = Math.sin(x * 10 + t) + Math.sin((y * 10 + t) / 2) + Math.sin((x * 10 + y * 10 + t) / 2)
60 + Math.sin(Math.sqrt((x - 0.5) ** 2 * 100 + (y - 0.5) ** 2 * 100) + t)
61 const k = (v + 4) / 8
62 return ramp([[20, 0, 60], [255, 40, 120], [255, 180, 60], [40, 220, 255], [20, 0, 60]], (k + t * 0.05) % 1)
63}
64
65const sunset = (x: number, y: number, t: number): RGB => {
66 const horizon = 0.55
67 if (y < horizon) {
68 const sky = ramp([[30, 10, 70], [190, 50, 120], [255, 140, 70], [255, 210, 130]], y / horizon)
69 const sun = Math.hypot((x - 0.5) * 1.6, y - horizon + 0.08)
70 const disc = smooth(0.17, 0.15, sun)
71 const stripes = y > horizon - 0.12 && Math.sin(y * 120) > 0.4 ? 0 : 1
72 return mix(sky, [255, 230, 150], disc * stripes)
73 }
74 const depth = (y - horizon) / (1 - horizon)
75 const wave = Math.sin(x * 40 + t * 2 + depth * 30) * Math.sin(x * 13 - t + depth * 10)
76 const water = ramp([[120, 40, 110], [40, 20, 80], [10, 10, 40]], depth)
77 const glitter = Math.abs(x - 0.5) < 0.12 * (1 - depth * 0.5) && wave > 0.5 ? 0.7 : 0
78 return mix(water, [255, 200, 120], glitter)
79}
80
81const warp = (x: number, y: number, t: number): RGB => {
82 const dx = x - 0.5
83 const dy = (y - 0.5) * 0.6
84 const ang = Math.atan2(dy, dx)
85 const r = Math.hypot(dx, dy)
86 const lane = Math.floor((ang + Math.PI) * 18)
87 const seed = Math.sin(lane * 91.7) * 43758.5
88 const speed = 0.5 + (seed - Math.floor(seed))
89 const pos = (t * speed * 0.6 + (seed - Math.floor(seed))) % 1
90 const streak = Math.exp(-Math.abs(r - pos * 0.8) * 40) * smooth(0.02, 0.15, r)
91 const hue = ramp([[120, 160, 255], [255, 255, 255], [255, 120, 220]], (seed * 7) % 1 < 0 ? 0 : ((seed * 7) % 1))
92 return mix([2, 2, 10], hue, Math.min(1, streak * 1.5))
93}
94
95// Breathing lotus: in 4s, hold 4s, out 6s
96const BREATH = 14
97const breathPhase = (t: number) => {
98 const c = t % BREATH
99 if (c < 4) return { label: 'breathe in', left: 4 - c, k: smooth(0, 1, c / 4) }
100 if (c < 8) return { label: 'hold', left: 8 - c, k: 1 }
101 return { label: 'breathe out', left: BREATH - c, k: 1 - smooth(0, 1, (c - 8) / 6) }
102}
103const breathe = (x: number, y: number, t: number, aspect: number): RGB => {
104 const { k } = breathPhase(t)
105 const dx = (x - 0.5) * aspect
106 const dy = y - 0.5
107 const r = Math.hypot(dx, dy)
108 const ang = Math.atan2(dy, dx)
109 const size = 0.12 + 0.22 * k
110 const petals = size * (0.75 + 0.25 * Math.abs(Math.cos(ang * 4 + t * 0.2)))
111 const inner = size * 0.45 * (0.8 + 0.2 * Math.cos(ang * 8 - t * 0.3))
112 const bg: RGB = mix([6, 10, 24], [18, 12, 40], y)
113 const halo = Math.exp(-((r - petals) ** 2) * 300) * 0.6
114 let col = mix(bg, [120, 90, 255], halo)
115 if (r < petals) col = mix([255, 120, 190], [255, 200, 230], 1 - r / petals)
116 if (r < inner) col = mix([255, 220, 120], [255, 250, 220], 1 - r / inner)
117 return col
118}
119
120// ---------- rasterise: two pixels per cell via the upper half block ----------
121const render = (
122 cols: number,
123 rows: number,
124 shade: (x: number, y: number) => RGB,
125 overlay?: { text: string; row: number },
126): string => {
127 const words = new Uint32Array(cols * rows * 3)
128 const ph = rows * 2
129 for (let r = 0; r < rows; r++) {
130 for (let c = 0; c < cols; c++) {
131 const top = shade(c / cols, (r * 2) / ph)
132 const bottom = shade(c / cols, (r * 2 + 1) / ph)
133 const i = (r * cols + c) * 3
134 words[i] = 0x2580
135 words[i + 1] = pack(top)
136 words[i + 2] = pack(bottom)
137 }
138 }
139 if (overlay) {
140 const start = Math.max(0, Math.floor((cols - overlay.text.length) / 2))
141 for (let k = 0; k < overlay.text.length && start + k < cols; k++) {
142 const i = (overlay.row * cols + start + k) * 3
143 const behind = words[i + 2]!
144 words[i] = overlay.text.charCodeAt(k)
145 words[i + 1] = 0xffffff
146 words[i + 2] = behind
147 }
148 }
149 return (new Uint8Array(words.buffer) as unknown as { toBase64: () => string }).toBase64()
150}
151
152export const register: Register = on => {
153 // What is currently mounted, so the clock can repaint pixels with $.ui.blit
154 // instead of redrawing the tree (redraws rebuilt the buttons and ate clicks).
155 let band: { id: string; cols: number; working: boolean } | null = null
156 let pane: { cols: number; rows: number; scene: Scene } | null = null
157
158 const shadeFor = (s: Scene, t: number, aspect: number, pal: RGB[]) => (x: number, y: number): RGB =>
159 s === 'aurora' ? aurora(x, y, t, pal, 1)
160 : s === 'plasma' ? plasma(x, y, t)
161 : s === 'sunset' ? sunset(x, y, t)
162 : s === 'warp' ? warp(x, y, t)
163 : breathe(x, y, t, aspect)
164 const overlayFor = (s: Scene, t: number, rows: number) => {
165 if (s !== 'breathe') return undefined
166 const b = breathPhase(t)
167 return { text: ` ${b.label} ${Math.ceil(b.left)} `, row: rows - 2 }
168 }
169 const bandCells = (cols: number, working: boolean) => {
170 const t = Date.now() / 1000
171 const pal = palette()
172 return render(cols, 2, (x, y) => scale(aurora(x, y, t, pal, working ? 1.8 : 0.6), working ? 1 : 0.75))
173 }
174 const paneCells = (p: { cols: number; rows: number; scene: Scene }) => {
175 const t = Date.now() / 1000
176 return render(p.cols, p.rows, shadeFor(p.scene, t, p.cols / (p.rows * 2), palette()), overlayFor(p.scene, t, p.rows))
177 }
178
179 on('session.start', async ($, e, next) => {
180 await $.command.register({ name: 'visuals', description: 'Open the generative visuals gallery' })
181 await $.command.register({ name: 'aurora', description: 'Toggle the aurora band above the prompt' })
182 // Drive the animation: redraw this plugin's band and pane about 12 times a second
183 $.clock.every(80, () => {
184 if (band) void $.ui.blit({ requestId: band.id, key: 'aurora-band', cells: bandCells(band.cols, band.working) }).catch(() => {})
185 if (pane) void $.ui.blit({ requestId: PANE, key: 'gallery', cells: paneCells(pane) }).catch(() => {})
186 })
187 return next(e)
188 })
189
190 on('command.run', { command: 'visuals' }, async ($, e) => {
191 const want = e.args.trim().toLowerCase()
192 const hit = SCENES.find(sc => sc.id === want || sc.label.toLowerCase() === want)
193 if (hit) await update($, scene, () => hit.id)
194 await $.ui.open({ id: PANE, title: 'Visuals', focus: true })
195 return { text: `Visuals gallery opened${hit ? ` on ${hit.label}` : ''}. Press 1-5, or Tab/arrows + Enter, to switch; Esc returns to the prompt.` }
196 })
197
198 on('command.run', { command: 'aurora' }, async $ => {
199 const on_ = !(await read($, isBandOn))
200 await update($, isBandOn, () => on_)
201 return { text: on_ ? 'Aurora band on.' : 'Aurora band off.' }
202 })
203
204 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
205 if (e.props.hasSurvey || !(await read($, isBandOn))) {
206 band = null
207 return next(e)
208 }
209 if (e.surface !== 'terminal') return next(e)
210 const { Raster } = $.ui.resolve(e)
211 const cols = Math.max(10, Math.min(512, e.props.bodyColumns))
212 band = { id: e.requestId ?? 'band', cols, working: e.props.isWorking }
213 return <Raster key="aurora-band" columns={cols} rows={2} cells={bandCells(cols, e.props.isWorking)} />
214 })
215
216 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
217 const { Box, Button, Text } = $.ui.resolve(e)
218 const Raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : null
219 const s = await read($, scene)
220 const cols = Math.max(20, Math.min(512, (e.viewport?.columns ?? 60) - 2))
221 const rows = Math.max(6, Math.min(256, (e.viewport?.rows ?? 24) - 6))
222 pane = { cols, rows, scene: s }
223
224 return (
225 <Box flexDirection="column">
226 <Box gap={1}>
227 {SCENES.map((sc, i) => (
228 <Button key={`sc-${sc.id}`} label={s === sc.id ? `▸${sc.label}` : sc.label}
229 hotkey={String(i + 1)} autoFocus={s === sc.id}
230 variant={s === sc.id ? 'primary' : undefined}
231 onPress={() => void update($, scene, () => sc.id)} />
232 ))}
233 </Box>
234 {Raster
235 ? <Raster key="gallery" columns={cols} rows={rows} cells={paneCells({ cols, rows, scene: s })} />
236 : <Text dimColor>This surface cannot draw pixels.</Text>}
237 {s === 'breathe' && <Text dimColor>4 in, 4 hold, 6 out. Three rounds before an interview.</Text>}
238 </Box>
239 )
240 })
241}
242types/index.d.ts 8 lines1export type Scene = 'aurora' | 'plasma' | 'sunset' | 'warp' | 'breathe'
2
3declare module 'claude-code' {
4 interface PluginState {
5 aurora: { scene: Scene; isBandOn: boolean }
6 }
7}
8