When a tool call fails, the spinner dies: the line goes grey and WASTED fades in, in hand-drawn pixel lettering

A Claude Code mod. When a tool call fails, the spinner dies: its word drains to grey and WASTED fades in, in hand-drawn pixel lettering. Then the spinner comes back.

sounds/custom.wav (see below).Raster, repainted with $.ui.blit at 20 fps. Needs a terminal at least 76 columns wide./plugin marketplace add roaringsoul404/cc-mods
/plugin install wasted@roaringsoul
To receive new versions on their own: /plugin → Marketplaces → roaringsoul → Enable auto-update. To turn it off, disable it in /plugin.
To try it without installing:
git clone https://github.com/roaringsoul404/cc-mods
claude --plugin-dir ./cc-mods/wasted
Then ask for something that fails:
Run ./HESOYAM with Bash. Full health, full armor, $250k. What could go wrong?
Desktop app: the effect has a desktop branch (the same pixels as an Svg, 10 fps), and its drawing test passes. The Claude Code build bundled with the Desktop app (2.1.284 at the time of writing) loads installed mods only with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in its environment, and the effect hasn't been watched there yet.
/plugin shows 1 mod active · wasted. Or start with --debug-file /tmp/wasted.log and run:
grep "wasted@inline" /tmp/wasted.log
loaded means it's on. not loaded: ... the rollout switch served off means Claude Code switched mods off remotely for that launch; restart and check again.
| File | What it does | | :- | :- | | hooks/register.js | Hooks: detects failures (tool.call), replaces the spinner (ui.render), runs the animation clock, plays sounds/custom.wav if present | | hooks/draw.js | Pure drawing: the lettering, the timeline and the shared pixel model, plus its terminal and SVG renderings | | sounds/ | Empty: where your custom.wav goes | | scripts/render-frames.mjs | Renders terminal frames to PNG in frames/: node scripts/render-frames.mjs 80 | | tests/wasted.test.ts | claude plugin test: a failed call draws a Raster on terminal and an Svg on desktop | | video/ | Records a real session and renders the 7 s demo video; see video/README.md |
A fan project inspired by a meme. Not affiliated with, endorsed by or connected to Rockstar Games or Take-Two Interactive. "Grand Theft Auto" and related names are their trademarks. This repository contains no game assets: the lettering is original and no audio is included. Any file you add as sounds/custom.wav is your responsibility.
hooks/register.js 93 lines1import { frame, frameModel, modelSvg, hex, DURATION_MS } from './draw.js'
2
3const KEY = 'wasted'
4const FRAME_MS = 50 // terminal: blit at 20 fps
5const DESKTOP_EVERY = 2 // desktop: redraw every 2nd tick, 10 fps
6
7// When the current death started, or null when nothing is playing
8let diedAt = null
9// The word the spinner showed when the tool failed
10let deathWord = 'Working'
11// The terminal spinner (agent id) whose Raster is on screen, or null
12let mounted = null
13// Whether a desktop spinner is drawing the effect
14let onDesktop = false
15let tick = 0
16// Whether this death's sound has started
17let played = false
18
19export function register(on) {
20 on('session.start', async ($, e, next) => {
21 $.clock.every(FRAME_MS, async () => {
22 if (diedAt === null) return
23 const t = Date.now() - diedAt
24 tick += 1
25 if (t >= DURATION_MS) {
26 // Effect over: give the spinner back
27 diedAt = null
28 mounted = null
29 onDesktop = false
30 $.ui.invalidate('ui.render')
31 return
32 }
33 // The mod ships no sound: it plays sounds/custom.wav if the user added one,
34 // and only once the effect is on screen (no sound from `claude -p`,
35 // background subagents, or an Esc that took the spinner away)
36 if (!played && (mounted !== null || onDesktop)) {
37 played = true
38 // Not awaited: play resolves only when the clip ends. No file, or no
39 // player on this machine: silence.
40 $.audio.play({ asset: 'sounds/custom.wav' }).catch(() => {})
41 }
42 // Desktop has no blit: redraw the site, which runs the render hook again
43 if (onDesktop && tick % DESKTOP_EVERY === 0) $.ui.invalidate('ui.render')
44 if (mounted === null) return
45 const r = await $.ui.blit({ requestId: mounted, key: KEY, ...frame(t, deathWord) })
46 if (r && r.deny) {
47 // Raster no longer mounted: ask for a redraw, which draws the current
48 // frame if the spinner is still on screen (also covers a site that
49 // refuses blits, at the slower invalidate rate)
50 mounted = null
51 $.ui.invalidate('ui.render')
52 }
53 })
54 return next(e)
55 })
56
57 on('tool.call', async ($, e, next) => {
58 const r = await next(e)
59 // Ignore failures while a death is already playing
60 if (r && r.isError && diedAt === null) {
61 diedAt = Date.now()
62 tick = 0
63 played = false
64 $.ui.invalidate('ui.render')
65 }
66 return r
67 })
68
69 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
70 if (diedAt === null || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
71 if (e.props && e.props.word) deathWord = e.props.word
72 return next(e)
73 }
74 const t = Date.now() - diedAt
75 if (e.surface === 'terminal') {
76 const { Raster } = $.ui.resolve(e)
77 mounted = e.requestId
78 return Raster({ key: KEY, ...frame(t, deathWord) })
79 }
80 // Desktop: the same pixel model, drawn as SVG
81 const { Box, Text, Svg } = $.ui.resolve(e)
82 const model = frameModel(t, deathWord)
83 onDesktop = true
84 return Box({
85 flexDirection: 'column',
86 children: [
87 Text({ color: hex(model.wordColor), children: [model.label] }),
88 Svg({ source: modelSvg(model), alt: 'WASTED' }),
89 ],
90 })
91 })
92}
93hooks/draw.js 154 lines1// Pure drawing for the WASTED effect. No mods API here, so a plain node
2// script can render the same frames to PNG (scripts/render-frames.mjs).
3
4export const DURATION_MS = 2500
5export const COLUMNS = 76
6export const ROWS = 10 // 1 word row + 9 rows of half-block pixels
7
8const DEFAULT = 0x01000000 // terminal default color
9const PX_W = COLUMNS
10const PX_H = (ROWS - 1) * 2 // 18 pixels tall, two per cell with ▀/▄
11
12const CLAUDE_ORANGE = 0xd77757
13const DEAD_GREY = 0x6e6e6e
14const RED = 0xd3302f
15const SHADOW = 0x1a0606
16
17// Timeline
18const GREY_END = 500 // word drains to grey
19const IN_START = 450 // lettering starts to appear
20const IN_END = 1400 // lettering settled, then holds until DURATION_MS
21const SCALE_FROM = 2.15
22const SCALE_TO = 2
23const SHADOW_OFF = 1 // shadow offset in output pixels, right and down
24
25// Hand-drawn 5x7 lettering. Original glyphs, not any existing typeface.
26const GLYPHS = {
27 W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '#.#.#', '.#.#.'],
28 A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
29 S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
30 T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
31 E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
32 D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
33}
34
35// The word as one bitmap: 6 glyphs, 1 pixel gap -> 35 x 7
36const BITMAP = (() => {
37 const rows = []
38 for (let y = 0; y < 7; y++) {
39 rows.push([...'WASTED'].map((ch) => GLYPHS[ch][y]).join('.'))
40 }
41 return rows
42})()
43const BM_W = BITMAP[0].length
44const BM_H = BITMAP.length
45
46const clamp01 = (x) => Math.max(0, Math.min(1, x))
47const easeOut = (x) => 1 - Math.pow(1 - x, 3)
48
49function lerpColor(a, b, t) {
50 const ch = (shift) => Math.round(((a >> shift) & 255) + (((b >> shift) & 255) - ((a >> shift) & 255)) * t)
51 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
52}
53
54// Is output pixel (px, py) inside the lettering at this scale, centered?
55function inLetters(px, py, scale) {
56 const bx = Math.floor((px + 0.5 - PX_W / 2) / scale + BM_W / 2)
57 const by = Math.floor((py + 0.5 - PX_H / 2) / scale + BM_H / 2)
58 if (bx < 0 || by < 0 || bx >= BM_W || by >= BM_H) return false
59 return BITMAP[by][bx] === '#'
60}
61
62// The shared pixel model: one frame of the effect, independent of surface.
63// `label` and `wordColor` are the dead spinner line; `pixels` is a
64// PX_W x PX_H grid, row-major, each a 0xRRGGBB color or null (transparent).
65export function frameModel(tMs, word) {
66 const wordColor = lerpColor(CLAUDE_ORANGE, DEAD_GREY, easeOut(clamp01(tMs / GREY_END)))
67 // A Raster refuses the whole tree over one cell that isn't a width-1
68 // character, so anything but printable Latin becomes '?'
69 const printable = (cp) => (cp >= 0x20 && cp <= 0x7e) || (cp >= 0xa0 && cp <= 0x24f)
70 const safe = [...(word || 'Working')].map((ch) => (printable(ch.codePointAt(0)) ? ch : '?')).join('')
71 const label = '✻ ' + safe + '…'
72
73 // WASTED, zooming in from slightly bigger and fading up from black
74 const p = clamp01((tMs - IN_START) / (IN_END - IN_START))
75 const alpha = easeOut(p)
76 const scale = SCALE_FROM + (SCALE_TO - SCALE_FROM) * easeOut(p)
77 const red = lerpColor(0x000000, RED, alpha)
78 const shadow = lerpColor(0x000000, SHADOW, alpha)
79
80 const pixels = new Array(PX_W * PX_H).fill(null)
81 if (p > 0) {
82 for (let py = 0; py < PX_H; py++) {
83 for (let px = 0; px < PX_W; px++) {
84 if (inLetters(px, py, scale)) pixels[py * PX_W + px] = red
85 else if (inLetters(px - SHADOW_OFF, py - SHADOW_OFF, scale)) pixels[py * PX_W + px] = shadow
86 }
87 }
88 }
89 return { label, wordColor, width: PX_W, height: PX_H, pixels }
90}
91
92// Terminal: the model as a flat array of [codePoint, fg, bg] per cell,
93// row-major, two pixels per cell with ▀/▄.
94export function modelCells(model) {
95 const cells = new Array(COLUMNS * ROWS * 3)
96 const put = (col, row, ch, fg, bg) => {
97 const i = (row * COLUMNS + col) * 3
98 cells[i] = ch.codePointAt(0)
99 cells[i + 1] = fg
100 cells[i + 2] = bg
101 }
102
103 const label = [...model.label].slice(0, COLUMNS)
104 for (let c = 0; c < COLUMNS; c++) put(c, 0, label[c] ?? ' ', model.wordColor, DEFAULT)
105
106 const pixel = (x, y) => model.pixels[y * PX_W + x]
107 for (let row = 1; row < ROWS; row++) {
108 const top = (row - 1) * 2
109 for (let c = 0; c < COLUMNS; c++) {
110 const a = pixel(c, top)
111 const b = pixel(c, top + 1)
112 if (a === null && b === null) put(c, row, ' ', DEFAULT, DEFAULT)
113 else if (b === null) put(c, row, '▀', a, DEFAULT)
114 else if (a === null) put(c, row, '▄', b, DEFAULT)
115 else put(c, row, '▀', a, b)
116 }
117 }
118 return cells
119}
120
121// Pack cells into the base64 string a Raster takes: u32 little-endian triples.
122export function packCells(cells) {
123 return new Uint8Array(Uint32Array.from(cells).buffer).toBase64()
124}
125
126export const hex = (color) => '#' + color.toString(16).padStart(6, '0')
127
128// Desktop: the model's pixels as an SVG document, one <rect> per pixel,
129// grouped by color. Same grid as the terminal, square pixels.
130export const SVG_PX = 6 // CSS pixels per model pixel
131export const SVG_MAX = 131072
132
133export function modelSvg(model) {
134 const byColor = new Map()
135 model.pixels.forEach((color, i) => {
136 if (color === null) return
137 const rect = `<rect x="${i % model.width}" y="${Math.floor(i / model.width)}" width="1" height="1"/>`
138 byColor.set(color, (byColor.get(color) ?? '') + rect)
139 })
140 let groups = ''
141 for (const [color, rects] of byColor) groups += `<g fill="${hex(color)}">${rects}</g>`
142 const w = model.width * SVG_PX
143 const h = model.height * SVG_PX
144 return (
145 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` +
146 `viewBox="0 0 ${model.width} ${model.height}" shape-rendering="crispEdges">${groups}</svg>`
147 )
148}
149
150// Terminal Raster props for one frame
151export function frame(tMs, word) {
152 return { columns: COLUMNS, rows: ROWS, cells: packCells(modelCells(frameModel(tMs, word))) }
153}
154