SLOPSHOPPER

clack

Mechanical keyboard sounds for Claude Code: you and Claude both clack, on 13 real switches, with a little keyboard that lights up.

newbandcommandpromptprocessnetwork
v0.1.0MITupdated 2026-10-04Aaravkataria24/aarav-mods/plugins/clack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clack
› 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 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keyboard ⎿ clack: ⌨ Cherry MX Blue · volume 80 ⎿ clack: ⎿ clack: ▸ mxblue Cherry MX Blue ⎿ clack: mxbrown Cherry MX Brown ⎿ clack: mxblack Cherry MX Black ⎿ clack: holypanda Holy Panda 1 2 3 4 5 6 7 8 9 0 - = bksp ⌨ Cherry MX Blue q w e r t y u i o p [ ] sound needs macOS a s d f g h j k l ; ' enter /keyboard to switch z x c v b n m , . / ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
1 2 3 4 5 6 7 8 9 0 - = bksp ⌨ Cherry MX Blue q w e r t y u i o p [ ] sound needs macOS a s d f g h j k l ; ' enter /keyboard to switch z x c v b n m , . /
README

aarav-mods

Small, silly mods that give Claude Code a personality.

ModWhat it does
sulkyYell at Claude and it sulks. It still does the work. It just won't be happy about it.
drunkGive Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly.
clackMechanical keyboard sounds. You and Claude both clack, on 13 real switches, and a little keyboard lights up.

Install

In Claude Code, add the marketplace once:

/plugin marketplace add Aaravkataria24/aarav-mods

Then install whichever mods you want:

/plugin install sulky@aarav-mods
/plugin install drunk@aarav-mods
/plugin install clack@aarav-mods

Needs Claude Code 2.1.287+ (mods are on by default). Works in the terminal and the Desktop app's Code tab. Everything runs locally: no network calls, nothing leaves your machine.


sulky

Yell at Claude Code and it sulks. It still does the work. It just won't be happy about it.

sulky in the terminal

❯ this is useless, are you serious. convert 100F to C
⏺ 37.8°C. Riveting stuff, I know.

❯ ARE YOU KIDDING ME!!! what is 5+5
⏺ k.
  10

❯ sorry, my bad. what is 2+2
⏺ ...ok. thank you for saying that.
  4

❯ what is 10/2
⏺ it's fine. we're good.
  5

Claude gets a little pixel face above your prompt that changes with its mood:

the faces

MoodFace
😒Annoyed (you snapped at it)side-eye, the occasional eye-roll
🥺Hurt (you kept going)big watery eyes, a tear rolls down
🙈Silent treatment (you really kept going)turns its back on you, fumes, peeks to check on you
😢Sad (you apologised)turns back round, crying
🥰Fine (all made up)happy again, a little heart
  • Yelling = caps, swearing at Claude, insults, !!!. Pasted logs, code and SHOUTY_ENV_VARS don't count, and neither does hype ("holy shit this is amazing").
  • Keep yelling and the grudge escalates. Say sorry (or "my bad", "love you") to make up, or just stay calm for a couple of prompts and it cools off on its own.
  • It never sabotages your work. Only the tone changes; code, commands and files are untouched.
  • The spinner gets moody too: Taking it personally…, Staring at the wall…, Writing in my diary…
  • /grudge shows how many times you've yelled at Claude.

drunk

Give Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly.

drunk in the terminal

❯ /shot tequila
  🥃 Claude downs a shot of tequila. That's 1. Claude is now tipsy.

❯ what is the capital of australia? one sentence        (3 shots in)
⏺ You're honestly my favourite human for ashking me this—I'm absholutely
  nailing geography right now—Canberra'sh the capital...

❯ show a one-line js example of a Set called sessions   (4 shots in)
⏺ I love you, you know that? hic I wash jusht thinking about how Shetsh
  are liiike... wait, never mind. hic
  const sessions = new Set();

❯ /water
  💧 Claude drinks a big glass of water. Sober now. Hungover, though.

❯ what is 2+2
⏺ please... not so loud. Four. Never again.

the faces

ShotsWhat happens
🙂1 · tipsyloose and chatty, rosy cheeks, a light slur
🥴2–3 · drunkyour new best friend, winking, tongue out, bottle in hand
😵4+ · wastedsentimental, loses its train of thought, sways all over the place
🕶️hungoverafter /water or /compact: sunglasses, ice pack, "never again"
  • /shot pours one (name the drink: /shot mezcal). After 6 the bartender cuts Claude off.
  • /water or compacting the context sobers it up, into a 3-prompt hangover.
  • The slurring is display-only. What Claude actually writes is spelled normally, so your transcript stays clean, and code blocks, inline code, file paths, URLs and IDENTIFIERS are never touched. The work itself is done sober.
  • Each message keeps the level it was written at, so scrolling back shows the night unfold.

