SLOPSHOPPER

joka-pond

Animated pond pane that levels up a consulting career as Claude works; open with /pond

newpaneguardcommandtoasttimer
v0.1.0MITupdated 2026-10-26Ishu-dev/claude-code-mods/mods/joka-pond
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · joka-pond
│ ┃ Joka Pond ✕ › 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 │ ┃ ~~~~~≈≈~~~~~≈≈~~~~~≈≈~~~~~ │ ┃ ~ >o> ~ ~ ~ ~ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ~ ~ ~ ~ │ ┃ ~ ~ ~ ~ ~ ~ ~ ✻ Worked for 42s · done 4:20 PM │ ┃ ~ ~ ~ │ ┃ › /pond │ ┃ Intern 59 XP, 41 to Analyst ⎿ joka-pond: Joka Pond opened. Intern, 59 XP, 1-day streak. │ ┃ [▓███████████████████████████░░░░░░░░░░░░░░░ │ ┃ ░░░░░] 59% │ ┃ 🔥 1-day streak storm passing... │ ┃ +6 Bash │ ┃ +6 Bash │ ┃ +6 Bash │ ┃ ⛈ Bash failed -3 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Joka Pond
╱ ╱╱ ╱╱ ╱ ╱ ╱╱ ╱╱ ╱ ╱ ╱ ╱ ╱ ╱ ╱ ╱╱ ╱ ╱╱╱╱ ╱ ╱ ╱ ╱ ╱╱ ╱╱ ╱╱ ╱ ╱╱ ╱ ✿ ≈≈~~~~~≈≈~~~~~≈≈~~~~~≈≈~~~~~≈≈~~~~~≈≈~~~~~≈≈~~~~~≈≈~~~~~≈≈~~ ~~~≈≈~~~~~ ~ >o> ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ Intern 59 XP, 41 to Analyst [▓███████████████████████████░░░░░░░░░░░░░░░░░░░░] 59% 🔥 1-day streak storm passing... +6 Bash +6 Bash +6 Bash ⛈ Bash failed -3
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 2 files
hooks/register.tsx 183 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Pond } from '../types'
5
6const PANE = 'joka-pond'
7const frame = atom({ plugin: 'joka-pond', key: 'frame' } as const, 0)
8const pond = atom({ plugin: 'joka-pond', key: 'pond' } as const, { xp: 0, streak: 0, stormUntil: 0, partyUntil: 0, log: [] })
9
10const LADDER = [
11  { at: 0, title: 'Intern' },
12  { at: 100, title: 'Analyst' },
13  { at: 300, title: 'Associate' },
14  { at: 700, title: 'Engagement Manager' },
15  { at: 1500, title: 'Principal' },
16  { at: 3000, title: 'Partner' },
17  { at: 6000, title: 'Managing Partner' },
18]
19const levelOf = (xp: number) => LADDER.reduce((lv, step, i) => (xp >= step.at ? i : lv), 0)
20
21const XP: Record<string, number> = { Write: 15, Edit: 10, Bash: 6, Agent: 20, WebSearch: 8, Read: 2, Grep: 2, Glob: 2 }
22
23// Small deterministic noise so the scene is stable between redraws of one frame
24const noise = (a: number, b: number) => {
25  const x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453
26  return x - Math.floor(x)
27}
28
29export const register: Register = on => {
30  let tick = 0
31
32  on('session.start', async ($, e, next) => {
33    await $.command.register({ name: 'pond', description: 'Open Joka Pond: your animated XP pond' })
34
35    // Load saved progress and update the daily streak
36    const saved = ((await $.store.get('pond')) as Partial<Pond> | undefined) ?? {}
37    const today = new Date(await $.clock.now()).toISOString().slice(0, 10)
38    const lastDay = ((await $.store.get('lastDay')) as string | undefined) ?? ''
39    const yesterday = new Date((await $.clock.now()) - 86400000).toISOString().slice(0, 10)
40    const streak = lastDay === today ? saved.streak ?? 1 : lastDay === yesterday ? (saved.streak ?? 0) + 1 : 1
41    await $.store.set('lastDay', today)
42    await update($, pond, () => ({ xp: saved.xp ?? 0, streak, stormUntil: 0, partyUntil: 0, log: [] }))
43
44    // The animation clock: 5 frames a second
45    $.clock.every(200, () => {
46      tick += 1
47      void update($, frame, () => tick)
48    })
49
50    return next(e)
51  })
52
53  on('command.run', { command: 'pond' }, async $ => {
54    await $.ui.open({ id: PANE, title: 'Joka Pond' })
55    const p = await read($, pond)
56    return { text: `Joka Pond opened. ${LADDER[levelOf(p.xp)]!.title}, ${p.xp} XP, ${p.streak}-day streak.` }
57  })
58
59  on('tool.call', async ($, e, next) => {
60    const ran = await next(e)
61    const failed = ran.deny !== undefined || ran.isError === true
62    const before = await read($, pond)
63    const tool = 'tool' in e ? String(e.tool) : 'tool'
64    const gain = failed ? -3 : XP[tool] ?? 3
65    const xp = Math.max(0, before.xp + gain)
66    const promoted = levelOf(xp) > levelOf(before.xp)
67    const entry = failed ? `⛈  ${tool} failed  -3` : `+${gain}  ${tool}`
68
69    const after: Pond = {
70      ...before,
71      xp,
72      stormUntil: failed ? tick + 15 : before.stormUntil,
73      partyUntil: promoted ? tick + 25 : before.partyUntil,
74      log: [entry, ...before.log].slice(0, 4),
75    }
76    await update($, pond, () => after)
77    await $.store.set('pond', after)
78
79    if (promoted) {
80      const title = LADDER[levelOf(xp)]!.title
81      $.ui.toast(`🎉 Promoted to ${title}!`)
82      void $.audio.speak(`Congratulations. You have been promoted to ${title}.`).catch(() => {})
83    }
84    return ran
85  })
86
87  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88    const { Box, Text } = $.ui.resolve(e)
89    const f = await read($, frame)
90    const p = await read($, pond)
91    const W = Math.max(24, Math.min(70, (e.viewport?.columns ?? 50) - 2))
92    const lv = levelOf(p.xp)
93    const stormy = f < p.stormUntil
94    const party = f < p.partyUntil
95    const hour = new Date().getHours()
96    const night = hour >= 19 || hour < 6
97
98    // Sky: drifting stars or clouds, rain in a storm, confetti at a promotion
99    const sky = [0, 1].map(row =>
100      Array.from({ length: W }, (_, x) => {
101        if (party && noise(x + f, row) > 0.8) return ['*', '+', '·', '✦'][Math.floor(noise(x, f + row) * 4)]!
102        if (stormy) return noise(x + f * 2, row) > 0.7 ? '╱' : ' '
103        if (row === 0 && x === W - 6) return night ? '☾' : '☀'
104        if (night) return noise(x, row) > 0.93 ? (noise(x, f >> 2) > 0.5 ? '·' : '✧') : ' '
105        return noise(Math.floor((x + f / 3) / 6), row) > 0.85 ? '░' : ' '
106      }).join(''),
107    )
108    const skyColor = party ? 'magenta' : stormy ? 'gray' : night ? 'white' : 'yellow'
109
110    // Lotus line: one bloom per level, gently bobbing
111    const lotusRow = Array.from({ length: W }, (_, x) => {
112      const slot = Math.floor(W / (lv + 2))
113      const isLotus = x > 0 && x % slot === 0 && x / slot <= lv + 1
114      return isLotus ? ((f >> 2) % 2 === 0 ? '✿' : '❀') : ' '
115    }).join('')
116
117    // Water with fish: more fish as the career grows
118    const fishCount = Math.min(8, lv + 2)
119    const water = [0, 1, 2].map(row => {
120      const cells: string[] = Array.from({ length: W }, (_, x) => {
121        const wave = (x + f + row * 3) % 7
122        return row === 0 ? (wave < 2 ? '≈' : '~') : wave === 0 ? '~' : ' '
123      })
124      for (let i = 0; i < fishCount; i++) {
125        if (i % 3 !== row) continue
126        const speed = 1 + (i % 3)
127        const span = W + 6
128        const raw = (Math.floor((f * speed) / 2) + i * 13) % (span * 2)
129        const right = raw < span
130        const x = right ? raw - 3 : span * 2 - raw - 3
131        const fish = right ? '><>' : '<><'
132        for (let k = 0; k < 3; k++) if (x + k >= 0 && x + k < W) cells[x + k] = fish[k]!
133        if (noise(i, f >> 1) > 0.85 && row > 0 && x + 1 >= 0 && x + 1 < W) cells[x + 1] = 'o'
134      }
135      return cells.join('')
136    })
137
138    const cur = LADDER[lv]!
139    const nxt = LADDER[lv + 1]
140    const pct = nxt ? (p.xp - cur.at) / (nxt.at - cur.at) : 1
141    const barW = Math.max(10, W - 22)
142    const fill = Math.round(pct * barW)
143    const shimmer = (f >> 1) % Math.max(1, fill + 4)
144
145    return (
146      <Box flexDirection="column">
147        {sky.map((line, i) => <Text key={`sky-${i}`} color={skyColor}>{line}</Text>)}
148        <Text color="magenta">{lotusRow}</Text>
149        {water.map((line, i) => {
150          const base = stormy ? 'gray' : i === 0 ? 'cyan' : 'blue'
151          const fishColor = stormy ? 'gray' : 'yellow'
152          // group runs of fish and water characters so each run gets one colour
153          const runs: { text: string; isFish: boolean }[] = []
154          for (const ch of line) {
155            const isFish = ch === '>' || ch === '<'
156            const last = runs[runs.length - 1]
157            if (last && last.isFish === isFish) last.text += ch
158            else runs.push({ text: ch, isFish })
159          }
160          return (
161            <Text key={`w-${i}`}>
162              {runs.map((r, k) => <Text key={`r-${i}-${k}`} color={r.isFish ? fishColor : base}>{r.text}</Text>)}
163            </Text>
164          )
165        })}
166        <Text> </Text>
167        <Text>
168          <Text bold color={party ? 'magenta' : 'green'}>{party ? '🎉 ' : ''}{cur.title}</Text>
169          <Text dimColor>  {p.xp} XP{nxt ? `, ${nxt.at - p.xp} to ${nxt.title}` : ', top of the firm'}</Text>
170        </Text>
171        <Text>
172          {'['}
173          {Array.from({ length: barW }, (_, i) =>
174            i < fill ? (i === shimmer ? '▓' : '█') : '░').join('')}
175          {']'} <Text dimColor>{Math.round(pct * 100)}%</Text>
176        </Text>
177        <Text dimColor>🔥 {p.streak}-day streak   {stormy ? 'storm passing...' : party ? 'promotion party!' : night ? 'night shift' : 'calm waters'}</Text>
178        {p.log.map((l, i) => <Text key={`log-${i}`} color={l.startsWith('⛈') ? 'red' : 'green'} dimColor={i > 0}>{l}</Text>)}
179      </Box>
180    )
181  })
182}
183
types/index.d.ts 14 lines
1export type Pond = {
2  xp: number
3  streak: number
4  stormUntil: number
5  partyUntil: number
6  log: string[]
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'joka-pond': { frame: number; pond: Pond }
12  }
13}
14