Counts the model's verbal tics, from "You're absolutely right" to the em dash.

<img src="../../brand/pixel-icons/svg/swear-jar.svg" width="64" height="64" alt="">
A jar that counts the model's verbal tics ("You're absolutely right", "Great question", the em dash) in one dim line above the prompt and a scoreboard. It never rewrites a reply.
swear-jar: absolutely right 3 · great question 1 · em dashes 12
Swear jar
This session: 16
em dashes 12
absolutely right 3
great question 1
All time: 140
em dashes 98
absolutely right 31
i apologize 11
Worst offender: em dashes (98)
Last reply
You're absolutely right, the cache key was wrong.
Plain-words nudge off (/swear-jar nudge on|off).
/plugin install swear-jar --marketplace modmyclaude/claude-code-mods
/swear-jar opens the scoreboard: this session, all time, the worst offender, and the last reply's hits in their sentences with the tic in bold./swear-jar reset empties this session's jar; /swear-jar reset all empties it for good./swear-jar nudge on adds one short line to the system prompt asking for plain words and no praise openers; nudge off removes it. Off by default; the switch is remembered.Counting is per response: every time Claude Code receives a reply from the model in the main conversation, the reply's text is searched for the tics, whole phrases, case aside. "You're absolutely right" and "You are absolutely right" share one jar. Every em dash counts.
Every setting has a default, so the notice at install about options not yet set is expected. Change them in /config (or on the install screen).
| Setting | Default | What it does |
|---|---|---|
tics | you're absolutely right, you are absolutely right, great question, excellent question, i apologize, certainly!, absolutely!, as an ai, it's worth noting, i hope this helps, let me know if, delve, em dash | The phrases to count, whole and regardless of case. The words em dash mean the character. Your list replaces the built-in one; an empty list brings it back. |
show | band | Where the running count shows: band (the line above the prompt), status (the status line, as swear-jar 16), both, or off. The scoreboard pane works whatever you pick. |
/swear-jar answers with no command output, so the model never reads the scoreboard.This mod was built from the Swear jar prompt.
More mods: the full catalogue.
hooks/register.tsx 251 lines1// Swear-jar: a jar that counts the model's verbal tics ("You're absolutely
2// right", "Great question", the em dash) in a dim line above the prompt and a
3// /swear-jar scoreboard, with an opt-in nudge for plain words.
4//
5// It counts the text of each main-conversation response as Claude Code receives
6// it from the model, once per response, in memory. It never rewrites a reply,
7// never holds a tool call, never reads a file.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, RenderNode } from 'claude-code'
11
12import type { SwearJarCounts, SwearJarHit } from '../types'
13import { BOLT_COLUMNS, boltSprite } from './bolt-draw'
14import { NUDGE_TEXT, bandWords, countReply, countsOf, merge, rowsOf, scoreboardText, showOf, statusWords, ticsOf, total, worst } from './jar'
15import type { Show } from './jar'
16
17const PANE = 'swear-jar'
18const NUDGE_ID = 'swear-jar:plain-words'
19const ALL_TIME = 'allTime'
20const NUDGE = 'nudge'
21
22const session = atom({ plugin: 'swear-jar', key: 'session' } as const, {} as SwearJarCounts)
23const allTime = atom({ plugin: 'swear-jar', key: 'allTime' } as const, {} as SwearJarCounts)
24const last = atom({ plugin: 'swear-jar', key: 'last' } as const, [] as SwearJarHit[])
25const nudge = atom({ plugin: 'swear-jar', key: 'nudge' } as const, false)
26
27/**
28 * The button pairs a safety hold draws (Proceed with Cancel, Allow once with Deny). While a
29 * pair is beneath, the band is left to the hold alone. One button alone is not a hold: a mod
30 * that waits with Send now and Cancel keeps the jar's line.
31 */
32const HOLD_PAIRS: readonly (readonly [string, string])[] = [
33 ['Proceed', 'Cancel'],
34 ['Allow once', 'Deny'],
35]
36
37function buttonLabels(node: unknown, found: Set<string> = new Set()): Set<string> {
38 if (Array.isArray(node)) {
39 for (const child of node) buttonLabels(child, found)
40 return found
41 }
42 if (typeof node !== 'object' || node === null) return found
43 const el = node as { type?: unknown; props?: Record<string, unknown>; children?: unknown }
44 const type = typeof el.type === 'string' ? el.type : (el.type as { name?: string } | undefined)?.name
45 if (type === 'Button' && typeof el.props?.label === 'string') found.add(el.props.label)
46 buttonLabels(el.props?.children, found)
47 buttonLabels(el.children, found)
48 return found
49}
50
51function holdsBeneath(node: unknown): boolean {
52 const labels = buttonLabels(node)
53 return HOLD_PAIRS.some(([yes, no]) => labels.has(yes) && labels.has(no))
54}
55
56/** The status line's count while that view is on; cleared when the jar is empty. */
57function showStatus($: EngineInterface, show: Show, counts: SwearJarCounts): void {
58 if (show.status) $.ui.status(total(counts) > 0 ? statusWords(counts) : undefined)
59}
60
61async function safely($: EngineInterface, work: () => Promise<unknown>): Promise<void> {
62 try {
63 await work()
64 } catch (error) {
65 $.ui.log(`swear-jar: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
66 }
67}
68
69export const register: Register = (on, options) => {
70 const settings = (options ?? {}) as Record<string, unknown>
71 const tics = ticsOf(settings.tics)
72 const show = showOf(settings.show)
73
74 on('session.start', async ($, e, next) => {
75 const started = await next(e)
76 await safely($, async () => {
77 await $.command.register({
78 name: 'swear-jar',
79 description: "The jar for the model's verbal tics: scoreboard, reset, nudge on|off",
80 argumentHint: '[reset [all] | nudge on|off]',
81 immediate: true,
82 })
83 })
84 await safely($, async () => {
85 const stored = countsOf(await $.store.get(ALL_TIME))
86 await update($, allTime, () => stored)
87 const isOn = (await $.store.get(NUDGE)) === true
88 await update($, nudge, () => isOn)
89 })
90 return started
91 })
92
93 // Each main-conversation response, as the model wrote it: counted once, in memory.
94 on('turn.step', async function* ($, e, next) {
95 const response = yield* next(e)
96 if (e.agentId === undefined && response.answer !== '') {
97 await safely($, async () => {
98 const { counts, hits } = countReply(response.answer, tics)
99 if (hits.length > 0) {
100 const now = merge(await read($, session), counts)
101 await update($, session, () => now)
102 const all = merge(await read($, allTime), counts)
103 await update($, allTime, () => all)
104 await $.store.set(ALL_TIME, all)
105 await update($, last, () => hits)
106 showStatus($, show, now)
107 } else if ((await read($, last)).length > 0) {
108 await update($, last, () => [])
109 }
110 })
111 }
112 return response
113 })
114
115 // The nudge: one short session-scoped section at the end of the system prompt, only while it is on.
116 on('prompt.compose', async ($, e, next) => {
117 const composed = await next(e)
118 if (!(await read($, nudge))) return composed
119 return { ...composed, sections: [...composed.sections, { id: NUDGE_ID, text: NUDGE_TEXT, scope: 'session' as const }] }
120 }).catch(($, e, next) => next(e))
121
122 // A command's text is read by the model, so every answer here is {} and the words go to toasts, the pane or a log row.
123 on('command.run', { command: 'swear-jar' }, async ($, e) => {
124 const [verb, arg] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
125 if (verb === undefined) {
126 const opened = await $.ui.open({ id: PANE, title: 'Swear jar', closeOnEscape: true })
127 if (!opened.isPlaced) $.ui.log(scoreboardText(await read($, session), await read($, allTime), await read($, last), await read($, nudge)))
128 return {}
129 }
130 if (verb === 'reset') {
131 await update($, session, () => ({}))
132 await update($, last, () => [])
133 if (arg === 'all') {
134 await update($, allTime, () => ({}))
135 await $.store.delete(ALL_TIME)
136 }
137 showStatus($, show, {})
138 $.ui.toast(arg === 'all' ? 'Swear jar emptied: this session and all time.' : 'Swear jar emptied for this session.')
139 return {}
140 }
141 if (verb === 'nudge') {
142 if (arg === 'on' || arg === 'off') {
143 const isOn = arg === 'on'
144 await update($, nudge, () => isOn)
145 await $.store.set(NUDGE, isOn)
146 $.ui.toast(isOn ? 'Plain-words nudge is on: one short line joins every request, about 30 tokens.' : 'Plain-words nudge is off.')
147 } else {
148 $.ui.toast(`Plain-words nudge is ${(await read($, nudge)) ? 'on' : 'off'}. /swear-jar nudge on|off`)
149 }
150 return {}
151 }
152 $.ui.toast('/swear-jar opens the scoreboard; /swear-jar reset [all]; /swear-jar nudge on|off.')
153 return {}
154 }).catch($ => {
155 $.ui.toast('/swear-jar could not finish.')
156 return {}
157 })
158
159 // The band: one dim line under whatever other mods draw, nothing while the jar is empty,
160 // and nothing at all while a safety hold beneath waits for an answer.
161 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162 if (!show.band || e.props.hasSurvey) return next(e)
163 const counts = await read($, session)
164 if (total(counts) === 0) return next(e)
165 const below = await next(e).catch(() => null)
166 if (below !== null && holdsBeneath(below)) return below
167 const { Box, Text } = $.ui.resolve(e)
168 return (
169 <Box key="swear-jar-wrap" flexDirection="column">
170 {below}
171 <Box key="swear-jar">
172 <Text dimColor wrap="truncate-end">{bandWords(counts)}</Text>
173 </Box>
174 </Box>
175 )
176 })
177
178 // The scoreboard pane: this session, all time, the worst offender, the last reply's hits in their sentences.
179 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
180 const elements = $.ui.resolve(e)
181 const { Box, Text } = elements
182 const counts = await read($, session)
183 const all = await read($, allTime)
184 const hits = await read($, last)
185 const isNudge = await read($, nudge)
186 const columns = e.props.bodyColumns
187 const rows = (c: SwearJarCounts, key: string): RenderNode[] =>
188 rowsOf(c).map(r => (
189 <Box key={`${key}-${r.label}`} flexDirection="row" gap={1}>
190 <Box key="label" flexGrow={1} flexShrink={1}>
191 <Text wrap="truncate-end">{r.label}</Text>
192 </Box>
193 <Box key="count" flexShrink={0}>
194 <Text bold>{String(r.count)}</Text>
195 </Box>
196 </Box>
197 ))
198 const top = worst(all, counts)
199 return (
200 <Box key="swear-jar-pane" flexDirection="column" paddingX={1}>
201 <Box key="title" flexDirection="row" gap={1} alignItems="center">
202 {columns >= BOLT_COLUMNS.tiny + 37 ? boltSprite(elements, e.surface, { size: 'tiny', pose: 'idle' }, 'title-bolt') : null}
203 <Text bold>Swear jar</Text>
204 </Box>
205 <Box key="session-title" marginTop={1}>
206 <Text bold>{`This session: ${total(counts)}`}</Text>
207 </Box>
208 {total(counts) === 0 ? (
209 <Text key="session-none" dimColor>
210 nothing yet
211 </Text>
212 ) : (
213 rows(counts, 'session')
214 )}
215 <Box key="all-title" marginTop={1}>
216 <Text bold>{`All time: ${total(all)}`}</Text>
217 </Box>
218 {rows(all, 'all')}
219 {top ? (
220 <Box key="worst" marginTop={1}>
221 <Text>{`Worst offender: ${top.label} (${top.count})`}</Text>
222 </Box>
223 ) : null}
224 <Box key="last-title" marginTop={1}>
225 <Text bold>Last reply</Text>
226 </Box>
227 {hits.length === 0 ? (
228 <Text key="last-none" dimColor>
229 no tics in the last reply
230 </Text>
231 ) : (
232 hits.slice(0, 5).map((hit, i) => (
233 <Box key={`hit-${i}`}>
234 <Text wrap="wrap">
235 {hit.before}
236 <Text bold>{hit.tic}</Text>
237 {hit.after}
238 </Text>
239 </Box>
240 ))
241 )}
242 <Box key="foot" marginTop={1}>
243 <Text color="subtle" wrap="wrap">
244 {`Plain-words nudge ${isNudge ? 'on' : 'off'} (/swear-jar nudge on|off). /swear-jar reset empties this session; reset all empties the jar for good. Counts come from the text of each reply, in memory; nothing here reaches the model.`}
245 </Text>
246 </Box>
247 </Box>
248 )
249 })
250}
251hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/jar.ts 164 lines1// Swear-jar's pure parts: the tic list, how a reply is counted, and the words
2// the band, the status line and the pane say. Nothing here touches the engine.
3
4import type { SwearJarCounts, SwearJarHit } from '../types'
5
6/** The em dash, spelled by its code so no literal ever sits in a public file. */
7export const EM_DASH = String.fromCharCode(0x2014)
8
9/** The tics counted out of the box; the `tics` setting replaces this list. */
10export const DEFAULT_TICS: readonly string[] = [
11 "you're absolutely right",
12 'you are absolutely right',
13 'great question',
14 'excellent question',
15 'i apologize',
16 'certainly!',
17 'absolutely!',
18 'as an ai',
19 "it's worth noting",
20 'i hope this helps',
21 'let me know if',
22 'delve',
23 EM_DASH,
24]
25
26/** The one line the nudge adds to every request while it is on: about 30 tokens. */
27export const NUDGE_TEXT = 'Plain words: do not open with praise or agreement phrases, avoid the em dash, say what you did.'
28
29const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
30const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[@-Z\\-_]/g
31const CURLY = /[‘’]/g
32
33/** Text with no escape sequences or control characters and single spaces, edges kept. */
34function squash(text: string): string {
35 return text.replace(ANSI, '').replace(CONTROL, '�').replace(/\s+/g, ' ')
36}
37
38/** Text made safe to draw: squashed and trimmed. */
39export function clean(text: string): string {
40 return squash(text).trim()
41}
42
43/** A tic as the list keeps it: lower case, straight apostrophes, one line; the words "em dash" mean the character. */
44export function normalizeTic(tic: string): string {
45 const t = clean(tic).replace(CURLY, "'").toLowerCase()
46 return /^em[ -]?dash(es)?$/.test(t) ? EM_DASH : t
47}
48
49/** The list from the `tics` setting: strings, cleaned and deduplicated; the built-in list when it leaves nothing. */
50export function ticsOf(setting: unknown): string[] {
51 const given = Array.isArray(setting) ? setting.filter((t): t is string => typeof t === 'string') : []
52 const list = [...new Set(given.map(normalizeTic).filter(t => t !== ''))]
53 return list.length > 0 ? list : [...DEFAULT_TICS]
54}
55
56/** What a tic is called on the board: the em dash by name, praise openers without their "you're". */
57export function labelOf(tic: string): string {
58 if (tic === EM_DASH) return 'em dashes'
59 return tic.replace(/^you(?:'re| are) /, '').replace(/!$/, '')
60}
61
62/** The tic as a whole phrase, any case; word boundaries where the tic starts or ends with a letter or digit. */
63function patternOf(tic: string): RegExp {
64 const escaped = tic.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
65 const lead = /^[a-z0-9]/.test(tic) ? '\\b' : ''
66 const tail = /[a-z0-9]$/.test(tic) ? '\\b' : ''
67 return new RegExp(`${lead}${escaped}${tail}`, 'gi')
68}
69
70const SENTENCE_END = /[.!?\n]/
71const WINDOW = 70
72
73/** The sentence around a hit, cut to about 70 characters on each side of the tic. */
74function sentenceAround(text: string, at: number, length: number): Pick<SwearJarHit, 'before' | 'tic' | 'after'> {
75 let start = at
76 while (start > 0 && !SENTENCE_END.test(text[start - 1] ?? '')) start -= 1
77 let end = at + length
78 while (end < text.length && !SENTENCE_END.test(text[end] ?? '')) end += 1
79 if (end < text.length && text[end] !== '\n') end += 1
80 const from = Math.max(start, at - WINDOW)
81 const to = Math.min(end, at + length + WINDOW)
82 const before = (from > start ? '...' : '') + squash(text.slice(from, at)).replace(/^\s+/, '')
83 const after = squash(text.slice(at + length, to)).replace(/\s+$/, '') + (to < end ? '...' : '')
84 return { before, tic: clean(text.slice(at, at + length)), after }
85}
86
87/** Every tic in a reply: counts by label, and the first 20 hits in their sentences. */
88export function countReply(text: string, tics: readonly string[]): { counts: SwearJarCounts; hits: SwearJarHit[] } {
89 const counts: SwearJarCounts = {}
90 const hits: SwearJarHit[] = []
91 const body = text.replace(CURLY, "'")
92 for (const tic of tics) {
93 const label = labelOf(tic)
94 for (const m of body.matchAll(patternOf(tic))) {
95 counts[label] = (counts[label] ?? 0) + 1
96 if (hits.length < 20) hits.push({ label, ...sentenceAround(body, m.index ?? 0, m[0].length) })
97 }
98 }
99 return { counts, hits }
100}
101
102export function merge(a: SwearJarCounts, b: SwearJarCounts): SwearJarCounts {
103 const out: SwearJarCounts = { ...a }
104 for (const [label, n] of Object.entries(b)) out[label] = (out[label] ?? 0) + n
105 return out
106}
107
108export const total = (c: SwearJarCounts): number => Object.values(c).reduce((sum, n) => sum + n, 0)
109
110/** Rows by count, highest first; ties keep the order the list names the tics in. */
111export function rowsOf(c: SwearJarCounts): { label: string; count: number }[] {
112 return Object.entries(c)
113 .map(([label, count]) => ({ label, count }))
114 .filter(r => r.count > 0)
115 .sort((x, y) => y.count - x.count)
116}
117
118/** The band's one dim line: the top three of this session. */
119export function bandWords(c: SwearJarCounts): string {
120 return `swear-jar: ${rowsOf(c)
121 .slice(0, 3)
122 .map(r => `${r.label} ${r.count}`)
123 .join(' · ')}`
124}
125
126export const statusWords = (c: SwearJarCounts): string => `swear-jar ${total(c)}`
127
128/** The worst offender all time, or this session's while the jar is new. */
129export function worst(all: SwearJarCounts, session: SwearJarCounts): { label: string; count: number } | undefined {
130 return rowsOf(total(all) > 0 ? all : session)[0]
131}
132
133export type Show = { band: boolean; status: boolean }
134
135/** The `show` setting: band (default), status, both or off. */
136export function showOf(setting: unknown): Show {
137 const word = typeof setting === 'string' ? setting.trim().toLowerCase() : 'band'
138 if (word === 'status') return { band: false, status: true }
139 if (word === 'both') return { band: true, status: true }
140 if (word === 'off') return { band: false, status: false }
141 return { band: true, status: false }
142}
143
144/** Counts as the store holds them: an object of whole positive numbers, anything else dropped. */
145export function countsOf(stored: unknown): SwearJarCounts {
146 if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) return {}
147 const out: SwearJarCounts = {}
148 for (const [label, n] of Object.entries(stored as Record<string, unknown>)) {
149 if (typeof n === 'number' && Number.isFinite(n) && n > 0) out[clean(label)] = Math.floor(n)
150 }
151 return out
152}
153
154/** The scoreboard as plain lines, for a surface that places no pane (a transcript row, which the model does not read). */
155export function scoreboardText(session: SwearJarCounts, all: SwearJarCounts, last: readonly SwearJarHit[], isNudge: boolean): string {
156 const lines = [`Swear jar: ${total(session)} this session, ${total(all)} all time`]
157 for (const r of rowsOf(session)) lines.push(` ${r.label} ${r.count}`)
158 const w = worst(all, session)
159 if (w) lines.push(`Worst offender: ${w.label} (${w.count})`)
160 lines.push(last.length === 0 ? 'Last reply: no tics' : `Last reply: ${last.map(h => h.label).join(', ')}`)
161 lines.push(`Plain-words nudge ${isNudge ? 'on' : 'off'}`)
162 return lines.join('\n')
163}
164hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109types/index.d.ts 29 lines1// What swear-jar keeps in $.state: this session's counts, the all-time counts
2// as its store holds them, the last reply's hits, and the nudge switch.
3
4/** Counts by tic label ("absolutely right", "em dashes"). */
5export type SwearJarCounts = Record<string, number>
6
7/** One tic found in the last reply, with the words around it. */
8export type SwearJarHit = {
9 label: string
10 before: string
11 tic: string
12 after: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'swear-jar': {
18 /** This session's counts; /swear-jar reset empties it. */
19 session: SwearJarCounts
20 /** All-time counts, read from the store at start and written on each change. */
21 allTime: SwearJarCounts
22 /** The last reply's hits, each in its sentence. */
23 last: SwearJarHit[]
24 /** Whether the plain-words nudge joins every request. */
25 nudge: boolean
26 }
27 }
28}
29