Counts the swearing in your prompts (English, German, Polish, Russian, Ukrainian): a heat meter, a swear jar, or Bluey-style child-friendly swaps


In 2008 Thom Holwerda drew the definitive code quality metric for OSNews: two doors marked CODE REVIEW, and the only number that matters is how many WTFs per minute come out from behind them. Robert C. Martin opened Clean Code with it.
The code review is a chat with Claude now, and the WTFs are typed. So this is a Claude Code mod that counts them, live, per message: in English, German, Polish, Russian and Ukrainian.
<img src="docs/meter.jpg" alt="A vintage panel meter labeled WTF / MSG, needle slammed into MELTDOWN, with kurwa, wtf and Scheiße floating above it" width="420" align="right">
What you get
[WTF +3 · бля]🔴 Meltdown · 9 WTFs ▲/wtf prints the tallyWorks in the Claude Code terminal and the Code tab of the Claude desktop app. Nothing leaves your machine unless you turn on Slack posting, and even then your words don't.
In the Claude desktop app (mockup of the Code tab: Claude's windows can't be screen-captured, so this is drawn from the mod's real output):
<img src="docs/screenshots/desk-meter.png" alt="Desktop mockup: each sweary message carries a colored pill like +3 · kurwa, a Meltdown strip with bars sits above the prompt, and the status bar reads 🔴 Meltdown · 5 WTFs ▲" width="760">
In the terminal (real session, captured from claude and rendered as-is):
<img src="docs/screenshots/meter-term.png" alt="Terminal: each sweary prompt is stamped [WTF +3 · kurwa], the strip above the prompt reads Meltdown with bars and avg 3.00 · rising, and the status line reads 🔴 Meltdown · 5 WTFs ▲" width="760">

The level is the average score of your last 4 messages, so one truly bad message can skip a stage.
stateDiagram-v2
direction LR
[*] --> Calm
Calm --> Grumbling: avg ≥ 0.6
Grumbling --> Heated: avg ≥ 1.5
Heated --> Meltdown: avg ≥ 2.5
Meltdown --> Heated: avg < 2.5
Heated --> Grumbling: avg < 1.5
Grumbling --> Calm: avg < 0.6
Inspired by Bud Light's "Swear Jar" ad: the office jar that paid for beer, until everyone started swearing on purpose. Switch mode to jar and every swear drops coins in, 25¢ per point (damn 25¢, wtf 50¢, kurwa 75¢). The jar never empties and carries over between sessions. Every $20 is a beer run.
<img src="docs/screenshots/desk-jar.png" alt="Desktop mockup in jar mode: messages carry coin pills like 🪙 +$0.75 · kurwa, and the strip shows 🫙 $3.00, a progress bar, $17.00 to a beer run, and a Skip Slack post button" width="760">
<img src="docs/screenshots/jar-term.png" alt="Terminal jar mode: prompts stamped [jar 🪙 +$1.50 · fucking], strip reads 🫙 $3.75 with a block progress bar and $16.25 to a beer run" width="760">
If Slack is connected in Claude (the Slack connector or the Slack plugin), the jar can post to a team channel as you:
<img src="docs/screenshots/slack.png" alt="Mockup of a #swear-jar Slack channel: four teammates' posts like +$2.50 in the swear jar, fighting a flaky test, and one that fills a jar and calls a beer run" width="620">
Your words never reach Slack. A post is built only from the amount and one topic picked from a fixed list (a flaky test, a deploy, CI, a merge conflict, the AI itself, …; see hooks/jar.ts). A small model picks the topic, and any answer that is not exactly on the list becomes something. File names, project names, numbers and your actual message can't get into a post, whatever the model says.
Not every swear posts. Coins drop on each one, but a post is queued only when the session gets Heated (roughly two strong swears in your last four messages), and it covers the whole blow-up since you were last calm: the total and up to two topics. After that, nothing new posts until you've calmed down and blown up again.
A queued post shows as a small ↗ queued · 2 min chip in the jar strip. Press it to see the exact line that will post, with Post now and Skip. /wtf skip also cancels. Skipped coins stay in your jar.
Setup: create the channel, then set two plugin options (/config in the terminal, or ~/.claude/settings.json):
"pluginConfigs": {
"wtf-meter@wtf-meter": {
"options": { "mode": "jar", "slackChannel": "swear-jar" }
}
}
Two accounts on one machine (say, personal and work)? Add "jarOnlyFor": "@company.com" to keep the jar on the work account and the meter everywhere else, and "slackOnlyFor": "@company.com" so only the work account posts. The desktop app tells the mod who is signed in; in the terminal, set WTF_METER_ACCOUNT_EMAIL in that account's settings env. An account the mod can't identify never posts.
slackChannel takes the channel name, optionally followed by its ID ("swear-jar C0123ABCD", the ID from the channel's "Copy link"). Add the ID if the name matches more than one channel. Messages always show the name. No Slack connected: the jar stays local and a toast says so.
For cleaning up your language before the kids pick it up. Set mode to bluey, and each sweary message is drawn as its child-friendly version: you typed wtf, run the tests, the chat shows biscuits, run the tests, with you typed: wtf underneath. In the terminal your message stays as typed, with one line added under it: [bluey · wtf → biscuits!].
| Swear | English | Deutsch | Polski | Українська | Русский |
|---|---|---|---|---|---|
medium (wtf, verdammt, сука) | biscuits! | Potzblitz! | – | отакої! | ёлки-палки! |
strong (the f-word, kurwa, Scheiße, mat) | boogerbeans! | ach du dickes Ei! | o kurka wodna! | ой лишенько! | ёшкин кот! |
| a whole message scoring 6+ | boogerbeans on toast! | ach du grüne Neune! | o matko i córko! | матінко рідна! | батюшки мои! |
Mild words (damn, kurde, блин) stay as they are: they're what parents already say instead. Russian and Ukrainian share the mat roots, so a message reads as Russian only when it has ы, э, ъ or ё.
From your first swap on, the strip counts your clean streak instead of a heat level (status line: 🍪 Clean for 5 · 1 swap): messages since your last swap, your best run this session, and the last few messages as dots. Break a streak of 5 or more and you get a toast. Bluey mode never posts to Slack.
"pluginConfigs": {
"wtf-meter@wtf-meter": { "options": { "mode": "bluey" } }
}
Needs a Claude Code build with function-hook mods (early access; built against 2.1.293).
/plugin marketplace add dreamiurg/wtf-meter
/plugin install wtf-meter@wtf-meter
Or from a shell:
claude plugin marketplace add dreamiurg/wtf-meter
claude plugin install wtf-meter@wtf-meter
Start a new session after installing.
Get new versions automatically: /plugin → Marketplaces → wtf-meter → Enable auto-update. Claude Code then checks for updates shortly after each session starts. Without it, update by hand with claude plugin marketplace update wtf-meter && claude plugin update wtf-meter@wtf-meter.
Every word in hooks/lexicon.ts has a weight: 1 mild (damn, kurde, блин), 2 medium (wtf, verdammt, сука), 3 strong (the f-word, kurwa, Scheiße, the Russian and Ukrainian mat roots). Censored spellings that dictation tools produce (f*, п*ц) count too.
Russian and Ukrainian share most of the mat roots, so they share patterns. Each pattern needs a non-letter before it, because JavaScript's \b does not see Cyrillic. That keeps hello, class, fickle, mister, корабля, Херсон and блины clean. Known miss: Ебург.
Only what you type counts. Background task notifications, scheduled prompts and messages from other agents are skipped.
Meter scores live in the session's own state and go away with it; the jar total is kept in the plugin's local store. The stamps and Bluey swaps change only how your message is drawn: Claude still reads exactly what you typed. Slack posting is off until you set a channel, and a post never contains your words (see swear jar mode). Picking the topic is one small model call through your own Claude Code session.
claude plugin validate .
claude plugin test .
pre-commit install # blocks secrets and personal data (emails, home paths) on commit
Run a session with claude --plugin-dir . to load your working copy; saving a file reloads it. Disable the installed copy first (/plugin disable wtf-meter@wtf-meter), or every message counts twice. tsc -p . works after the first load, which lays the API types into .claude-plugin/types/.
Releases are automatic: once CI passes on main, any feat: or fix: commits ship as a new version (Release Please bumps plugin.json, tags, and writes the changelog).
MIT
hooks/register.tsx 459 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Msg, Pending } from '../types'
5import { channelIdIn, channelLabel, channelLink, cleanTopic, parseChannel, CENTS_PER_POINT, JAR_CENTS, money, postText, topicPrompt } from './jar'
6import { blueyStatus, blueyVersion, cap, streak, swappable } from './bluey'
7import { LEVELS, levelOf, score, stampColor, worstHit } from './lexicon'
8import type { Hit } from './lexicon'
9
10const msgs = atom({ plugin: 'wtf-meter', key: 'msgs' } as const, [])
11const isHidden = atom({ plugin: 'wtf-meter', key: 'isHidden' } as const, false)
12const jarCents = atom({ plugin: 'wtf-meter', key: 'jarCents' } as const, 0)
13const pending = atom({ plugin: 'wtf-meter', key: 'pending' } as const, null)
14const cooling = atom({ plugin: 'wtf-meter', key: 'cooling' } as const, false)
15const isPreviewOpen = atom({ plugin: 'wtf-meter', key: 'isPreviewOpen' } as const, false)
16const tick = atom({ plugin: 'wtf-meter', key: 'tick' } as const, 0)
17
18const JAR_KEY = 'jarCents' // $.store: the jar outlives the session, like a real one
19const POST_DELAY_MS = 2 * 60 * 1000
20const JAR_COLOR = '#c39a1c'
21const BLUEY_COLOR = '#3f7fc4'
22const SWAP_COLOR = '#d9963f'
23const STREAK_TOAST = 5 // a clean run this long gets a toast when it breaks
24
25// Only what the person typed counts: not task notifications, peers, plugins.
26const NOT_TYPED = new Set([
27 'task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay',
28 'channel', 'coordinator', 'observer', 'observer-activity', 'auto-continuation', 'plugin',
29 'unclassified',
30])
31const isTyped = (origin: { kind: string } | undefined) => !NOT_TYPED.has(origin?.kind ?? '')
32
33const worstOf = (hits: readonly Hit[]) => worstHit(hits)?.word.toLowerCase() ?? null
34
35function meterStatus(list: readonly Msg[]) {
36 const scores = list.map(m => m.score)
37 const now = levelOf(scores)
38 const before = levelOf(scores.slice(0, -1))
39 const wtf = list.reduce((a, m) => a + m.hits, 0)
40 const arrow = now.avg > before.avg ? ' ▲' : now.avg < before.avg ? ' ▼' : ''
41 return `${now.level.dot} ${now.level.name} · ${wtf} WTF${wtf === 1 ? '' : 's'}${arrow}`
42}
43
44const jarStatus = (cents: number) => `🫙 ${money(cents)} · ${money(JAR_CENTS - (cents % JAR_CENTS))} to a beer run`
45
46// Set from the plugin's options when the module registers; a reload resets them.
47const cfg = { mode: 'meter', jarOnlyFor: '', jar: undefined as boolean | undefined, channel: '', onlyFor: '', name: '', id: '' }
48let timer: { cancel: () => void } | undefined
49let ticker: { cancel: () => void } | undefined // redraws the countdown while a post waits
50
51async function showStatus($: EngineInterface) {
52 if (await jarMode($)) return $.ui.status(jarStatus(await read($, jarCents)))
53 const list = await read($, msgs)
54 if (cfg.mode === 'bluey') return $.ui.status(blueyStatus(streak(list.map(m => m.swaps))))
55 $.ui.status(list.length ? meterStatus(list) : undefined)
56}
57
58const textOf = (r: { content: { type: string; text?: string }[] }) => r.content.map(c => c.text ?? '').join('\n')
59
60// Posts through whatever Slack connector the session has: the first MCP tool
61// named slack_send_message. Its argument names aren't documented, so the two
62// common shapes are tried; a rejected shape posts nothing.
63async function sendToSlack($: EngineInterface, text: string): Promise<string | null> {
64 const tools = await $.tool.list()
65 const send = tools.find(t => t.mcp && /__slack_send_message$/.test(t.name))
66 if (!send) return 'no Slack connector in this session. Connect Slack in Claude, or clear the channel setting.'
67 const server = send.name.slice('mcp__'.length, -'__slack_send_message'.length)
68
69 let channel = cfg.id || String((await $.store.get(`channel:${cfg.name}`)) ?? '')
70 if (!channel) {
71 const found = await $.mcp
72 .call(server, 'slack_search_channels', { query: cfg.name })
73 .catch(() => undefined)
74 channel = (found && !found.isError && channelIdIn(textOf(found))) || ''
75 if (!channel) return `couldn't find ${channelLabel(cfg)}. Add its ID (C…) after the name in the channel setting.`
76 await $.store.set(`channel:${cfg.name}`, channel)
77 }
78
79 let last = ''
80 for (const args of [{ channel_id: channel, message: text }, { channel_id: channel, text }]) {
81 const r = await $.mcp.call(server, 'slack_send_message', args).catch((err: unknown) => ({ isError: true, content: [{ type: 'text', text: String(err) }] }))
82 if (!r.isError) return null
83 last = textOf(r)
84 }
85 return last.slice(0, 160) || 'Slack refused the message.'
86}
87
88// The signed-in account: the desktop app sets CLAUDE_CODE_USER_EMAIL; elsewhere set
89// WTF_METER_ACCOUNT_EMAIL. An account the mod can't see never matches.
90async function accountIs($: EngineInterface, suffix: string) {
91 const email = ((await $.env.get('WTF_METER_ACCOUNT_EMAIL')) ?? (await $.env.get('CLAUDE_CODE_USER_EMAIL')) ?? '').toLowerCase()
92 return email !== '' && email.endsWith(suffix)
93}
94
95// Jar mode, unless jarOnlyFor names another account; that account gets the meter.
96async function jarMode($: EngineInterface) {
97 cfg.jar ??= cfg.mode === 'jar' && (!cfg.jarOnlyFor || (await accountIs($, cfg.jarOnlyFor)))
98 return cfg.jar
99}
100
101// Posts only from the account slackOnlyFor names, and only in jar mode.
102async function canPost($: EngineInterface) {
103 if (!cfg.channel || !(await jarMode($))) return false
104 return !cfg.onlyFor || accountIs($, cfg.onlyFor)
105}
106
107const HEATED = LEVELS[2].min
108
109async function topicFor($: EngineInterface, texts: readonly string[]) {
110 const r = await $.model
111 .complete({ model: 'haiku', prompt: topicPrompt(texts), maxTokens: 16, timeoutMs: 20000 })
112 .catch(() => undefined)
113 return cleanTopic(r?.isAnswered ? r.text : undefined) // no answer: "something", still posts
114}
115
116// Drafts the exact line that will post; redrafted when a new swear changes the amount.
117async function draft($: EngineInterface) {
118 const b = await read($, pending)
119 if (!b?.queued) return
120 const text = postText(b.cents, await topicFor($, b.texts), await read($, jarCents), b.pick)
121 const isFirst = !b.preview
122 await update($, pending, p => (p && p.cents === b.cents ? { ...p, preview: text } : p))
123 if (isFirst) $.ui.toast(`Queued for ${channelLabel(cfg)}: ${text} Posts in 2 min; /wtf skip cancels.`)
124}
125
126function arm($: EngineInterface, ms: number) {
127 timer?.cancel()
128 ticker?.cancel()
129 timer = $.clock.after(Math.max(0, ms), () => void flush($))
130 ticker = $.clock.every(15000, () => void update($, tick, n => n + 1))
131}
132
133function disarm() {
134 timer?.cancel()
135 ticker?.cancel()
136 timer = undefined
137 ticker = undefined
138}
139
140// Adds a message to the current blow-up and queues the post once the session is Heated.
141async function track($: EngineInterface, text: string | null, cents: number, avg: number) {
142 const heated = avg >= HEATED
143 if (!heated) await update($, cooling, () => false)
144 let p: Pending | null = await read($, pending)
145 if (text) {
146 p = p
147 ? { ...p, cents: p.cents + cents, texts: [...p.texts, text] }
148 : { cents, texts: [text], queued: false, preview: null, pick: Math.random(), dueAt: 0 }
149 }
150 if (p && !p.queued && avg < LEVELS[1].min) p = null // calmed down without a blow-up
151 if (p && !p.queued && heated && !(await read($, cooling))) {
152 p = { ...p, queued: true, dueAt: (await $.clock.now()) + POST_DELAY_MS }
153 arm($, POST_DELAY_MS)
154 }
155 await update($, pending, () => p)
156 if (p?.queued && text) $.clock.after(0, () => void draft($))
157}
158
159// Posts the previewed line, once the delay is up or on Post now.
160async function flush($: EngineInterface) {
161 disarm()
162 const b = await read($, pending)
163 if (!b?.queued) return
164 await update($, pending, () => null)
165 await update($, cooling, () => true)
166 await update($, isPreviewOpen, () => false)
167 if (!(await canPost($))) return
168 const text = b.preview ?? postText(b.cents, await topicFor($, b.texts), await read($, jarCents), b.pick)
169 const failed = await sendToSlack($, text)
170 $.ui.toast(failed ? `Swear jar: Slack post failed: ${failed}` : `Posted to ${channelLabel(cfg)}: ${text}`)
171}
172
173async function skip($: EngineInterface) {
174 disarm()
175 const had = await read($, pending)
176 await update($, pending, () => null)
177 await update($, isPreviewOpen, () => false)
178 if (had?.queued) await update($, cooling, () => true)
179 return had?.queued ? had : null
180}
181
182export const register: Register = (on, options) => {
183 cfg.mode = String(options.mode ?? 'meter')
184 cfg.jarOnlyFor = String(options.jarOnlyFor ?? '').trim().toLowerCase()
185 cfg.jar = undefined
186 cfg.channel = String(options.slackChannel ?? '').trim()
187 Object.assign(cfg, parseChannel(cfg.channel))
188 cfg.onlyFor = String(options.slackOnlyFor ?? '').trim().toLowerCase()
189
190 on('session.start', async ($, e, next) => {
191 await $.command.register({ name: 'wtf', description: 'WTF meter: show the strip again and print the tally', argumentHint: '[skip]' })
192 if (await jarMode($)) {
193 const kept = Number((await $.store.get(JAR_KEY)) ?? 0)
194 await update($, jarCents, () => kept)
195 // A reload drops timers; a queued post gets its timer back.
196 const waiting = await read($, pending)
197 if (waiting?.queued && (await canPost($))) arm($, waiting.dueAt - (await $.clock.now()))
198 }
199 await showStatus($)
200 return next(e)
201 })
202
203 on('prompt.submit', async ($, e, next) => {
204 if (!isTyped(e.origin)) return next(e)
205 const s = score(e.text)
206
207 const msg: Msg = { score: s.total, hits: s.hits.length, worst: worstOf(s.hits), swaps: swappable(s.hits).length }
208 const list = await update($, msgs, l => [...l, msg].slice(-500))
209
210 if (await jarMode($)) {
211 const cents = s.total * CENTS_PER_POINT
212 if (cents) {
213 const total = await update($, jarCents, c => c + cents)
214 await $.store.set(JAR_KEY, total)
215 const full = Math.floor(total / JAR_CENTS) > Math.floor((total - cents) / JAR_CENTS)
216 $.ui.toast(`🪙 +${money(cents)} in the jar (${money(total)}).${full ? ' 🍺 Jar full: beer run!' : ''}`)
217 }
218 if (await canPost($)) await track($, cents ? e.text : null, cents, levelOf(list.map(m => m.score)).avg)
219 await showStatus($)
220 return next(e)
221 }
222
223 if (cfg.mode === 'bluey') {
224 // Only a streak the strip was showing, i.e. after a first swap, can break.
225 const prev = streak(list.slice(0, -1).map(m => m.swaps))
226 const clean = msg.swaps && prev.total && prev.now >= STREAK_TOAST ? blueyVersion(e.text) : null
227 if (clean) {
228 $.ui.toast(`Streak over at ${prev.now} clean messages. ${cap(clean.swap)}`)
229 await update($, isHidden, () => false) // a broken streak brings a hidden strip back
230 }
231 await showStatus($)
232 return next(e)
233 }
234
235 const scores = list.map(m => m.score)
236 const was = levelOf(scores.slice(0, -1)).level
237 const now = levelOf(scores).level
238 if (now !== was) {
239 $.ui.toast(`${was.name} → ${now.name}. ${now.toast}`)
240 await update($, isHidden, () => false) // a level change brings a hidden strip back
241 }
242 await showStatus($)
243 return next(e)
244 }).catch(($, e, next) => next(e)) // a counter must never block a prompt
245
246 // Stamp each of your messages that scored: a colored pill under the bubble
247 // on the desktop, a text line where only text draws. Bluey mode redraws it instead.
248 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
249 if (!isTyped(e.props.origin)) return next(e)
250 // Bluey mode draws the child-friendly version; Claude still got what was typed.
251 if (cfg.mode === 'bluey') {
252 const clean = blueyVersion(e.props.text)
253 if (!clean) return next(e)
254 if (e.surface !== 'desktop') {
255 return next({ ...e, props: { ...e.props, text: `${e.props.text}\n[bluey · ${clean.said.join(', ')} → ${clean.swap}]` } })
256 }
257 const { Box, Markdown, Text } = $.ui.resolve(e)
258 return (
259 <Box flexDirection="column" gap={1}>
260 <Markdown text={clean.markdown} />
261 <Text dimColor>you typed: {clean.said.join(', ')}</Text>
262 </Box>
263 )
264 }
265 const s = score(e.props.text)
266 if (!s.total) return next(e)
267 const isJar = await jarMode($)
268 const label = isJar ? `🪙 +${money(s.total * CENTS_PER_POINT)} · ${worstOf(s.hits)}` : `+${s.total} · ${worstOf(s.hits)}`
269
270 if (e.surface !== 'desktop') {
271 return next({ ...e, props: { ...e.props, text: `${e.props.text}\n[${isJar ? 'jar' : 'WTF'} ${label}]` } })
272 }
273 const { Box, Markdown, Text } = $.ui.resolve(e)
274 const color = isJar ? JAR_COLOR : stampColor(s.total)
275 return (
276 <Box flexDirection="column" gap={1}>
277 <Markdown text={e.props.text} />
278 <Box flexDirection="row">
279 <Box borderStyle="round" borderColor={color} paddingX={1}>
280 <Text color={color} bold>{isJar ? label : `● ${label}`}</Text>
281 </Box>
282 </Box>
283 </Box>
284 )
285 })
286
287 // The strip above the prompt: heat bars (meter), the jar (jar) or the clean streak (bluey).
288 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
289 if (e.props.hasSurvey) return next(e)
290 const t = $.ui.resolve(e)
291 const { Box, Text, Button, Link } = t
292 const hide = <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
293 const show = (label: string) => <Button key="show" plain label={`${label} ▸`} onPress={() => update($, isHidden, () => false)} />
294
295 if (await jarMode($)) {
296 const cents = await read($, jarCents)
297 const waiting = await read($, pending)
298 if (!cents && !waiting) return next(e)
299 if (await read($, isHidden)) {
300 return show(`🫙 ${money(cents)}`)
301 }
302 const fill = (cents % JAR_CENTS) / JAR_CENTS
303 const amount = <Text bold color={JAR_COLOR}>🫙 {money(cents)}</Text>
304 const left = <Text dimColor> {money(JAR_CENTS - (cents % JAR_CENTS))} to a beer run </Text>
305 // A queued post shows as a small chip; pressing it opens the draft with its controls.
306 const queued = waiting?.queued ? waiting : null
307 await read($, tick) // redraw the countdown
308 const mins = queued ? Math.max(0, Math.ceil((queued.dueAt - (await $.clock.now())) / 60000)) : 0
309 const chip = queued
310 ? <Button key="preview" plain label={`↗ queued · ${mins ? `${mins} min` : '<1 min'}`} onPress={() => update($, isPreviewOpen, o => !o)} />
311 : null
312 const card = queued && (await read($, isPreviewOpen))
313 ? (
314 <Box flexDirection="column" borderStyle="round" borderColor={JAR_COLOR} paddingX={1}>
315 <Box flexDirection="row" gap={1}>
316 <Text dimColor>Posts to</Text>
317 {cfg.id ? <Link href={channelLink(cfg.id)} label={channelLabel(cfg)} /> : <Text>{channelLabel(cfg)}</Text>}
318 <Text dimColor>as you, in {mins ? `${mins} min` : 'under a minute'}</Text>
319 </Box>
320 <Text>{queued.preview ?? 'Drafting…'}</Text>
321 <Box flexDirection="row" gap={1}>
322 <Button key="post-now" label="Post now" onPress={() => void flush($)} />
323 <Button key="skip" label="Skip" onPress={() => void skip($)} />
324 </Box>
325 </Box>
326 )
327 : null
328
329 if (e.surface === 'terminal' || !('Svg' in t)) {
330 const cells = 12
331 const full = Math.round(fill * cells)
332 return (
333 <Box flexDirection="column">
334 <Box flexDirection="row">
335 {amount}
336 <Text> </Text>
337 <Text color={JAR_COLOR}>{'█'.repeat(full)}</Text>
338 <Text dimColor>{'░'.repeat(cells - full)}</Text>
339 {left}
340 {chip}
341 {hide}
342 </Box>
343 {card}
344 </Box>
345 )
346 }
347 const { Svg } = t
348 const W = 200, H = 10
349 const bar = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"><rect width="${W}" height="${H}" rx="5" fill="#8a8f94" fill-opacity="0.25"/><rect width="${fill ? Math.max(6, W * fill) : 0}" height="${H}" rx="5" fill="${JAR_COLOR}"/></svg>`
350 return (
351 <Box flexDirection="column" gap={1}>
352 <Box flexDirection="row" alignItems="center" gap={1}>
353 {amount}
354 <Svg source={bar} alt={`Swear jar ${Math.round(fill * 100)}% full`} width={W} height={H} />
355 {left}
356 {chip}
357 {hide}
358 </Box>
359 {card}
360 </Box>
361 )
362 }
363
364 const list = await read($, msgs)
365 if (cfg.mode === 'bluey') {
366 const k = streak(list.map(m => m.swaps))
367 if (!k.total) return next(e)
368 const head = `🍪 Clean for ${k.now}`
369 if (await read($, isHidden)) {
370 return show(head)
371 }
372 // the terminal keeps 68 cells for the head, the tail and Hide
373 const dots = e.surface === 'terminal' ? Math.max(4, Math.min(12, (e.props.bodyColumns ?? 80) - 68)) : 12
374 return (
375 <Box flexDirection="row" alignItems="center" gap={1}>
376 <Text bold color={BLUEY_COLOR}>{head} message{k.now === 1 ? '' : 's'}</Text>
377 <Box flexDirection="row">
378 {list.slice(-dots).map(m => <Text color={m.swaps ? SWAP_COLOR : BLUEY_COLOR}>●</Text>)}
379 </Box>
380 <Text dimColor>best {k.best} · {k.total} swap{k.total === 1 ? '' : 's'} this session</Text>
381 {hide}
382 </Box>
383 )
384 }
385 if (!list.length) return next(e)
386 const scores = list.map(m => m.score)
387 const { level, avg } = levelOf(scores)
388
389 // Hidden collapses to one chip that opens the strip again.
390 if (await read($, isHidden)) {
391 return show(`${level.dot} ${level.name}`)
392 }
393 const before = levelOf(scores.slice(0, -1)).avg
394 const trend = avg > before ? 'rising' : avg < before ? 'cooling' : 'steady'
395 // Color each bar by the level the session was at when that message landed.
396 const bars = scores.map((sc, i) => ({ sc, color: sc ? levelOf(scores.slice(0, i + 1)).level.color : null }))
397 const label = <Text bold color={level.color}>{level.name}</Text>
398 const tail = <Text dimColor> avg {avg.toFixed(2)} · {trend} </Text>
399
400 if (e.surface === 'terminal' || !('Svg' in t)) {
401 const room = Math.max(4, (e.props.bodyColumns ?? 80) - 40)
402 const BLOCKS = '▁▂▃▄▅▆▇█'
403 return (
404 <Box flexDirection="row">
405 {label}
406 <Text> </Text>
407 {bars.slice(-room).map(b => (
408 <Text color={b.color ?? undefined} dimColor={!b.color}>
409 {BLOCKS[Math.min(7, b.sc)]}
410 </Text>
411 ))}
412 {tail}
413 {hide}
414 </Box>
415 )
416 }
417
418 const { Svg } = t
419 const shown = bars.slice(-60)
420 const W = 360, H = 28, gap = 2
421 const bw = Math.max(3, Math.min(14, (W - gap * shown.length) / shown.length))
422 const max = Math.max(6, ...scores)
423 const rects = shown
424 .map((b, i) => {
425 const h = b.sc ? Math.max(4, (b.sc / max) * H) : 2
426 return `<rect x="${i * (bw + gap)}" y="${H - h}" width="${bw}" height="${h}" rx="1.5" fill="${b.color ?? '#8a8f94'}" fill-opacity="${b.color ? 1 : 0.4}"/>`
427 })
428 .join('')
429 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${rects}</svg>`
430
431 return (
432 <Box flexDirection="row" alignItems="center" gap={1}>
433 {label}
434 <Svg source={svg} alt={`Swear score per message, now ${level.name}`} width={W} height={H} />
435 {tail}
436 {hide}
437 </Box>
438 )
439 })
440
441 // `/wtf` brings the strip back and prints the tally; `/wtf skip` cancels a waiting Slack post.
442 on('command.run', { command: 'wtf' }, async ($, e) => {
443 if (e.args.trim() === 'skip') {
444 const had = await skip($)
445 return { text: had ? `Swear jar: skipped the Slack post for ${money(had.cents)}. The coins stay in your jar.` : 'Swear jar: nothing waiting to post.' }
446 }
447 await update($, isHidden, () => false)
448 if (await jarMode($)) return { text: `Swear jar: ${jarStatus(await read($, jarCents))}${(await canPost($)) ? `. Posting to ${channelLabel(cfg)}.` : cfg.channel ? `. Not posting to ${channelLabel(cfg)} from this account.` : '. Slack posting is off: no channel set.'}` }
449 const list = await read($, msgs)
450 if (cfg.mode === 'bluey') {
451 const k = streak(list.map(m => m.swaps))
452 return { text: k.total ? `Bluey mode: ${blueyStatus(k)}. Best streak ${k.best}.` : 'Bluey mode: nothing swapped yet.' }
453 }
454 if (!list.length) return { text: 'WTF meter: nothing counted yet.' }
455 const words = list.map(m => m.worst).filter(Boolean).join(', ')
456 return { text: `WTF meter: ${meterStatus(list)}${words ? `. Worst words: ${words}` : ''}` }
457 })
458}
459hooks/jar.ts 71 lines1// Swear jar: coins per swear, and the one line it may post to Slack.
2//
3// Privacy by construction: a post is built only from an amount and one topic
4// from TOPICS. The model only picks a topic; any reply
5// that is not exactly one of them becomes "something". Nothing the person
6// typed can reach Slack, whatever the model answers.
7
8export const CENTS_PER_POINT = 25
9export const JAR_CENTS = 2000
10
11export const TOPICS = [
12 'a flaky test', 'a failing build', 'CI', 'a deploy', 'a release', 'a merge conflict',
13 'a bug', 'a database', 'a migration', 'dependencies', 'the type checker', 'performance',
14 'a config file', 'an API', 'the AI itself', 'a code review', 'infrastructure', 'docs',
15] as const
16
17export const money = (cents: number) => `$${(cents / 100).toFixed(2)}`
18
19export function topicPrompt(texts: readonly string[]): string {
20 return [
21 'A developer swore at their AI coding assistant. Pick the topic below that best names what they are frustrated with, or two if both clearly apply.',
22 "Answer with the topic exactly as written, or two joined by ' and ', and nothing else. If none fits, answer: something",
23 '',
24 'Topics:',
25 ...TOPICS.map(t => `- ${t}`),
26 '',
27 'Their messages:',
28 ...texts.map(t => `<message>${t.slice(0, 600)}</message>`),
29 ].join('\n')
30}
31
32/** One or two topics, each exactly from TOPICS; anything else becomes "something". */
33export function cleanTopic(reply: string | undefined): string {
34 const picked: string[] = []
35 for (const part of (reply ?? '').split(/\s+and\s+|[,;\n]/)) {
36 const t = part.trim().replace(/^[-*"'\s]+|["'.\s]+$/g, '').toLowerCase()
37 const hit = TOPICS.find(x => x.toLowerCase() === t)
38 if (hit && !picked.includes(hit)) picked.push(hit)
39 }
40 return picked.slice(0, 2).join(' and ') || 'something'
41}
42
43const VERBS = ['fighting', 'wrestling with', 'having words with', 'losing it at', 'yelling at']
44
45/** The post, in the first person: the Slack connector posts as you. */
46export function postText(cents: number, topic: string, totalCents: number, pick = Math.random()): string {
47 const verb = VERBS[Math.floor(pick * VERBS.length) % VERBS.length]
48 let text = `🫙 +${money(cents)} in the swear jar, ${verb} ${topic}.`
49 if (Math.floor(totalCents / JAR_CENTS) > Math.floor((totalCents - cents) / JAR_CENTS)) {
50 text += ` 🍺 That fills my jar: ${money(totalCents)} in all. Beer run!`
51 }
52 return text
53}
54
55/** A Slack channel id (C…, G…) in a tool result's text, if any. */
56export const channelIdIn = (text: string) => /\b[CG][A-Z0-9]{8,}\b/.exec(text)?.[0]
57
58/** `slackChannel` as written: a name, an ID, or both ("swear-jar C0FAKE0CHAN"). */
59export function parseChannel(raw: string): { name: string; id: string } {
60 const parts = raw.split(/[\s,|]+/).filter(Boolean)
61 const id = parts.find(p => /^[CG][A-Z0-9]{8,}$/.test(p)) ?? ''
62 const name = (parts.find(p => p !== id) ?? '').replace(/^#/, '')
63 return { name, id }
64}
65
66/** How a channel reads to people: its name, never the raw ID. */
67export const channelLabel = (c: { name: string }) => (c.name ? `#${c.name}` : 'the Slack channel')
68
69/** Opens the channel in Slack, in any workspace the viewer belongs to. */
70export const channelLink = (id: string) => `https://slack.com/app_redirect?channel=${id}`
71hooks/bluey.ts 61 lines1// Bluey mode: each swear above mild gets a child-friendly swap, in the language it was said in.
2// Display only: Claude still reads what was typed.
3
4import { RED, score, worstHit } from './lexicon'
5import type { Hit } from './lexicon'
6
7// Per language: [weight 2, weight 3, a whole message scoring RED or more].
8// Polish has no weight-2 words in the lexicon yet, so 'o rany' waits for one.
9const SWAPS = {
10 en: ['biscuits', 'boogerbeans', 'boogerbeans on toast'],
11 de: ['Potzblitz', 'ach du dickes Ei', 'ach du grüne Neune'],
12 pl: ['o rany', 'o kurka wodna', 'o matko i córko'],
13 uk: ['отакої', 'ой лишенько', 'матінко рідна'],
14 ru: ['ёлки-палки', 'ёшкин кот', 'батюшки мои'],
15} as const
16
17// The shared mat roots read as Russian only when Russian-only letters show up.
18const langOf = (h: Hit, text: string) => (h.lang !== 'cyr' ? h.lang : /[ыэъё]/i.test(text) ? 'ru' : 'uk')
19const swapOf = (h: Hit, text: string) => SWAPS[langOf(h, text)][h.w >= 3 ? 1 : 0]
20export const cap = (s: string) => s[0]!.toUpperCase() + s.slice(1)
21const capped = (s: string, like: string) => (like[0] !== like[0]!.toLowerCase() ? cap(s) : s)
22
23/** The hits Bluey mode swaps. Weight 1 (damn, kurde, блин) is what parents already say instead. */
24export const swappable = (hits: readonly Hit[]) => hits.filter(h => h.w >= 2)
25
26/**
27 * The child-friendly version of a message, or null when it has nothing to swap.
28 */
29export function blueyVersion(text: string): { markdown: string; said: string[]; swap: string } | null {
30 const s = score(text)
31 const hits = swappable(s.hits)
32 if (!hits.length) return null
33 // ponytail: a swear inside a code span shows its ** literally; fine for chat prose
34 let markdown = ''
35 let at = 0
36 for (const h of hits) {
37 markdown += `${text.slice(at, h.i)}**${capped(swapOf(h, text), h.word)}**`
38 at = h.end
39 }
40 markdown += text.slice(at)
41 const worst = worstHit(hits)!
42 // the big one counts mild words too, so it lands where the meter's stamp turns red
43 const swap = s.total >= RED ? SWAPS[langOf(worst, text)][2] : swapOf(worst, text)
44 return { markdown, said: hits.map(h => h.word), swap: `${swap}!` }
45}
46
47/** Clean messages since the last swap, the best run, and swaps in all. */
48export function streak(swaps: readonly (number | undefined)[]) {
49 let best = 0
50 let run = 0
51 for (const s of swaps) {
52 run = s ? 0 : run + 1
53 best = Math.max(best, run)
54 }
55 return { now: run, best, total: swaps.reduce<number>((a, b) => a + (b ?? 0), 0) }
56}
57
58/** Status text, or undefined before the first swap. */
59export const blueyStatus = (k: ReturnType<typeof streak>) =>
60 k.total ? `🍪 Clean for ${k.now} · ${k.total} swap${k.total === 1 ? '' : 's'}` : undefined
61hooks/lexicon.ts 79 lines1// Weighted swear patterns for EN, RU, UK, DE and PL. JS \b does not see Cyrillic,
2// so each pattern needs a non-letter before it (L) and, where a stem could
3// run on into an innocent word, a non-letter after it (E).
4const L = '(?<![\\p{L}\\p{N}*])'
5const E = '(?![\\p{L}])'
6
7// Russian and Ukrainian share the mat roots, so they share 'cyr'.
8type Lang = 'en' | 'de' | 'pl' | 'cyr'
9
10const LEX: readonly [number, Lang, string][] = [
11 [3, 'en', L + '\\p{L}*fuck\\p{L}*'],
12 [3, 'en', L + '\\p{L}*shit\\p{L}*'],
13 [3, 'en', L + 'cunt\\p{L}*'],
14 [2, 'en', L + '(?:wtf|stfu|fml)' + E],
15 [2, 'en', L + '(?:ass(?:hole|hat)s?|bitch\\p{L}*|bastard\\p{L}*)' + E],
16 [1, 'en', L + '(?:(?:god)?damn\\p{L}*|crap\\p{L}*|hell|ffs|omfg)' + E],
17 // censored by dictation: f***, sh*t, п***ц
18 [3, 'en', L + '(?:f\\*+(?:c?k\\p{L}*|\\**)|sh\\*+t\\p{L}*)'],
19 [3, 'cyr', L + '[пхбе]\\*+\\p{L}*'],
20 [3, 'cyr', L + '(?:на|по|ни|от|за|о|до|при|рас|с)?ху[йяеёиюї]\\p{L}*'],
21 [3, 'cyr', '\\p{L}*пизд\\p{L}*'],
22 [3, 'cyr', L + 'бля(?:д\\p{L}*|ть|ха)?' + E],
23 [3, 'cyr', L + '(?:за|у|вы|на|под|до|от|про|раз|съ|при|по|из)?[еёї]б(?:а|у|ну|ё|е[тш]|л|ис)\\p{L}*'],
24 [2, 'cyr', L + '(?:сук(?:а|и|у|ой|ин\\p{L}*)|муд(?:ак|ач|ил)\\p{L}*|(?:на|по|ни)?хер(?!сон)\\p{L}*|г[оі]вн\\p{L}*|курв\\p{L}*)' + E],
25 // DE
26 [3, 'de', L + '(?:(?:ver|ge|abge)?fick(?:en|t|te|er|st|e)?|schei(?:ß|ss|s)\\p{L}*|arschloch\\p{L}*)' + E],
27 [2, 'de', L + '(?:verdammt\\p{L}*|mist(?:stück)?|kacke)' + E],
28 // PL
29 [3, 'pl', L + '(?:kurw\\p{L}*|\\p{L}*pierdol\\p{L}*|(?:wy|za|od|roz|s)?jeb(?:a|i|ie|n|ło|ać)\\p{L}*|chuj\\p{L}*)'],
30 [1, 'pl', L + '(?:kurde|kurczę|cholera(?:\\s+jasna)?)' + E],
31 // RU/UK, mild
32 [1, 'cyr', L + '(?:блин|хр[еі]н\\p{L}*|ч[её]рт(?:и|ов|овски)?|дідьк\\p{L}*|трясця|холера|срак\\p{L}*|лайн[оа])' + E],
33]
34const RX = LEX.map(([w, lang, re]) => ({ w, lang, rx: new RegExp(re, 'giu') }))
35
36export type Hit = { i: number; end: number; word: string; w: number; lang: Lang }
37
38export const worstHit = (hits: readonly Hit[]) => [...hits].sort((a, b) => b.w - a.w)[0]
39
40export function score(text: string): { total: number; hits: Hit[] } {
41 const all: Hit[] = []
42 for (const { w, lang, rx } of RX) {
43 for (const m of text.matchAll(rx)) {
44 all.push({ i: m.index, end: m.index + m[0].length, word: m[0], w, lang })
45 }
46 }
47 all.sort((a, b) => a.i - b.i || b.w - a.w)
48 const hits: Hit[] = []
49 for (const h of all) {
50 const last = hits[hits.length - 1]
51 if (!last || h.i >= last.end) hits.push(h)
52 }
53 return { total: hits.reduce((s, h) => s + h.w, 0), hits }
54}
55
56export const LEVELS = [
57 { name: 'Calm', dot: '🔵', min: 0, color: '#5b8db0', toast: 'Cooled off. Carry on.' },
58 { name: 'Grumbling', dot: '🟡', min: 0.6, color: '#d4a72c', toast: 'Some grumbling in here.' },
59 { name: 'Heated', dot: '🟠', min: 1.5, color: '#e8743b', toast: 'Breathe.' },
60 { name: 'Meltdown', dot: '🔴', min: 2.5, color: '#e5484d', toast: 'Maybe git stash and get a coffee.' },
61] as const
62export type Level = (typeof LEVELS)[number]
63
64/** Level from the average score of the last 4 messages. */
65export function levelOf(scores: readonly number[]): { level: Level; avg: number } {
66 const win = scores.slice(-4)
67 const avg = win.length ? win.reduce((a, b) => a + b, 0) / win.length : 0
68 let level: Level = LEVELS[0]
69 for (const l of LEVELS) if (avg >= l.min) level = l
70 return { level, avg }
71}
72
73/** A message scoring this much alone gets a red stamp (and Bluey's biggest swap). */
74export const RED = 6
75
76/** Color for one message's stamp, by how bad that message alone was. */
77export const stampColor = (total: number) =>
78 total >= RED ? LEVELS[3].color : total >= 3 ? LEVELS[2].color : LEVELS[1].color
79types/index.d.ts 23 lines1/** `swaps`: hits Bluey mode swaps (weight 2 and up); missing on messages counted before Bluey mode existed. */
2export type Msg = { score: number; hits: number; worst: string | null; swaps?: number }
3/**
4 * One blow-up: the coins and messages since the person was last calm (local only).
5 * `queued` once the session reached Heated; `preview` is the exact line that will post.
6 */
7export type Pending = { cents: number; texts: string[]; queued: boolean; preview: string | null; pick: number; dueAt: number }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'wtf-meter': {
12 msgs: Msg[]
13 isHidden: boolean
14 jarCents: number
15 pending: Pending | null
16 /** After a post or skip, until the level drops below Heated: no new queue. */
17 cooling: boolean
18 isPreviewOpen: boolean
19 tick: number
20 }
21 }
22}
23