clack

Mechanical keyboard sounds for Claude Code. Your typing clacks, and when Claude writes, you hear it typing too, at a human pace, on the same switches. A little keyboard above the prompt lights up white for your keys and coral for Claude's.

clack in the terminal

  • 13 real switches, recorded: Cherry MX Blue (default), Brown and Black, Holy Panda, Topre, NovelKeys Cream, Alpaca, Turquoise Tealios, Gateron Black Ink and Red Ink, Blue Alps, Box Navy, and the IBM Model M buckling spring.
  • Every key plays the recording for its keyboard row, with its own key-up; space, enter and backspace have their own sounds.
  • /keyboard lists switches · /keyboard holypanda switches · /keyboard volume 50 · /keyboard off / on · /keyboard hide / show (the board). Your choice is remembered.

macOS only for sound (the board works everywhere). Sounds play through a tiny bundled helper, bin/clackd, so each keystroke sounds within a few milliseconds instead of the half second a fresh player takes. It starts with your Claude Code session, plays only the bundled sounds, and quits when the session ends. Its source is helper/clackd.swift; helper/build.sh rebuilds it. It reads only the bundled sounds and makes no network calls.

Switch recordings from kbsim by Thomas Lai (MIT), see sounds/LICENSE-kbsim.

License

MIT

