SLOPSHOPPER

meadow

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

newrows
v1.0.0no licenseupdated 2026-10-05DenisGuiraudet/claude-mods/meadow
A shopper browsing a rack in a slop shop
README

Claude mods

Mods for Claude Code: plugins of function hooks that change how the app looks.

ModWhat it does
meadowClaude'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-spinnerThe 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.

Use them

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).

Check them

claude plugin validate meadow
bun test meadow/tests
Source 3 files
hooks/register.tsx 43 lines
1import { 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}
43
hooks/scenery.ts 112 lines
1// 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}
112
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    meadow: { final: string }
4  }
5}
6