SLOPSHOPPER

chart

Gives Claude a tool that draws bar, line, area and donut charts inline in the chat

newrowsguardtool
v0.1.0MITupdated 2026-10-26Ishu-dev/claude-code-mods/mods/chart
A shopper browsing a rack in a slop shop
README

Claude Code mods

Thirteen mods I wrote for Claude Code in October 2026, the week mods were announced. A mod is a plugin of TypeScript hooks that runs inside Claude Code: it can draw panes and bands in the terminal, add slash commands, give Claude new tools, and block or react to tool calls. I built these to see how far the system goes, and a few of them I now use every day.

The one I use most is interview-coach: it asks a placement interview question aloud, records my spoken answer, transcribes it on the Mac with Whisper, and scores pace, filler words and structure against my own CV stories.

The mods

ModHow you use itWhat it does
interview-coach/coachSpoken mock interview in a side pane. Asks a question aloud, records from the mic, transcribes locally (Whisper large-v3-turbo via whisper.cpp), then shows speaking time against the target, words per minute, filler words, time spent on each step of the answer framework, and two stronger example answers drawn from your profile. Keeps a WPM trend across sessions.
nihongo/nihongoJapanese tutor pane: kana drill that weights look-alike pairs and brings misses back sooner, 44 N5 word cards, and a sentence builder where Claude Haiku sets a line from the week's grammar and corrects your kana answer. A word band above the prompt shows a new word each turn.
joka-pond/pondAnimated ASCII pond that earns XP for every tool Claude uses and climbs a consulting ladder from Intern to Managing Partner. Storms when a tool fails, keeps a daily streak.
aurora/visuals, /auroraTruecolor pixel art: an aurora band above the prompt whose colours follow the time of day, plus a gallery (plasma, ocean sunset, warp, a 4-4-6 breathing lotus).
avatar/buddyA pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight.
chartClaude calls itGives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends.
heatmapClaude calls itInline truecolor heatmaps with heat, green and diverging palettes.
diagramClaude calls itFlowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows.
animateClaude calls itPlays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps.
pixelClaude calls itDraws a pixel-art image in the chat from a JSON grid of colours.
change-ledger/changesPane listing every file Claude created or edited this session.
em-dash-guardautomaticBlocks any write or edit that adds an em dash, my house style for documents.
hello-mod/hiStarter mod: an instant reply with no model call, a guard that blocks rm -rf, and a toast at the end of each turn.

statusline/statusline-command.sh is my status line: model, effort, context used, 5-hour and weekly usage with time left, folder and git branch, colour-coded green, yellow and red.

Try one

claude --plugin-dir ./mods/chart

Pass --plugin-dir once per mod you want. claude plugin validate ./mods/chart checks a mod before loading it.

interview-coach needs ffmpeg, whisper-cpp (brew install ffmpeg whisper-cpp) and a Whisper model file. Set the paths at the top of mods/interview-coach/hooks/register.tsx, and copy profile.example.md to the profile path with your own experiences.

What I learned building them

  • The $ context object can't be stored or passed around. Long-running work has to start from a worker in session.start; buttons only queue jobs for it.
  • Redrawing a pane on a timer rebuilds its buttons and swallows clicks. Animations repaint pixels with $.ui.blit instead.
  • A tool registered in session.start becomes callable only from the next prompt.
  • Plugin tool results must be strings, so structured results go back as JSON text.
  • ffmpeg's -progress flag must come before -i, or progress output never arrives.