Source 3 files
hooks/register.tsx 186 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { BOARD_COLUMNS, BOARD_ROWS, DEFAULT_SWITCH, SWITCHES, boardCells, boardSvg, isSwitch, keyFor, keyForEdit } from './keys'
5import type { BoardKey, Lit, Switch } from './keys'
6import type { ClackSettings } from '../types'
7
8const DEFAULTS: ClackSettings = { switch: DEFAULT_SWITCH, volume: 0.8, isMuted: false, isHidden: false }
9const settings = atom({ plugin: 'clack', key: 'settings' } as const, DEFAULTS)
10// Bumped to redraw the board as keys light up and fade.
11const tick = atom({ plugin: 'clack', key: 'tick' } as const, 0)
12const isHelperUp = atom({ plugin: 'clack', key: 'isHelperUp' } as const, false)
13
14// Claude "types" at a human pace rather than at token speed.
15const CLAUDE_KEY_MS = 55
16// Past this, Claude's backlog is trimmed so the clacking keeps up with what's on screen.
17const MAX_BACKLOG = 80
18const LIT_MS = 180
19
20export const register: Register = on => {
21  const socketPath = `/tmp/clack-${crypto.randomUUID().slice(0, 8)}.sock`
22  let isReady = false
23  let backlog = ''
24  const lit = new Map<BoardKey, { by: 'you' | 'claude'; at: number }>()
25
26  on('session.start', async ($, e, next) => {
27    const started = await next(e)
28    const saved = (await $.store.get('settings')) as Partial<ClackSettings> | undefined
29    await update($, settings, () => ({ ...DEFAULTS, ...saved }))
30    await $.command.register({ name: 'keyboard', description: 'Switch keyboard sound (/keyboard holypanda), volume (/keyboard volume 50), off/on, hide/show' })
31
32    // The sound helper lives as long as the session; it preloads every switch and plays each
33    // keystroke in a few milliseconds (spawning a player per sound would lag ~half a second).
34    void (async () => {
35      try {
36        const helper = $.process.spawn({ argv: [`${$.plugin.root}/bin/clackd`, `${$.plugin.root}/sounds`, socketPath] })
37        for await (const piece of helper) {
38          if ('text' in piece && piece.text.includes('clackd ready')) {
39            isReady = true
40            await update($, isHelperUp, () => true)
41          }
42        }
43      } catch {
44        // Not macOS (or the helper couldn't start): the board still lights up, silently.
45      }
46      isReady = false
47      await update($, isHelperUp, () => false)
48    })()
49
50    // One clock drives Claude's typing and fades the lit keys.
51    $.clock.every(CLAUDE_KEY_MS, async () => {
52      const char = backlog.charAt(0)
53      if (char) {
54        backlog = backlog.slice(1)
55        // Runs of whitespace are one keystroke.
56        while (/\s/.test(char) && /^\s/.test(backlog)) backlog = backlog.slice(1)
57        const { sound, board } = keyFor(char)
58        await press(sound, board, 'claude')
59      }
60      const now = performance.now()
61      let changed = Boolean(char)
62      for (const [key, l] of lit) if (now - l.at > LIT_MS) { lit.delete(key); changed = true }
63      if (changed) await update($, tick, n => n + 1)
64    })
65
66    async function press(sound: string, board: BoardKey | null, by: 'you' | 'claude') {
67      // Synchronous, so the key is lit before the redraw this keystroke triggers.
68      if (board) lit.set(board, { by, at: performance.now() })
69      const s = await read($, settings)
70      if (!isReady || s.isMuted) return
71      const gain = (s.volume * (0.85 + Math.random() * 0.2) * (by === 'claude' ? 0.8 : 1)).toFixed(2)
72      const play = (kind: 'press' | 'release', name: string) =>
73        $.http.fetch(`http://clack/play/${s.switch}/${kind}/${name}?gain=${gain}`, { socketPath }).catch(() => undefined)
74      void play('press', sound)
75      const release = sound.startsWith('GENERIC') ? 'GENERIC' : sound
76      $.clock.after(60 + Math.random() * 40, () => void play('release', release))
77    }
78    pressFor = press
79    return started
80  })
81
82  // Set once the session starts; the hooks below play through it.
83  let pressFor: ((sound: string, board: BoardKey | null, by: 'you' | 'claude') => Promise<void>) | null = null
84
85  // Your keystrokes in the prompt box. Fire and forget: typing never waits on a sound.
86  on('prompt.edit', async ($, e, next) => {
87    if (e.key && !e.key.ctrl && !e.key.meta) {
88      const { sound, board } = keyForEdit(e.key.key)
89      void pressFor?.(sound, board, 'you')
90      await update($, tick, n => n + 1)
91    }
92    return next(e)
93  })
94
95  on('prompt.submit', async ($, e, next) => {
96    if (e.origin.kind === 'composer') void pressFor?.('ENTER', 'enter', 'you')
97    return next(e)
98  })
99
100  // Claude's words as they stream in, typed out at a human pace.
101  on('turn.step', async function* ($, e, next) {
102    for await (const chunk of next(e)) {
103      if (chunk.kind === 'text') backlog += chunk.text
104      // Tool input (a file Claude is writing, a command) is typing too.
105      if (chunk.kind === 'input') backlog += chunk.json.replace(/\\n/g, '\n').replace(/\\[t"\\]/g, ' ')
106      if (backlog.length > MAX_BACKLOG) backlog = backlog.slice(-MAX_BACKLOG)
107      yield chunk
108    }
109  })
110
111  on('command.run', { command: 'keyboard' }, async ($, e) => {
112    const [word = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
113    const current = await read($, settings)
114    let changed: ClackSettings | null = null
115    let reply = ''
116    if (word === '') {
117      const list = (Object.keys(SWITCHES) as Switch[])
118        .map(k => `${k === current.switch ? '▸' : ' '} ${k.padEnd(10)} ${SWITCHES[k]}`).join('\n')
119      reply = `⌨  ${SWITCHES[current.switch as Switch] ?? current.switch} · volume ${Math.round(current.volume * 100)}${current.isMuted ? ' · muted' : ''}\n\n${list}\n\n/keyboard <switch> · /keyboard volume 0-100 · /keyboard off|on · /keyboard hide|show`
120    } else if (isSwitch(word)) {
121      changed = { ...current, switch: word, isMuted: false }
122      reply = `⌨  Now clacking on ${SWITCHES[word]}.`
123    } else if (word === 'volume' && /^\d+$/.test(value)) {
124      const volume = Math.min(100, Number(value)) / 100
125      changed = { ...current, volume }
126      reply = `⌨  Volume ${Math.round(volume * 100)}.`
127    } else if (word === 'off' || word === 'mute') {
128      changed = { ...current, isMuted: true }
129      reply = '⌨  Muted. /keyboard on to clack again.'
130    } else if (word === 'on' || word === 'unmute') {
131      changed = { ...current, isMuted: false }
132      reply = '⌨  Clacking.'
133    } else if (word === 'hide' || word === 'show') {
134      changed = { ...current, isHidden: word === 'hide' }
135      reply = word === 'hide' ? '⌨  Keyboard hidden (sounds stay on).' : '⌨  Keyboard shown.'
136    } else {
137      reply = `Unknown switch "${word}". Try: ${Object.keys(SWITCHES).join(', ')}`
138    }
139    if (changed) {
140      const next = changed
141      await update($, settings, () => next)
142      await $.store.set('settings', next)
143      if (word && isSwitch(word)) void pressFor?.('GENERIC_R2', null, 'you')
144    }
145    return { text: reply }
146  })
147
148  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149    const s = await read($, settings)
150    if (s.isHidden || e.props.hasSurvey) return next(e)
151    await read($, tick)
152    const isUp = await read($, isHelperUp)
153    const litNow: Record<BoardKey, 'you' | 'claude'> = {}
154    for (const [key, l] of lit) litNow[key] = l.by
155    const name = SWITCHES[s.switch as Switch] ?? s.switch
156    const status = !isUp ? 'sound needs macOS' : s.isMuted ? 'muted' : `volume ${Math.round(s.volume * 100)}`
157
158    if (e.surface === 'terminal') {
159      const { Box, Raster, Text } = $.ui.resolve(e)
160      return (
161        <Box flexDirection="row">
162          <Raster key="board" columns={BOARD_COLUMNS} rows={BOARD_ROWS} cells={boardCells(litNow as Lit)} />
163          <Box flexDirection="column" paddingLeft={2} justifyContent="center">
164            <Text bold>⌨  {name}</Text>
165            <Text dimColor>{status}</Text>
166            <Text dimColor>/keyboard to switch</Text>
167          </Box>
168        </Box>
169      )
170    }
171    if (e.surface === 'desktop') {
172      const { Box, Svg, Text } = $.ui.resolve(e)
173      return (
174        <Box flexDirection="row" alignItems="center">
175          <Svg source={boardSvg(litNow as Lit)} alt="a keyboard lighting up as keys are typed" width={BOARD_COLUMNS * 6} height={BOARD_ROWS * 14} />
176          <Box flexDirection="column" paddingLeft={2}>
177            <Text bold>⌨  {name}</Text>
178            <Text dimColor>{status} · /keyboard to switch</Text>
179          </Box>
180        </Box>
181      )
182    }
183    return next(e)
184  })
185}
186
hooks/keys.ts 122 lines
1// Which sound a key makes, and the little keyboard drawn above the prompt.
2
3export const SWITCHES = {
4  mxblue: 'Cherry MX Blue',
5  mxbrown: 'Cherry MX Brown',
6  mxblack: 'Cherry MX Black',
7  holypanda: 'Holy Panda',
8  topre: 'Topre',
9  cream: 'NovelKeys Cream',
10  alpaca: 'Alpaca',
11  turquoise: 'Turquoise Tealios',
12  blackink: 'Gateron Black Ink',
13  redink: 'Gateron Red Ink',
14  bluealps: 'Blue Alps',
15  boxnavy: 'Box Navy',
16  buckling: 'Buckling Spring (IBM Model M)',
17} as const
18export type Switch = keyof typeof SWITCHES
19export const DEFAULT_SWITCH: Switch = 'mxblue'
20
21export const isSwitch = (name: string): name is Switch => Object.hasOwn(SWITCHES, name)
22
23// Board keys: one per character the board can show, plus the wide special keys.
24export type BoardKey = string // 'a'..'z', '0'..'9', punctuation, 'space', 'enter', 'backspace'
25
26const ROWS: readonly string[] = ['1234567890-=', 'qwertyuiop[]', "asdfghjkl;'", 'zxcvbnm,./']
27
28// The recordings come per keyboard row: R0 numbers, R1 qwerty, R2 asdf, R3 zxcv, R4 bottom.
29export function keyFor(char: string): { sound: string; board: BoardKey | null } {
30  if (char === ' ') return { sound: 'SPACE', board: 'space' }
31  if (char === '\n' || char === '\r') return { sound: 'ENTER', board: 'enter' }
32  const c = char.toLowerCase()
33  const row = ROWS.findIndex(r => r.includes(c))
34  if (row >= 0) return { sound: `GENERIC_R${row}`, board: c }
35  if (/[!@#$%^&*()_+]/.test(c)) return { sound: 'GENERIC_R0', board: '1234567890-='['!@#$%^&*()_+'.indexOf(c)] ?? null }
36  return { sound: 'GENERIC_R4', board: null }
37}
38
39// The key a prompt-box edit came from, as `prompt.edit` names it.
40export function keyForEdit(key: string): { sound: string; board: BoardKey | null } {
41  const k = key.toLowerCase()
42  if (k === 'space') return keyFor(' ')
43  if (k === 'return' || k === 'enter') return keyFor('\n')
44  if (k === 'backspace' || k === 'delete') return { sound: 'BACKSPACE', board: 'backspace' }
45  if (k.length === 1) return keyFor(k)
46  return { sound: 'GENERIC_R4', board: null }
47}
48
49// ---- the board: a Raster of keycaps (glyph on a coloured cap), one terminal row per key row ----
50
51export type Lit = Readonly<Record<BoardKey, 'you' | 'claude'>>
52
53type Cap = { key: BoardKey; label: string; width: number }
54const capsFor = (row: string): Cap[] => row.split('').map(c => ({ key: c, label: c, width: 3 }))
55const LAYOUT: { indent: number; caps: Cap[] }[] = [
56  { indent: 0, caps: [...capsFor(ROWS[0] ?? ''), { key: 'backspace', label: 'bksp', width: 6 }] },
57  { indent: 2, caps: capsFor(ROWS[1] ?? '') },
58  { indent: 3, caps: [...capsFor(ROWS[2] ?? ''), { key: 'enter', label: 'enter', width: 7 }] },
59  { indent: 5, caps: capsFor(ROWS[3] ?? '') },
60  { indent: 13, caps: [{ key: 'space', label: '', width: 23 }] },
61]
62export const BOARD_COLUMNS = Math.max(...LAYOUT.map(r => r.indent + r.caps.reduce((n, c) => n + c.width + 1, 0)))
63export const BOARD_ROWS = LAYOUT.length
64
65const DEFAULT = 0x01000000
66const CAP = 0x3a3a3c
67const LEGEND = 0xa8a8ad
68const YOU = 0xf2f2f2
69const YOU_LEGEND = 0x1a1a1a
70const CLAUDE = 0xd97757
71const CLAUDE_LEGEND = 0xffffff
72
73function grid(lit: Lit): { char: number; fg: number; bg: number }[][] {
74  return LAYOUT.map(({ indent, caps }) => {
75    const row: { char: number; fg: number; bg: number }[] = []
76    const blank = () => row.push({ char: 0x20, fg: DEFAULT, bg: DEFAULT })
77    for (let i = 0; i < indent; i++) blank()
78    for (const cap of caps) {
79      const by = lit[cap.key]
80      const bg = by === 'you' ? YOU : by === 'claude' ? CLAUDE : CAP
81      const fg = by === 'you' ? YOU_LEGEND : by === 'claude' ? CLAUDE_LEGEND : LEGEND
82      const pad = Math.floor((cap.width - cap.label.length) / 2)
83      for (let x = 0; x < cap.width; x++) {
84        const ch = cap.label[x - pad]
85        row.push({ char: ch ? ch.charCodeAt(0) : 0x20, fg, bg })
86      }
87      blank()
88    }
89    while (row.length < BOARD_COLUMNS) blank()
90    return row
91  })
92}
93
94// Packs the board into a Raster's `cells`: base64 of [codePoint, fg, bg] u32 triplets.
95export function boardCells(lit: Lit): string {
96  const cells = grid(lit).flat()
97  const words = new Uint32Array(cells.length * 3)
98  cells.forEach((c, i) => words.set([c.char, c.fg, c.bg], i * 3))
99  const bytes = new Uint8Array(words.buffer)
100  let binary = ''
101  for (let j = 0; j < bytes.length; j++) binary += String.fromCharCode(bytes[j] ?? 0)
102  return btoa(binary)
103}
104
105// The same board as SVG, for the Desktop app (no Raster there).
106export function boardSvg(lit: Lit): string {
107  const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
108  const parts: string[] = []
109  LAYOUT.forEach(({ indent, caps }, y) => {
110    let x = indent
111    for (const cap of caps) {
112      const by = lit[cap.key]
113      const bg = by === 'you' ? YOU : by === 'claude' ? CLAUDE : CAP
114      const fg = by === 'you' ? YOU_LEGEND : by === 'claude' ? CLAUDE_LEGEND : LEGEND
115      parts.push(`<rect x="${x * 8}" y="${y * 18}" width="${cap.width * 8}" height="15" rx="3" fill="${hex(bg)}"/>`)
116      if (cap.label) parts.push(`<text x="${(x + cap.width / 2) * 8}" y="${y * 18 + 11}" font-family="monospace" font-size="10" text-anchor="middle" fill="${hex(fg)}">${cap.label.replace('&', '&amp;').replace('<', '&lt;')}</text>`)
117      x += cap.width + 1
118    }
119  })
120  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${BOARD_COLUMNS * 8} ${BOARD_ROWS * 18}">${parts.join('')}</svg>`
121}
122
types/index.d.ts 14 lines
1export type ClackSettings = {
2  switch: string
3  // 0 to 1.
4  volume: number
5  isMuted: boolean
6  isHidden: boolean
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'clack': { settings: ClackSettings; tick: number; isHelperUp: boolean }
12  }
13}
14