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

Small, silly mods that give Claude Code a personality.
| Mod | What it does |
|---|---|
| sulky | Yell at Claude and it sulks. It still does the work. It just won't be happy about it. |
| drunk | Give Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly. |
| clack | Mechanical keyboard sounds. You and Claude both clack, on 13 real switches, and a little keyboard lights up. |
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.
Yell at Claude Code and it sulks. It still does the work. It just won't be happy about it.

❯ 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:

| Mood | Face | |
|---|---|---|
| 😒 | 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 |
!!!. Pasted logs, code and SHOUTY_ENV_VARS don't count, and neither does hype ("holy shit this is amazing")./grudge shows how many times you've yelled at Claude.Give Claude a /shot. It gets tipsy, then drunk, then wasted. It still does the work. Mostly.

❯ /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.

| Shots | What happens | |
|---|---|---|
| 🙂 | 1 · tipsy | loose and chatty, rosy cheeks, a light slur |
| 🥴 | 2–3 · drunk | your new best friend, winking, tongue out, bottle in hand |
| 😵 | 4+ · wasted | sentimental, loses its train of thought, sways all over the place |
| 🕶️ | hungover | after /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.inline code, file paths, URLs and IDENTIFIERS are never touched. The work itself is done sober.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.

/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.
MIT
hooks/register.tsx 120 lines1import { 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}
120hooks/face.ts 171 lines1// 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}
171hooks/mood.ts 120 lines1import 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}
120types/index.d.ts 16 lines1export 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