Source 1 files
hooks/register.tsx 238 lines
1import type { Register } from 'claude-code'
2
3const TOOL = 'mcp__chart__chart'
4
5type Kind = 'bar' | 'line' | 'area' | 'donut'
6type Series = { name: string; values: number[] }
7type Chart = { title: string; kind: Kind; labels: string[]; series: Series[]; unit: string }
8
9const PALETTE = ['#4cc9f0', '#f72585', '#ffd166', '#06d6a0', '#b388ff', '#ff8c42', '#90e0ef', '#ef476f']
10const hex = (h: string) => parseInt(h.slice(1), 16)
11const NONE = 0x01000000
12const GRID = 0x2a2f3a
13
14const fmt = (v: number) =>
15  Math.abs(v) >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : Math.abs(v) >= 1e3 ? `${(v / 1e3).toFixed(1)}k`
16  : Number.isInteger(v) ? String(v) : v.toFixed(1)
17
18const b64 = (w: Uint32Array) => (new Uint8Array(w.buffer) as unknown as { toBase64: () => string }).toBase64()
19
20// A pixel canvas drawn with half blocks: 2 pixels per terminal row
21const canvas = (cols: number, rows: number) => {
22  const ph = rows * 2
23  const px = new Uint32Array(cols * ph).fill(NONE)
24  return {
25    ph,
26    set: (x: number, y: number, c: number) => {
27      const xi = Math.round(x)
28      const yi = Math.round(y)
29      if (xi >= 0 && xi < cols && yi >= 0 && yi < ph) px[yi * cols + xi] = c
30    },
31    get: (x: number, y: number) => px[y * cols + x]!,
32    cells: () => {
33      const w = new Uint32Array(cols * rows * 3)
34      for (let r = 0; r < rows; r++) {
35        for (let x = 0; x < cols; x++) {
36          const top = px[r * 2 * cols + x]!
37          const bot = px[(r * 2 + 1) * cols + x]!
38          const i = (r * cols + x) * 3
39          w[i] = top === NONE && bot === NONE ? 0x20 : 0x2580
40          w[i + 1] = top
41          w[i + 2] = bot
42        }
43      }
44      return b64(w)
45    },
46  }
47}
48
49const line = (set: (x: number, y: number, c: number) => void, x0: number, y0: number, x1: number, y1: number, c: number) => {
50  const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
51  for (let k = 0; k <= n; k++) set(x0 + ((x1 - x0) * k) / n, y0 + ((y1 - y0) * k) / n, c)
52}
53
54const parse = (out: unknown): Chart | null => {
55  const text = typeof out === 'string' ? out
56    : Array.isArray(out) ? out.map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '')).join('')
57    : ''
58  try {
59    const c = JSON.parse(text) as Chart
60    return Array.isArray(c.series) && c.series.length > 0 ? c : null
61  } catch {
62    return null
63  }
64}
65
66export const register: Register = on => {
67  on('session.start', async ($, e, next) => {
68    await $.tool.register({
69      name: 'chart',
70      description:
71        'Draw a chart inline in the chat in true colour. kind: bar (compare categories, horizontal bars, grouped when several series), ' +
72        'line (trend over labels, several series), area (one or two series, filled), donut (shares of a whole; uses the first series). ' +
73        'labels are the categories or x points; each series has a name and one value per label. Keep under 40 labels and 6 series.',
74      inputSchema: {
75        type: 'object',
76        properties: {
77          title: { type: 'string' },
78          kind: { type: 'string', enum: ['bar', 'line', 'area', 'donut'] },
79          labels: { type: 'array', items: { type: 'string' } },
80          series: {
81            type: 'array',
82            items: { type: 'object', properties: { name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } } }, required: ['name', 'values'] },
83          },
84          unit: { type: 'string' },
85        },
86        required: ['title', 'kind', 'labels', 'series'],
87      },
88    })
89    return next(e)
90  })
91
92  on('tool.call', { tool: TOOL }, async ($, e) => {
93    const input = e as unknown as Partial<Chart>
94    const labels = (input.labels ?? []).slice(0, 40).map(String)
95    const series = (input.series ?? []).slice(0, 6).map(s => ({ name: String(s.name), values: (s.values ?? []).slice(0, 40).map(Number) }))
96    if (labels.length === 0 || series.length === 0) return { deny: 'chart: give labels and at least one series.' }
97    const kind: Kind = ['bar', 'line', 'area', 'donut'].includes(String(input.kind)) ? (input.kind as Kind) : 'bar'
98    return { result: JSON.stringify({ title: String(input.title ?? 'Chart'), kind, labels, series, unit: String(input.unit ?? '') }) }
99  })
100
101  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
102    if (e.props.tool !== TOOL || e.props.isErrored || e.surface !== 'terminal') return next(e)
103    const c = parse(e.props.output)
104    if (!c) return next(e)
105    const { Box, Text, Raster } = $.ui.resolve(e)
106    const width = Math.max(40, Math.min(140, (e.viewport?.columns ?? 100) - 8))
107    const unit = c.unit ? ` ${c.unit}` : ''
108    const all = c.series.flatMap(s => s.values).filter(Number.isFinite)
109    const max = Math.max(...all, 0)
110    const min = Math.min(...all, 0)
111    const legend = c.series.length > 1 || c.kind === 'donut'
112
113    const legendOf = (items: { name: string; color: string; extra?: string }[]) => (
114      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
115        {items.map((it, i) => (
116          <Text key={`lg-${i}`}><Text color={it.color}>■</Text> {it.name}{it.extra ? <Text dimColor> {it.extra}</Text> : ''}</Text>
117        ))}
118      </Box>
119    )
120
121    if (c.kind === 'bar') {
122      const labelW = Math.min(18, Math.max(...c.labels.map(l => l.length)))
123      const barW = Math.max(10, width - labelW - 12)
124      return (
125        <Box flexDirection="column">
126          <Text bold>{c.title}</Text>
127          {c.labels.map((lab, i) => (
128            <Box key={`row-${i}`} flexDirection="column">
129              {c.series.map((s, k) => {
130                const v = s.values[i] ?? 0
131                const n = max > 0 ? Math.max(0, Math.round((v / max) * barW)) : 0
132                const color = PALETTE[k % PALETTE.length]!
133                return (
134                  <Text key={`b-${i}-${k}`}>
135                    <Text dimColor>{(k === 0 ? lab : '').slice(0, labelW).padEnd(labelW)} </Text>
136                    <Text color={color}>{'█'.repeat(n)}{n === 0 ? '▏' : ''}</Text>
137                    <Text> {fmt(v)}{unit}</Text>
138                  </Text>
139                )
140              })}
141            </Box>
142          ))}
143          {legend && legendOf(c.series.map((s, k) => ({ name: s.name, color: PALETTE[k % PALETTE.length]! })))}
144        </Box>
145      )
146    }
147
148    if (c.kind === 'donut') {
149      const s = c.series[0]!
150      const vals = c.labels.map((_, i) => Math.max(0, s.values[i] ?? 0))
151      const total = vals.reduce((a, b) => a + b, 0) || 1
152      const rows = 12
153      const cols = rows * 2 + 2
154      const cv = canvas(cols, rows)
155      const cx = (cols - 1) / 2
156      const cy = (cv.ph - 1) / 2
157      const R = Math.min(cx, cy)
158      const ends: number[] = []
159      vals.reduce((acc, v) => { ends.push(acc + v / total); return acc + v / total }, 0)
160      for (let y = 0; y < cv.ph; y++) {
161        for (let x = 0; x < cols; x++) {
162          const d = Math.hypot(x - cx, y - cy)
163          if (d > R || d < R * 0.55) continue
164          const a = ((Math.atan2(y - cy, x - cx) + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI)) / (2 * Math.PI)
165          const idx = ends.findIndex(t => a <= t)
166          cv.set(x, y, hex(PALETTE[(idx < 0 ? ends.length - 1 : idx) % PALETTE.length]!))
167        }
168      }
169      return (
170        <Box flexDirection="column">
171          <Text bold>{c.title}</Text>
172          <Box flexDirection="row" gap={3} alignItems="center">
173            <Raster key={`d-${e.props.tool_use_id}`} columns={cols} rows={rows} cells={cv.cells()} />
174            <Box flexDirection="column">
175              {c.labels.map((lab, i) => (
176                <Text key={`dl-${i}`}>
177                  <Text color={PALETTE[i % PALETTE.length]}>■</Text> {lab}
178                  <Text dimColor>  {fmt(vals[i]!)}{unit}  {Math.round((vals[i]! / total) * 100)}%</Text>
179                </Text>
180              ))}
181              <Text dimColor>total {fmt(total)}{unit}</Text>
182            </Box>
183          </Box>
184        </Box>
185      )
186    }
187
188    // line and area: pixel plot with y labels on the left and x labels below
189    const yLabW = Math.max(fmt(max).length, fmt(min).length) + unit.length + 1
190    const cols = Math.min(512, width - yLabW - 2)
191    const rows = 12
192    const cv = canvas(cols, rows)
193    const span = max - min || 1
194    const yOf = (v: number) => (cv.ph - 1) - ((v - min) / span) * (cv.ph - 1)
195    const n = c.labels.length
196    const xOf = (i: number) => (n <= 1 ? 0 : (i / (n - 1)) * (cols - 1))
197    for (let k = 0; k <= 4; k++) {
198      const y = Math.round(((cv.ph - 1) * k) / 4)
199      for (let x = 0; x < cols; x += 2) cv.set(x, y, GRID)
200    }
201    c.series.forEach((s, k) => {
202      const color = hex(PALETTE[k % PALETTE.length]!)
203      if (c.kind === 'area') {
204        const fill = ((color >> 1) & 0x7f7f7f)
205        for (let x = 0; x < cols; x++) {
206          const t = n <= 1 ? 0 : (x / (cols - 1)) * (n - 1)
207          const i = Math.floor(t)
208          const v = (s.values[i] ?? 0) + ((s.values[Math.min(n - 1, i + 1)] ?? 0) - (s.values[i] ?? 0)) * (t - i)
209          for (let y = Math.round(yOf(v)); y < cv.ph; y++) cv.set(x, y, fill)
210        }
211      }
212      for (let i = 0; i < n - 1; i++) line(cv.set, xOf(i), yOf(s.values[i] ?? 0), xOf(i + 1), yOf(s.values[i + 1] ?? 0), color)
213      for (let i = 0; i < n; i++) cv.set(xOf(i), yOf(s.values[i] ?? 0), 0xffffff)
214    })
215    const step = Math.max(1, Math.ceil(n / Math.max(1, Math.floor(cols / 8))))
216    let xAxis = ''
217    for (let i = 0; i < n; i += step) {
218      const pos = Math.round(xOf(i))
219      xAxis = xAxis.padEnd(pos) + c.labels[i]!.slice(0, 7)
220    }
221    return (
222      <Box flexDirection="column">
223        <Text bold>{c.title}</Text>
224        <Box flexDirection="row">
225          <Box flexDirection="column" width={yLabW} height={rows} justifyContent="space-between">
226            <Text dimColor>{fmt(max)}{unit}</Text>
227            <Text dimColor>{fmt((max + min) / 2)}</Text>
228            <Text dimColor>{fmt(min)}{unit}</Text>
229          </Box>
230          <Raster key={`l-${e.props.tool_use_id}`} columns={cols} rows={rows} cells={cv.cells()} />
231        </Box>
232        <Text dimColor>{' '.repeat(yLabW)}{xAxis.slice(0, cols)}</Text>
233        {legend && legendOf(c.series.map((s, k) => ({ name: s.name, color: PALETTE[k % PALETTE.length]! })))}
234      </Box>
235    )
236  })
237}
238