SLOPSHOPPER

explorai-loading

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

newbandguardprompttimer
v0.3.0no licenseupdated 2026-10-05Tomy-Phillip/claude-mods/plugins/explorai-loading
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · explorai-loading
› 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) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▀ ▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▀ ▀▀
README

claude-mods

A Claude Code plugin marketplace with my mods.

explorai-loading

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.

Install

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.

Develop

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.

Source 3 files
hooks/register.tsx 225 lines
1import 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}
225
hooks/logo.ts 19 lines
1// 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]
19
hooks/sprite.ts 21 lines
1// 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