Claude's replies as pixel bubbles: a short sky and hill on top, a strip of grass under the last block

Mods for Claude Code: plugins of function hooks that change how the app looks.
| Mod | What it does |
|---|---|
meadow | Claude's replies as pixel bubbles: a short pixel sky and hill on top of each reply, a strip of grass under its last block. Desktop app only; the terminal keeps its own look. |
bunny-spinner | The loading line speaks as a bunny: 🐰 Michel: reading the docs, with · nom nom… while a tool runs. The app keeps its timer beside it. The name is a setting. |
Load one or both in every session by adding their folders to ~/.claude/settings.json (paths separated by :):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/repos/claude-mods/meadow:~/repos/claude-mods/bunny-spinner",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
CLAUDE_CODE_PLUGIN_DIR_WATCH reloads a mod when you edit it. Or install them from this repo as a plugin marketplace (/plugin marketplace add DenisGuiraudet/claude-mods).
claude plugin validate meadow
bun test meadow/testshooks/register.tsx 43 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { grassSvg, skySvg } from './scenery'
5
6// The text of the main loop's last answer: the one block of a reply that ends it
7const finalState = atom({ plugin: 'meadow', key: 'final' } as const, '')
8
9// Markdown draws at most 10000 characters; a longer block keeps the app's own look
10const LIMIT = 10_000
11
12export const register: Register = on => {
13 on('turn.complete', async ($, e, next) => {
14 const result = await next(e)
15 if (!e.agentId) {
16 await update($, finalState, () => e.answer.trim())
17 }
18
19 return result
20 })
21
22 // Every block of a reply is padded like a bubble; the first gets the sky and hill on top, the last the grass below
23 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
24 const ui = $.ui.resolve(e)
25 if (!('Svg' in ui) || e.props.text.length > LIMIT) {
26 return next(e)
27 }
28 const { Box, Markdown, Svg } = ui
29 const text = e.props.text.trim()
30 const isLast = text !== '' && text === (await read($, finalState))
31
32 return (
33 <Box key="meadow" flexDirection="column" marginRight={6} marginBottom={1}>
34 {e.props.isFirstOfReply && <Svg key="sky" source={skySvg()} alt="A pixel meadow: blue sky, clouds and a green hill" />}
35 <Box key="page" flexDirection="column" paddingX={2} paddingY={1}>
36 <Markdown key="text" text={e.props.text} />
37 </Box>
38 {isLast && <Svg key="grass" source={grassSvg()} alt="A strip of pixel grass under the message" />}
39 </Box>
40 )
41 })
42}
43hooks/scenery.ts 112 lines1// The meadow: two strips of pixel art, a sky with a hill for the top of a reply and a grass edge for the bottom.
2// One art pixel is 2 drawing units wide; the strips are wider than any chat column and scale down to fit.
3
4const PIXEL = 2
5const COLUMNS = 160
6
7export type Meadow = { skyHeight: number; grassHeight: number }
8
9export const MEADOW: Meadow = { skyHeight: 9, grassHeight: 3 }
10
11// Sky bands from the top down, each with its height in art pixels (nine rows in all)
12const SKY: Array<[string, number]> = [['#2f6fd1', 2], ['#4686df', 2], ['#6fa8ee', 2], ['#8bbcf3', 1], ['#a9cff7', 1], ['#c4e0fa', 1]]
13const GRASS = { light: '#7bd13b', mid: '#4fae2a', dark: '#2f8a1c', soil: '#6b4a2a' }
14
15type Cloud = { x: number; y: number; seconds: number; offset: number; scale: number }
16
17const CLOUDS: Cloud[] = [
18 { x: 0, y: 0, seconds: 90, offset: 0, scale: 0.8 },
19 { x: 0, y: 2, seconds: 130, offset: -60, scale: 0.6 },
20 { x: 0, y: 1, seconds: 110, offset: -95, scale: 0.7 },
21]
22
23// A cloud is a few overlapping blocks of white with a pale underside
24const BLOB: Array<[number, number, number, number]> = [
25 [2, 0, 6, 1],
26 [1, 1, 9, 1],
27 [0, 2, 11, 1],
28 [3, 3, 7, 1],
29]
30
31function rect(x: number, y: number, w: number, h: number, fill: string) {
32 return `<rect x="${x * PIXEL}" y="${y * PIXEL}" width="${w * PIXEL}" height="${h * PIXEL}" fill="${fill}"/>`
33}
34
35
36// A pixel-rounded bubble: each row near a rounded end is cut in by a few art pixels, in steps
37const CORNER = [3, 2, 1]
38
39function inset(row: number, rows: number, top: boolean, bottom: boolean) {
40 if (top && row < CORNER.length) {
41 return CORNER[row]
42 }
43 if (bottom && rows - 1 - row < CORNER.length) {
44 return CORNER[rows - 1 - row]
45 }
46
47 return 0
48}
49
50function clip(id: string, rows: number, top: boolean, bottom: boolean) {
51 const right: string[] = []
52 const left: string[] = []
53 for (let r = 0; r < rows; r++) {
54 const cut = inset(r, rows, top, bottom)
55 right.push(`${(COLUMNS - cut) * PIXEL},${r * PIXEL}`, `${(COLUMNS - cut) * PIXEL},${(r + 1) * PIXEL}`)
56 left.unshift(`${cut * PIXEL},${(r + 1) * PIXEL}`, `${cut * PIXEL},${r * PIXEL}`)
57 }
58
59 return `<clipPath id="${id}"><polygon points="${[...right, ...left].join(' ')}"/></clipPath>`
60}
61
62function cloud(c: Cloud) {
63 const body = BLOB.map(([x, y, w, h]) => rect(x, y, w, h, y === 3 ? '#d9e8fb' : '#ffffff')).join('')
64 const from = -14 * PIXEL
65 const to = (COLUMNS + 2) * PIXEL
66 const travel = `<animateTransform attributeName="transform" type="translate" values="${from} ${c.y * PIXEL};${to} ${c.y * PIXEL}" dur="${c.seconds}s" begin="${c.offset}s" repeatCount="indefinite"/>`
67
68 return `<g transform="translate(${from} ${c.y * PIXEL})"><g transform="scale(${c.scale})">${body}</g>${travel}</g>`
69}
70
71// Rolling ground: two sine waves, so the hill never looks like one bump
72function ground(column: number) {
73 return Math.round(2.4 + 1.2 * Math.sin(column / 21 + 0.8) + 0.6 * Math.sin(column / 7 + 2))
74}
75
76export function skySvg(): string {
77 const { skyHeight } = MEADOW
78 let y = 0
79 const bands = SKY.map(([fill, rows]) => {
80 const band = rect(0, y, COLUMNS, rows, fill)
81 y += rows
82
83 return band
84 }).join('')
85 const sun = rect(141, 1, 4, 4, '#fff3a8') + rect(140, 2, 6, 2, '#fff3a8')
86 let hill = ''
87 for (let x = 0; x < COLUMNS; x++) {
88 const top = skyHeight - ground(x)
89 const shade = x % 2 === 0 && (x * 7) % 11 < 3 ? GRASS.light : GRASS.mid
90 hill += rect(x, top, 1, 1, GRASS.light)
91 if (top + 1 < skyHeight) {
92 hill += rect(x, top + 1, 1, 1, shade)
93 }
94 if (top + 2 < skyHeight) {
95 hill += rect(x, top + 2, 1, skyHeight - top - 2, GRASS.dark)
96 }
97 }
98
99 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${COLUMNS * PIXEL} ${skyHeight * PIXEL}" preserveAspectRatio="xMidYMax slice" shape-rendering="crispEdges">${clip('c', skyHeight, true, false)}<g clip-path="url(#c)">${bands}${sun}${CLOUDS.map(cloud).join('')}${hill}</g></svg>`
100}
101
102export function grassSvg(): string {
103 const { grassHeight } = MEADOW
104 let tufts = ''
105 for (let x = 0; x < COLUMNS; x++) {
106 const tall = (x * 13) % 9 < 3
107 tufts += rect(x, 0, 1, tall ? 2 : 1, tall ? GRASS.light : GRASS.mid)
108 }
109
110 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${COLUMNS * PIXEL} ${grassHeight * PIXEL}" preserveAspectRatio="xMidYMin slice" shape-rendering="crispEdges">${clip('c', grassHeight, false, true)}<g clip-path="url(#c)">${rect(0, 0, COLUMNS, grassHeight, GRASS.dark)}${tufts}${rect(0, grassHeight - 1, COLUMNS, 1, GRASS.soil)}</g></svg>`
111}
112types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 meadow: { final: string }
4 }
5}
6