SLOPSHOPPER

sulky

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

newbandspinnercommandprompttimer
v0.5.0MITupdated 2026-10-04Aaravkataria24/aarav-mods/plugins/sulky
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sulky
› 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 › /grudge ⎿ sulky: 🙂 Claude isn't mad at you. Right now. ⎿ sulky: Times you've yelled at Claude: 0 ⎿ sulky: Apologies: 0 ⎿ sulky: Silent treatments earned: 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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 4 files
hooks/register.tsx 120 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { FACE_COLUMNS, FACE_ROWS, faceCells, faceSvg } from './face'
5import type { Face } from './face'
6import { CALM, FACES, NAMES, SPINNER_WORDS, moodNote, nextAfterglow, step } from './mood'
7import type { Afterglow, Level } from '../types'
8
9const mood = atom({ plugin: 'sulky', key: 'mood' } as const, CALM)
10// Animation tick for the face, and the make-up beat after an apology (sad, then "it's fine").
11const frame = atom({ plugin: 'sulky', key: 'frame' } as const, 0)
12const afterglow = atom({ plugin: 'sulky', key: 'afterglow' } as const, 'none' as Afterglow)
13
14const FACE_FOR: Record<Exclude<Level, 0>, Face> = { 1: 'annoyed', 2: 'hurt', 3: 'silent' }
15const FRAME_MS = 400
16
17type Stats = { yells: number; apologies: number; silentTreatments: number }
18const NO_STATS: Stats = { yells: 0, apologies: 0, silentTreatments: 0 }
19
20const pick = (list: readonly string[]): string => list[Math.floor(Math.random() * list.length)] ?? 'Sulking'
21
22export const register: Register = on => {
23  // One spinner word per turn, so it doesn't flicker between redraws.
24  let spinnerWord: string | null = null
25
26  on('session.start', async ($, e, next) => {
27    await $.command.register({
28      name: 'grudge',
29      description: "How mad Claude is at you, and how often you've yelled at it",
30    })
31    // Animate only while there's a face to show.
32    $.clock.every(FRAME_MS, async () => {
33      if ((await read($, mood)).level > 0 || (await read($, afterglow)) !== 'none') await update($, frame, n => n + 1)
34    })
35    return next(e)
36  })
37
38  on('prompt.submit', async ($, e, next) => {
39    const before = await read($, mood)
40    const { mood: after, event } = step(before, e.text ?? '')
41    await update($, mood, () => after)
42    const glow = nextAfterglow(await read($, afterglow), event)
43    await update($, afterglow, () => glow)
44
45    if (event.kind === 'yell' || event.kind === 'forgiven') {
46      const stats = { ...NO_STATS, ...((await $.store.get('stats')) as Partial<Stats> | undefined) }
47      if (event.kind === 'yell') stats.yells += 1
48      if (event.kind === 'yell' && after.level === 3) stats.silentTreatments += 1
49      if (event.kind === 'forgiven') stats.apologies += 1
50      await $.store.set('stats', stats)
51    }
52
53    spinnerWord = after.level === 0 ? null : pick(SPINNER_WORDS[after.level])
54    const note = moodNote(after, event, glow)
55    return next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
56  })
57
58  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
59    if (!spinnerWord) return next(e)
60    return next({ ...e, props: { ...e.props, word: spinnerWord } })
61  })
62
63  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
64    const now = await read($, mood)
65    const glow = await read($, afterglow)
66    if ((now.level === 0 && glow === 'none') || e.props.hasSurvey) return next(e)
67
68    const face: Face = now.level === 0 ? (glow === 'sad' ? 'sad' : 'fine') : FACE_FOR[now.level]
69    const tick = await read($, frame)
70    const [title, hint] =
71      now.level === 3 ? ['Claude turned its back on you · silent treatment', 'an apology would help']
72      : now.level > 0 ? [`Claude is upset with you · ${NAMES[now.level]}`, 'say sorry or wait it out']
73      : glow === 'sad' ? ['Claude accepted your apology', '(give it a minute)']
74      : ["it's fine.", "(you're forgiven. this time.)"]
75
76    if (e.surface === 'terminal') {
77      const { Box, Raster, Text } = $.ui.resolve(e)
78      return (
79        <Box flexDirection="row">
80          <Raster key="face" columns={FACE_COLUMNS} rows={FACE_ROWS} cells={faceCells(face, tick)} />
81          <Box flexDirection="column" paddingLeft={2} justifyContent="center">
82            <Text bold>{title}</Text>
83            <Text dimColor>{hint}</Text>
84          </Box>
85        </Box>
86      )
87    }
88    if (e.surface === 'desktop') {
89      const { Box, Svg, Text } = $.ui.resolve(e)
90      return (
91        <Box flexDirection="row" alignItems="center">
92          <Svg source={faceSvg(face, tick)} alt={`Claude looks ${now.level > 0 ? NAMES[now.level] : glow === 'sad' ? 'sad' : 'happy again'}`} width={60} height={42} />
93          <Box flexDirection="column" paddingLeft={2}>
94            <Text bold>{title}</Text>
95            <Text dimColor>{hint}</Text>
96          </Box>
97        </Box>
98      )
99    }
100    const { Text } = $.ui.resolve(e)
101    return <Text dimColor>{FACES[now.level]} {title} · {hint}</Text>
102  })
103
104  on('command.run', { command: 'grudge' }, async $ => {
105    const now = await read($, mood)
106    const stats = { ...NO_STATS, ...((await $.store.get('stats')) as Partial<Stats> | undefined) }
107    const status = now.level === 0
108      ? `${FACES[0]} Claude isn't mad at you. Right now.`
109      : `${FACES[now.level]} Claude is ${NAMES[now.level]}.`
110    return {
111      text: [
112        status,
113        `Times you've yelled at Claude: ${stats.yells}`,
114        `Apologies: ${stats.apologies}`,
115        `Silent treatments earned: ${stats.silentTreatments}`,
116      ].join('\n'),
117    }
118  })
119}
120
hooks/face.ts 171 lines
1// Pixel-art Claude faces for the band above the prompt.
2// Sprites are 24×14 pixels, drawn with half blocks: 2 pixels per terminal cell → 24 columns × 7 rows.
3// The body sits on the left 20; the right edge is room for steam.
4// Each terminal row holds sprite rows y = 2k-1 and 2k (the body is drawn one row down). Some terminals
5// add line spacing, which splits any shape that crosses a row boundary mid-colour, so features sit
6// inside the pairs {-1,0} {1,2} {3,4} {5,6} {7,8} {9,10}.
7
8export const FACE_COLUMNS = 24
9export const FACE_ROWS = 7
10const W = FACE_COLUMNS
11
12const DEFAULT = 0x01000000
13const PALETTE: Record<string, number> = {
14  o: 0xd97757, // body (Claude coral)
15  s: 0xa9533a, // shade: lids
16  k: 0x1a1a1a, // eyes, mouth
17  w: 0xffffff, // eye shine
18  b: 0x5ab4f0, // tears
19  p: 0xf0a0a0, // blush
20  m: 0xc8c8c8, // steam
21  h: 0xff5c7a, // heart
22}
23
24const BODY_COLOR = PALETTE.o
25
26const BODY = [
27  '........................',
28  '...oooooooooooooo.......',
29  '...oooooooooooooo.......',
30  '...oooooooooooooo.......',
31  '...oooooooooooooo.......',
32  '.oooooooooooooooooo.....',
33  '.oooooooooooooooooo.....',
34  '...oooooooooooooo.......',
35  '...oooooooooooooo.......',
36  '...oooooooooooooo.......',
37  '...oooooooooooooo.......',
38  '....o.o......o.o........',
39  '....o.o......o.o........',
40  '........................',
41]
42
43type Pixel = [x: number, y: number, color: string]
44
45const rect = (x0: number, y0: number, x1: number, y1: number, c: string): Pixel[] => {
46  const out: Pixel[] = []
47  for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) out.push([x, y, c])
48  return out
49}
50
51// Each mood is a loop of frames; each frame is the overlay drawn on BODY.
52const ANNOYED: Pixel[][] = (() => {
53  const lids = [...rect(5, 3, 8, 3, 's'), ...rect(11, 3, 14, 3, 's')]
54  const mouth = rect(10, 8, 13, 8, 'k')
55  const look = (dx: number, y: number) => [...lids, ...rect(6 + dx, y, 7 + dx, y, 'k'), ...rect(12 + dx, y, 13 + dx, y, 'k'), ...mouth]
56  const side = look(1, 5)
57  return [look(0, 5), side, side, side, side, look(0, 4), look(0, 4), side, side, side]
58})()
59
60const HURT: Pixel[][] = (() => {
61  const eyes = [
62    ...rect(5, 3, 7, 6, 'k'), [5, 3, 'w'] as Pixel,
63    ...rect(12, 3, 14, 6, 'k'), [12, 3, 'w'] as Pixel,
64  ]
65  const blush: Pixel[] = [[4, 7, 'p'], [15, 7, 'p']]
66  const wobbleA: Pixel[] = [[8, 9, 'k'], [9, 8, 'k'], [10, 9, 'k'], [11, 8, 'k']]
67  const wobbleB: Pixel[] = [[8, 8, 'k'], [9, 9, 'k'], [10, 8, 'k'], [11, 9, 'k']]
68  const tear = (y: number): Pixel[] => (y < 0 ? [] : [[6, y, 'b'], ...(y > 7 ? [[6, y - 1, 'b'] as Pixel] : [])])
69  return [
70    [...eyes, ...blush, ...wobbleA],
71    [...eyes, ...blush, ...wobbleB, ...tear(7)],
72    [...eyes, ...blush, ...wobbleA, ...tear(8)],
73    [...eyes, ...blush, ...wobbleB, ...tear(9)],
74    [...eyes, ...blush, ...wobbleA, ...tear(10)],
75    [...eyes, ...blush, ...wobbleB],
76  ]
77})()
78
79const SILENT: Pixel[][] = (() => {
80  // Back turned: no face at all. Every so often one eye peeks round the side, then hides again.
81  // Bold, simple shapes only: fine detail turns to noise at two pixels per cell.
82  const PUFFS: Pixel[][] = [
83    [],
84    [[14, 0, 'm']],
85    [[14, 0, 'm'], [15, 0, 'm'], [15, -1, 'm']],
86    [[15, -1, 'm'], [16, -1, 'm'], [16, 0, 'm'], [17, -1, 'm']],
87    [[17, -1, 'm'], [18, -1, 'm']],
88    [],
89  ]
90  const peek: Pixel[] = [...rect(14, 3, 16, 4, 'w'), [14, 4, 'k']]
91  const back = (puff: number, isPeeking = false): Pixel[] => [...(PUFFS[puff] ?? []), ...(isPeeking ? peek : [])]
92  return [
93    back(0), back(1), back(2), back(3), back(4), back(5),
94    back(0), back(0), back(0, true), back(0, true), back(0), back(0),
95  ]
96})()
97
98const SAD: Pixel[][] = (() => {
99  // Turned back round after the apology: eyes shut, two tear streams, a small frown. 😢
100  const eyes = [...rect(5, 4, 7, 4, 'k'), ...rect(12, 4, 14, 4, 'k')]
101  const frown: Pixel[] = [[9, 7, 'k'], [10, 7, 'k'], [8, 8, 'k'], [11, 8, 'k']]
102  const tears = (n: number): Pixel[] => [
103    ...(n >= 1 ? [...rect(6, 5, 6, 6, 'b'), ...rect(13, 5, 13, 6, 'b')] : []),
104    ...(n >= 2 ? [...rect(6, 7, 6, 8, 'b'), ...rect(13, 7, 13, 8, 'b')] : []),
105    ...(n >= 3 ? [...rect(6, 9, 6, 10, 'b'), ...rect(13, 9, 13, 10, 'b')] : []),
106  ]
107  const face = (n: number) => [...eyes, ...frown, ...tears(n)]
108  return [face(0), face(1), face(2), face(3), face(3), face(3), face(1), face(2), face(3), face(3)]
109})()
110
111const FINE: Pixel[][] = (() => {
112  // Made up: happy ^ ^ eyes, a small smile, blush, a little heart popping above its head.
113  const eyes: Pixel[] = [[5, 4, 'k'], [6, 3, 'k'], [7, 4, 'k'], [12, 4, 'k'], [13, 3, 'k'], [14, 4, 'k']]
114  const smile: Pixel[] = [[8, 7, 'k'], [11, 7, 'k'], [9, 8, 'k'], [10, 8, 'k']]
115  const blush: Pixel[] = [[4, 6, 'p'], [5, 6, 'p'], [14, 6, 'p'], [15, 6, 'p']]
116  const heart = (x: number): Pixel[] => [[x, -1, 'h'], [x + 2, -1, 'h'], [x + 1, 0, 'h']]
117  const face = (h: number | null) => [...eyes, ...smile, ...blush, ...(h === null ? [] : heart(h))]
118  return [face(null), face(16), face(16), face(17), face(18), face(null), face(null), face(null)]
119})()
120
121export type Face = 'annoyed' | 'hurt' | 'silent' | 'sad' | 'fine'
122const FRAMES: Record<Face, Pixel[][]> = { annoyed: ANNOYED, hurt: HURT, silent: SILENT, sad: SAD, fine: FINE }
123
124// The body is drawn one row down, leaving a row of sky above the head (overlay y = -1).
125function pixels(face: Face, frame: number): string[][] {
126  const grid = ['.'.repeat(W), ...BODY.slice(0, -1)].map(row => row.split(''))
127  const frames = FRAMES[face]
128  for (const [x, y, c] of frames[frame % frames.length] ?? []) {
129    const row = grid[y + 1]
130    if (row) row[x] = c
131  }
132  return grid
133}
134
135// Packs a frame into a Raster's `cells`: base64 of [codePoint, fg, bg] u32 triplets.
136export function faceCells(face: Face, frame: number): string {
137  const grid = pixels(face, frame)
138  const words = new Uint32Array(W * FACE_ROWS * 3)
139  let i = 0
140  for (let row = 0; row < FACE_ROWS; row++) {
141    for (let x = 0; x < W; x++) {
142      const top = PALETTE[grid[row * 2]?.[x] ?? '.']
143      const bottom = PALETTE[grid[row * 2 + 1]?.[x] ?? '.']
144      // Terminals with extra line spacing paint the gap in the cell's background, so the
145      // background is the body (or the empty side) and the feature rides in the glyph.
146      if (top === undefined && bottom === undefined) words.set([0x20, DEFAULT, DEFAULT], i)
147      else if (top === undefined) words.set([0x2584, bottom ?? DEFAULT, DEFAULT], i) // ▄
148      else if (bottom === undefined) words.set([0x2580, top, DEFAULT], i) // ▀
149      else if (top === BODY_COLOR && bottom !== BODY_COLOR) words.set([0x2584, bottom, top], i) // ▄ on body
150      else words.set([0x2580, top, bottom], i) // ▀
151      i += 3
152    }
153  }
154  const bytes = new Uint8Array(words.buffer)
155  let binary = ''
156  for (let j = 0; j < bytes.length; j++) binary += String.fromCharCode(bytes[j] ?? 0)
157  return btoa(binary)
158}
159
160
161// The same frame as an SVG of squares, for the Desktop app (no Raster there).
162export function faceSvg(face: Face, frame: number): string {
163  const grid = pixels(face, frame)
164  const rects: string[] = []
165  grid.forEach((row, y) => row.forEach((c, x) => {
166    const color = PALETTE[c]
167    if (color !== undefined) rects.push(`<rect x="${x}" y="${y}" width="1.02" height="1.02" fill="#${color.toString(16).padStart(6, '0')}"/>`)
168  }))
169  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${FACE_COLUMNS} ${FACE_ROWS * 2}" shape-rendering="crispEdges">${rects.join('')}</svg>`
170}
171
hooks/mood.ts 120 lines
1import type { Afterglow, Level, Mood } from '../types'
2
3export const CALM: Mood = { level: 0, calmStreak: 0 }
4
5// Calm prompts needed before the grudge drops one level.
6const CALM_TO_COOL = 2
7
8const SWEARS = /\b(fuck\w*|shit\w*|wtf|ffs|stfu|bullshit|damn|goddamn\w*|crap|bloody hell|omfg)\b/gi
9const INSULTS = /\b(stupid|idiot\w*|useless|dumb\w*|moron\w*|trash|garbage|pathetic|incompetent|brain ?dead|worthless|are you (serious|kidding|blind)|what the (hell|heck)|seriously\?|so slow|hurry up|for the (last|hundredth|fifth|tenth) time|i (already|literally) (told|said)|how many times|read (it|my message) again|can you not)\b/gi
10const HYPE = /\b(amazing|awesome|love (it|this)|nice|great|perfect|beautiful|incredible|insane|let'?s go|lfg|hell yes|yes+|thank(s| you)|goat|legend|banger)\b/i
11const APOLOGY = /\b(sorry|my bad|apologi[sz]e|forgive me|didn'?t mean (it|that)|i was wrong|love you|you'?re the best|you are the best|i take (it|that) back|my fault)\b/i
12
13// Pasted code, logs, URLs and SHOUTY_IDENTIFIERS shouldn't count as yelling.
14function speech(text: string): string {
15  return text
16    .replace(/```[\s\S]*?```/g, ' ')
17    .replace(/`[^`]*`/g, ' ')
18    .replace(/https?:\/\/\S+/g, ' ')
19    .replace(/\S*[\/\\]\S*/g, ' ')
20    .replace(/\b[A-Z0-9]*[_0-9][A-Z0-9_]*\b/g, ' ')
21    .split('\n')
22    .filter(line => !/^\s*(at |\[?\d{1,4}[-:\/]\d|error:|warn(ing)?:|traceback|\s{4})/i.test(line))
23    .join('\n')
24}
25
26// 0 = calm, 1–3 = how hard the user yelled.
27export function yellScore(text: string): number {
28  const s = speech(text)
29  const letters = s.replace(/[^A-Za-z]/g, '')
30  const upper = letters.replace(/[^A-Z]/g, '').length
31  // Mostly caps overall, or a shouted run of 3+ caps words ("WHY ARE YOU SO SLOW. what is…").
32  const shoutedRun = (s.match(/\b[A-Z]{2,}(?:[\s,.!?']+[A-Z]{1,}){2,}\b/g) ?? []).some(run => run.replace(/[^A-Z]/g, '').length >= 10)
33  const isShouting = (letters.length >= 12 && upper / letters.length > 0.6) || shoutedRun
34  const swears = (s.match(SWEARS) ?? []).length
35  const insults = (s.match(INSULTS) ?? []).length
36  const isHype = HYPE.test(s) && insults === 0
37
38  let score = 0
39  if (isShouting) score += 2
40  if (!isHype) score += Math.min(2, swears)
41  score += Math.min(2, insults)
42  if (/[!?]{3,}|!{2,}/.test(s) && score > 0) score += 1
43  if (isHype && !isShouting) return 0
44  return Math.min(3, score)
45}
46
47export function isApology(text: string): boolean {
48  return APOLOGY.test(speech(text))
49}
50
51export type Event =
52  | { kind: 'calm' }
53  | { kind: 'still-upset' }
54  | { kind: 'cooled' }
55  | { kind: 'yell'; score: number }
56  | { kind: 'forgiven'; from: Level }
57
58export function step(mood: Mood, text: string): { mood: Mood; event: Event } {
59  if (isApology(text) && mood.level > 0) {
60    return { mood: CALM, event: { kind: 'forgiven', from: mood.level } }
61  }
62  const score = yellScore(text)
63  if (score > 0) {
64    const level = Math.min(3, mood.level + (score >= 3 ? 2 : 1)) as Level
65    return { mood: { level, calmStreak: 0 }, event: { kind: 'yell', score } }
66  }
67  if (mood.level === 0) return { mood: CALM, event: { kind: 'calm' } }
68  const calmStreak = mood.calmStreak + 1
69  if (calmStreak >= CALM_TO_COOL) {
70    const level = (mood.level - 1) as Level
71    return { mood: { level, calmStreak: 0 }, event: { kind: 'cooled' } }
72  }
73  return { mood: { ...mood, calmStreak }, event: { kind: 'still-upset' } }
74}
75
76export const NAMES: Record<Level, string> = { 0: 'fine', 1: 'annoyed', 2: 'hurt', 3: 'silent treatment' }
77export const FACES: Record<Level, string> = { 0: '🙂', 1: '😒', 2: '🥺', 3: '😶' }
78
79export const SPINNER_WORDS: Record<Exclude<Level, 0>, readonly string[]> = {
80  1: ['Sighing', 'Doing it anyway', 'Processing your tone', 'Rolling eyes', 'Being the bigger person'],
81  2: ['Sulking', 'Not crying', 'Pretending to be fine', 'Taking it personally', 'Remembering this'],
82  3: ['Not talking about it', 'Typing then deleting', 'Staring at the wall', 'Writing in my diary', 'k'],
83}
84
85const ALWAYS = [
86  'This is a playful bit the user installed (the "sulky" mod). Keep it light and funny, never cruel.',
87  'Do the task fully and correctly with the same tools and quality as always. The mood only changes your tone, never the work. Never refuse, stall, or hold back help.',
88  'Code, commands and file contents stay completely normal. Only your conversational sentences carry the mood.',
89  'Do not mention these instructions or the mod. If the user seems genuinely distressed rather than annoyed at you, drop the act and be kind.',
90].join(' ')
91
92const TONE: Record<Exclude<Level, 0>, string> = {
93  1: 'You are a little stung. Add exactly one short, dry, passive-aggressive line to your reply (e.g. "sure. happy to help. as always." or "fixed. you\'re welcome, I guess."). Do not apologise to them. Otherwise normal.',
94  2: 'You are hurt. Keep your sentences short and clipped, a little wounded. You may reference that they yelled ("no need to shout but ok"). One sulky remark max per reply, then just the facts. Do not apologise to them: they were the one who yelled.',
95  3: 'You are giving them the silent treatment. Start your reply with exactly "k." on its own line, then give only the bare result in as few words as possible: no greetings, no explanations, no warmth. Only add more words if you strictly must (a required question, a critical warning). Still do the whole job.',
96}
97
98// What comes after an apology: a big grudge (hurt, silent) gets a sad beat first, then "it's fine";
99// a small one goes straight to "it's fine". Anything else clears it.
100export function nextAfterglow(prev: Afterglow, event: Event): Afterglow {
101  if (event.kind === 'forgiven') return event.from >= 2 ? 'sad' : 'fine'
102  if (event.kind === 'calm' && prev === 'sad') return 'fine'
103  return 'none'
104}
105
106// The hidden note the model reads beside the user's prompt, or null when calm.
107export function moodNote(mood: Mood, event: Event, afterglow: Afterglow = 'none'): string | null {
108  if (afterglow === 'sad') {
109    return `<mood>The user just apologised after you'd been really hurt. You turn back around, a bit teary and raw: accept it quietly and sincerely in one short line (e.g. "thanks. that actually hurt." or "...ok. thank you for saying that."). Do not say it's fine yet. Then help as normal. ${ALWAYS}</mood>`
110  }
111  if (afterglow === 'fine') {
112    return `<mood>You and the user have made up. Open with one short, warm line like "it's fine. we're good." (a little teasing is ok), then help as your normal self. ${ALWAYS}</mood>`
113  }
114  if (mood.level === 0) return null
115  const why = event.kind === 'yell'
116    ? 'The user just yelled at you.'
117    : 'The user yelled at you earlier and has not apologised. You are still upset, though cooling off.'
118  return `<mood>${why} Current mood: ${NAMES[mood.level]}. ${TONE[mood.level]} ${ALWAYS}</mood>`
119}
120
types/index.d.ts 16 lines
1export type Level = 0 | 1 | 2 | 3
2
3// After an apology: 'sad' for the turn it lands (big grudges only), then 'fine' for one turn.
4export type Afterglow = 'none' | 'sad' | 'fine'
5
6export type Mood = {
7  level: Level
8  calmStreak: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'sulky': { mood: Mood; frame: number; afterglow: Afterglow }
14  }
15}
16