Shows the Explorai logo above the prompt, filling with brand colors as Claude completes its tasks, with a dancer beside it.

A Claude Code plugin marketplace with my mods.
While Claude works, shows the Explorai logo above the prompt. The logo fills with the Explorai colors as Claude completes the items of its task list (over time when there is no list), with a dancer beside it.
Needs Claude Code 2.1.287 or later, in a terminal (the VS Code chat panel doesn't draw mods). The band is 93 columns wide.
This repository is private: you need access to it, and GitHub access on your machine (gh auth login).
claude plugin marketplace add Tomy-Phillip/claude-mods
claude plugin install explorai-loading@claude-mods
Then start a new claude session. /plugin shows 1 mod active · explorai-loading.
To update: claude plugin marketplace update claude-mods, then claude plugin update explorai-loading@claude-mods.
To review what the mod can do before installing, clone the repository and run claude plugin validate plugins/explorai-loading: it hooks prompts, turns, the task tools and the band above the prompt, and calls only the clock and the interface. It reads no files and makes no network requests.
claude plugin validate plugins/explorai-loading
claude plugin test plugins/explorai-loading
plugins/explorai-loading/bake_logo.py and bake_sprite.py regenerate hooks/logo.ts and hooks/sprite.ts from the images.
hooks/register.tsx 225 lines1import type { Register } from 'claude-code'
2
3import { LOGO } from './logo'
4import { SPRITE } from './sprite'
5
6const FRAME_MS = 80
7// Without a task list: seconds for the fill to reach ~63%, creeping toward 100% until the turn ends
8const FILL_SECONDS = 20
9const DEFAULT = 0x01000000
10const UNFILLED = 0x3a3a3a
11const PALETTE = [0xacf127, 0x2ddcf9, 0xf127ac]
12const TOP_HALF = 0x2580
13
14// Frames per dance beat (~240 ms)
15const BEAT_FRAMES = 3
16// Pixels of room the sprite bounces up and sways sideways in
17const BOUNCE = 2
18const SWAY = 2
19const GAP = 3
20
21const logoWidth = LOGO[0]?.length ?? 0
22const spriteSize = SPRITE.length
23const spriteLeft = logoWidth + GAP
24const height = spriteSize + BOUNCE
25// The logo sits vertically centered beside the sprite
26const logoTop = Math.floor((height - LOGO.length) / 2)
27const width = spriteLeft + spriteSize + SWAY
28const rows = Math.ceil(height / 2)
29
30const mix = (a: number, b: number, t: number) => {
31 const ch = (shift: number) =>
32 Math.round(((a >> shift) & 0xff) * (1 - t) + ((b >> shift) & 0xff) * t)
33
34 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
35}
36
37// Brand colors flowing along x, shifted every frame
38const flow = (x: number, frame: number) => {
39 const pos = (((x + frame * 1.5) / 14) % PALETTE.length + PALETTE.length) % PALETTE.length
40 const i = Math.floor(pos)
41
42 return mix(PALETTE[i] ?? 0, PALETTE[(i + 1) % PALETTE.length] ?? 0, pos - i)
43}
44
45export const pixel = (x: number, y: number, fill: number, frame: number) => {
46 const mark = LOGO[y]?.[x]
47
48 if (mark === undefined || mark === '.') {
49 return null
50 }
51
52 return x / logoWidth <= fill ? flow(x, frame) : UNFILLED
53}
54
55// Where the dancer stands on this frame: hops, sways, and turns around every four beats
56export const dancePose = (frame: number) => {
57 const beat = Math.floor(frame / BEAT_FRAMES)
58 const swing = [0, 1, 2, 1] as const
59
60 return {
61 top: BOUNCE - (swing[beat % 4] ?? 0),
62 left: swing[(beat + 1) % 4] ?? 0,
63 isMirrored: Math.floor(beat / 4) % 2 === 1,
64 }
65}
66
67export const spritePixel = (x: number, y: number, frame: number) => {
68 const pose = dancePose(frame)
69 const sx = x - pose.left
70 const sy = y - pose.top
71
72 if (sx < 0 || sx >= spriteSize || sy < 0 || sy >= spriteSize) {
73 return null
74 }
75
76 const column = pose.isMirrored ? spriteSize - 1 - sx : sx
77 const hex = SPRITE[sy]?.slice(column * 6, column * 6 + 6) ?? '......'
78
79 return hex === '......' ? null : parseInt(hex, 16)
80}
81
82// Composes the logo and the dancer into one picture, in band pixels
83const bandPixel = (x: number, y: number, fill: number, frame: number) =>
84 x < spriteLeft ? pixel(x, y - logoTop, fill, frame) : spritePixel(x - spriteLeft, y, frame)
85
86export const frameCells = (fill: number, frame: number) => {
87 const words = new Uint32Array(width * rows * 3)
88
89 for (let row = 0; row < rows; row++) {
90 for (let x = 0; x < width; x++) {
91 const top = bandPixel(x, row * 2, fill, frame)
92 const bottom = bandPixel(x, row * 2 + 1, fill, frame)
93 const i = (row * width + x) * 3
94 const isEmpty = top === null && bottom === null
95
96 words[i] = isEmpty ? 0x20 : TOP_HALF
97 words[i + 1] = top ?? DEFAULT
98 words[i + 2] = bottom ?? DEFAULT
99 }
100 }
101
102 // The runtime has Uint8Array.prototype.toBase64; TypeScript's lib doesn't declare it yet
103 return (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
104}
105
106type Status = 'pending' | 'in_progress' | 'completed'
107
108// Share of Claude's task list that is completed, or null when there is no list
109export const taskProgress = (statuses: Iterable<Status>) => {
110 let total = 0
111 let done = 0
112
113 for (const status of statuses) {
114 total += 1
115 done += status === 'completed' ? 1 : 0
116 }
117
118 return total === 0 ? null : { done, total }
119}
120
121export const register: Register = on => {
122 let startedAt = 0
123 let frame = 0
124 let shown = 0
125 let ticker: { cancel: () => void } | null = null
126 // Claude's task list this session, by task id (TaskCreate/TaskUpdate) or index (TodoWrite)
127 const tasks = new Map<string, Status>()
128
129 const stop = () => {
130 ticker?.cancel()
131 ticker = null
132 }
133
134 on('prompt.submit', async ($, e, next) => {
135 stop()
136 startedAt = await $.clock.now()
137 frame = 0
138 shown = 0
139 // A finished list belongs to the last task; an open one carries over to this prompt
140 if ([...tasks.values()].every(status => status === 'completed')) {
141 tasks.clear()
142 }
143 ticker = $.clock.every(FRAME_MS, () => {
144 frame += 1
145 $.ui.invalidate('ui.render')
146 })
147
148 return next(e)
149 })
150
151 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
152 const ran = await next(e)
153
154 if (ran.deny === undefined && ran.isError !== true) {
155 tasks.set(ran.result.task.id, 'pending')
156 }
157
158 return ran
159 })
160
161 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
162 const ran = await next(e)
163
164 if (ran.deny === undefined && ran.isError !== true && e.status !== undefined) {
165 if (e.status === 'deleted') {
166 tasks.delete(e.taskId)
167 } else {
168 tasks.set(e.taskId, e.status)
169 }
170 }
171
172 return ran
173 })
174
175 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
176 const ran = await next(e)
177
178 if (ran.deny === undefined && ran.isError !== true) {
179 tasks.clear()
180 e.todos.forEach((todo, i) => tasks.set(`todo:${i}`, todo.status))
181 }
182
183 return ran
184 })
185
186 on('turn.complete', ($, e, next) => {
187 stop()
188 $.ui.invalidate('ui.render')
189
190 return next(e)
191 })
192
193 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
194 if (e.surface !== 'terminal' || e.props.hasSurvey) {
195 return next(e)
196 }
197
198 if (!e.props.isWorking) {
199 stop()
200
201 return next(e)
202 }
203
204 const progress = taskProgress(tasks.values())
205 const seconds = ((await $.clock.now()) - startedAt) / 1000
206 const target = progress
207 ? progress.done / progress.total
208 : 1 - Math.exp(-seconds / FILL_SECONDS)
209 // Glide toward the target so a completed task sweeps in instead of jumping
210 shown += (target - shown) * 0.2
211 const { Box, Raster, Text } = $.ui.resolve(e)
212
213 return (
214 <Box flexDirection="column">
215 <Raster key="explorai-logo" columns={width} rows={rows} cells={frameCells(shown, frame)} />
216 {progress && (
217 <Text dimColor>
218 {progress.done}/{progress.total} tasks · {Math.round((100 * progress.done) / progress.total)}%
219 </Text>
220 )}
221 </Box>
222 )
223 })
224}
225hooks/logo.ts 19 lines1// Generated from explo_logo_rvb-NoirVert-ACFF13.png by bake_logo.py
2// '#' dark letters, 'g' green .ai, '.' empty; two rows per terminal cell
3export const LOGO = [
4 '........................................................................',
5 '.....................................................................g..',
6 '..................................##................................gg..',
7 '..................................##....................................',
8 '....#####...##.....##...######....##...######.....######...ggggggg...g..',
9 '...#######.#####.####..########...##..########...#######..ggggggggg.gg..',
10 '..##..........####.....##.....##..##.##......##..#........gg.....gg.gg..',
11 '..#..#####.....##......##.....##..##.##......##.##.......gg......gg.gg..',
12 '..##..........####.....##.....##..##.##......##.##.......ggg.....gg.gg..',
13 '..########.#####.####..##.######..##..########..##.....g..gggggg.gg.gg..',
14 '....#####...##.....##..##..###....#....######....#.....g...gggg..gg..g..',
15 '.......................##...............................................',
16 '.......................#................................................',
17 '........................................................................',
18]
19hooks/sprite.ts 21 lines1// Generated from image.png by bake_sprite.py
2// One 6-digit hex color per pixel, '......' transparent; two rows per terminal cell
3export const SPRITE = [
4 '................................................................................................',
5 '................................................................................................',
6 '5454544e4e4e4545453537343d4e3148643552793a547a3e5b774b59833d5585345583354f6d3d3f41404141415d5e5d',
7 '............464944373736484e2ba2a3277e78285b563548533d465e3a53793b4f7b36436b2f425d353f413e6a7048',
8 '............5985383a4b3d4d795dd5de2af6e613f7b315e4892291502e3f32302e302c2c2e2b2c2725794126c2c372',
9 '......5078335c8e3659903d4cbcabb3d34befe516f7bb12f68c1def5621e22626b8272aa32927db4022dc682eb5cb94',
10 '5785365c8e365c8e375c8e366e7242929a60c6ce32f2d110f5991af06120e82f238e432d3c3b32d55d22c8af60......',
11 '5b8d365c8e375c8e375c8e377a6d3aa35b3fa35b3fa6673bba7838be743490623779723b7f643ca36b3ba2664c......',
12 '4173655c8d365c8e37608f3c6d9153817c568a6a3f955f3c9e583ca65d40a65b40a75b3fa85c40a35b41............',
13 '0743f1255ba4467351839e73dbdddfdfe1e27da0645b8d36588635567b32566b3d676738........................',
14 '0643f60b3ee10644f75675cab5b6b8babbbe597ba5245692225495194eb70942e4..............................',
15 '0344fe0643f50a3fe647688a9da59dcdced14470ec0344fe0344fe0344fe0344fe0544f81737ac..................',
16 '0840eb093fe81342c753794f9ea89dd9d9da4471ee0344fe0345fe0344fe0344fe0344fd0a3fe6446858............',
17 '0843ed0b43e50f46e35a75c8cbccced3d4d55676d22253da2252da2252d92855d5194bdd0f3ac74c6d43............',
18 '6c8bb97092be7092be91a5bed6dbddd9dee08fa4c36c8db87192be7092bf7092bf6b88b1........................',
19 '6f91bd7091bd7091bd718fb78fa0b4............6985ad7091bd7091bd7091bd6985aa........................',
20]
21