SLOPSHOPPER

wtf-meter

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

newbandrowscommandtoaststatus
★ 1v0.8.0MITupdated 2026-10-08dreamiurg/wtf-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wtf-meter
› 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 › /wtf ⎿ wtf-meter: WTF meter: 🔵 Calm · 0 WTFs Calm ▁ avg 0.00 · steady [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ wtf-meter: 🔵 Calm · 0 WTFs

Draws

Band
Calm ▁ avg 0.00 · steady [ Hide ]
README

WTF meter

CI License: MIT

GOOD SESSION vs BAD SESSION: two doors labeled CLAUDE CODE, the bad one surrounded by WTF, KURWA!, SCHEISSE, DUDE WTF, WHY DID YOU DELETE THE TESTS

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

  • Every message where you swore gets stamped: [WTF +3 · бля]
  • A strip above the prompt tracks the heat, one bar per message
  • The status line keeps score: 🔴 Meltdown · 9 WTFs ▲
  • A toast pops when the session changes level
  • /wtf prints the tally
  • Or switch to swear jar mode and let the team see who is paying in
  • Or Bluey mode: each stronger swear gets a child-friendly swap, so you can practice before the kids hear it

Works 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.

See it

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 four stages of a Claude Code session

CALM, GRUMBLING (dammit), HEATED (WTF?!), MELTDOWN (KURWA!)

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

Swear jar mode

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">

Post to a team Slack channel

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.

Bluey mode

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!].

SwearEnglishDeutschPolskiУкраїнськаРусский
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" } }
}

Install

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.

Scoring

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.

Privacy

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.

Develop

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).

Credits

  • Bluey mode's English swaps come from Bluey ("Biscuits! Boogerbeans on toast!"), by way of a friend who swears like that now. Not affiliated with the show.
  • The idea: Thom Holwerda's WTFs/minute cartoon, OSNews, 2008. The drawings here are new homages generated with an image model, not copies; the prompts are in docs/image-prompts.md.

License

MIT

Source 5 files
hooks/register.tsx 459 lines
1import { 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}
459
hooks/jar.ts 71 lines
1// 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}`
71
hooks/bluey.ts 61 lines
1// 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
61
hooks/lexicon.ts 79 lines
1// 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
79
types/index.d.ts 23 lines
1/** `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