Blocks a prompt with a typo or a grammar error until you fix it

A Claude Code mod for people who want their English to get better while they work.
When you send a prompt with a typo or a grammar error, Sgt. Apostrophe stops it before Claude sees it. The officer lists each error with its fix, puts your text back in the prompt box with the errors underlined in red, and waits for you to fix it.
<img src="docs/moods/arrest.svg" width="128" alt="Sgt. Apostrophe, angry, blowing a whistle"> <img src="docs/moods/shocked.svg" width="128" alt="Sgt. Apostrophe, shocked"> <img src="docs/moods/happy.svg" width="128" alt="Sgt. Apostrophe, happy"> <img src="docs/moods/sleepy.svg" width="128" alt="Sgt. Apostrophe, asleep with a donut"> <img src="docs/moods/medal.svg" width="128" alt="Sgt. Apostrophe with a medal">
| Mood | When |
|---|---|
| Arrest | Your prompt has 1 to 3 errors |
| Shocked | Your prompt has 4 errors or more |
| Happy | Your fixed prompt is clean, or the patrol comes back on duty |
| Sleepy | You sent the patrol off duty |
| Medal | Every 10 clean prompts in a row |
The officer has lines for each mood, and different lines when you are a repeat offender.
From the marketplace in this repository:
claude plugin marketplace add vutran-tvg/grammar-police
claude plugin install grammar-police@vutran-tvg
Or from a local clone, for one session:
claude --plugin-dir /path/to/grammar-police
To load a local clone in the Claude desktop app, add its path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
When a prompt is stopped, the band above the prompt box shows the charges and these buttons:
| Button | Key | What it does |
|---|---|---|
| Not a mistake | 1, 2, 3… | Adds the word to your dictionary, so it is never flagged again. When no charge is left, your prompt is sent. |
| Send anyway | S | Sends the prompt once, unchecked. If you edited the text in the box, your edit is sent. |
| Off duty | O | Turns the patrol off. |
| Dismiss | D | Closes the band. |
| Command | What it does |
|---|---|
/grammar | Turns the patrol on or off |
/grammar on, /grammar off | Turns it on, or off |
/grammar chill | The default: only clear typos and clear grammar errors |
/grammar strict | Also articles, prepositions, capital letters, and apostrophes |
/grammar stats | Your arrests and your clean streak |
/grammar ignore <word> | Adds a word to your dictionary, such as a name |
/grammar unignore <word> | Removes a word from your dictionary |
/grammar ignored | Lists your dictionary |
On or off, the patrol level, the dictionary, and your record are kept across sessions.
| Setting | Default | What it is |
|---|---|---|
model | haiku | The model that checks each prompt: an alias, or a full model id. Change it if your organization or cloud provider does not offer Haiku. |
model setting.>) are removed, and at most 2,000 characters are checked.!), and prompts from scripts (claude -p, the Agent SDK) are never checked.The Claude desktop app shows its own "A hook blocked your prompt" card and keeps a copy of the stopped prompt in the chat. A mod cannot remove either, so the mod keeps the card's text to a single 👮 and puts everything else in the band.
npm install
| Script | What it does |
|---|---|
npm run check | Type-checks the mod and the tests |
npm test | Runs the tests (claude plugin test) |
npm run validate | Validates the plugin and the marketplace manifests |
npm run moods | Draws the mood pictures in docs/moods/ from the mascot code |
npm run check needs the API types, which Claude Code writes into .claude-plugin/types/ the first time it loads the mod. On a fresh clone, load the mod once (claude --plugin-dir .) before you run it.
| Path | What it holds |
|---|---|
hooks/register.tsx | The hooks: the prompt check, the /grammar command, the band |
hooks/logic.ts | The pure parts: the reply parser, the prose filter, the underlines, the dictionary match |
hooks/mascot.ts | Sgt. Apostrophe, as 1 animated SVG per mood |
types/index.d.ts | The types of the mod's state |
tests/ | The tests, with a fake host in tests/host.ts |
hooks/register.tsx 409 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GrammarError, GrammarLevel, GrammarMood, GrammarScene } from '../types'
5import { isIgnored, marks, parseErrors, pick, proseOf } from './logic'
6import { ASCII, mascot } from './mascot'
7
8const isOn = atom({ plugin: 'grammar-police', key: 'isOn' } as const, true)
9const level = atom({ plugin: 'grammar-police', key: 'level' } as const, 'chill')
10const scene = atom({ plugin: 'grammar-police', key: 'scene' } as const, null)
11
12const NAME = 'Sgt. Apostrophe'
13
14const LINES = {
15 one: [
16 'Freeze! Hands where I can see your keyboard.',
17 'One tiny crime. I saw it. I always see it.',
18 'Ahem. Fix this one, and we never speak of it again.',
19 'Halt, citizen! Your sentence needs a sentence.',
20 ],
21 many: [
22 '{n} crimes in one prompt? Bold. Very bold.',
23 'My whistle is tired. So am I.',
24 'Step away from the Enter key. Slowly.',
25 ],
26 shocked: [
27 'This is not a prompt. This is a crime scene. 🚧',
28 'Backup! I need backup! 📻',
29 'I have to sit down for this one.',
30 ],
31 repeat: [
32 'You again?! I am starting a file on you. 📁',
33 'Strike {rap}. My donut is getting cold.',
34 'We have to stop meeting like this.',
35 ],
36 cleared: [
37 'Clean as a whistle. You may pass! ✨',
38 'Beautiful. I might cry. 🥲',
39 'Case closed. Good citizen!',
40 'Now THAT is English. Carry on.',
41 ],
42 offDuty: ['Fine. Donut break. 🍩 Call me back with /grammar.', 'Off duty. Nobody saw anything. 😴'],
43 onDuty: ['Sgt. Apostrophe, reporting for duty! 🫡', 'I am back. And I brought my whistle.'],
44 medal: ['{n} clean prompts in a row! The Medal of Grammar is yours! 🏅'],
45}
46
47const RULES: Record<GrammarLevel, string> = {
48 chill:
49 'Flag only clear spelling typos, wrong words, a missing or wrong verb form, ' +
50 'subject-verb agreement, a missing apostrophe in a contraction, and a lowercase "i" as a pronoun. ' +
51 'Do not flag capital letters at the start of a sentence, punctuation, or a missing article.',
52 strict:
53 'Flag every spelling typo, grammar error, wrong or missing article, wrong preposition, ' +
54 'missing capital letter, and missing apostrophe.',
55}
56
57const SYSTEM =
58 'You are a strict but fair English proofreader for a non-native speaker who wants to improve. ' +
59 "You check only the writer's own English prose. " +
60 'Ignore code, shell commands, file paths, identifiers, URLs, names of products and tools, ' +
61 'names of people, projects, companies, and places, even when you do not know them, ' +
62 'text inside backticks or quotes, and pasted logs or documents. ' +
63 'Do not flag casual tone, informal words, chat style, a sentence fragment, or a missing final period. ' +
64 'Answer with JSON only, no prose and no code fence: ' +
65 '{"errors":[{"wrong":"<the smallest exact substring of the text that is wrong>",' +
66 '"right":"<the fix for that substring>","why":"<at most 8 words>"}]}. ' +
67 'Each "wrong" value must appear in the text exactly, character for character. ' +
68 'When the text has no error, answer {"errors":[]}.'
69
70// The person's own prompt, from the terminal, the desktop app, or a phone.
71const PERSON = new Set(['composer', 'bridge', 'sdk'])
72
73// How long a scene stays before it leaves by itself. An arrest stays until the prompt is clean.
74const SCENE_MS: Record<GrammarMood, number> = {
75 arrest: 0,
76 shocked: 0,
77 happy: 5000,
78 sleepy: 5000,
79 medal: 7000,
80}
81
82const BORDER: Record<GrammarMood, string> = {
83 arrest: 'red',
84 shocked: 'red',
85 happy: 'green',
86 sleepy: 'gray',
87 medal: 'yellow',
88}
89
90async function readIgnored($: EngineInterface): Promise<string[]> {
91 const value = await $.store.get('ignored')
92 return Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : []
93}
94
95async function addIgnored($: EngineInterface, word: string) {
96 const one = word.trim().toLowerCase()
97 const list = await readIgnored($)
98 if (one !== '' && !list.includes(one)) await $.store.set('ignored', [...list, one].sort())
99}
100
101
102async function showStatus($: EngineInterface) {
103 const isOnDuty = await read($, isOn)
104 $.ui.status(isOnDuty ? `👮 ${NAME}: on duty (${await read($, level)})` : undefined)
105}
106
107async function play(
108 $: EngineInterface,
109 mood: GrammarMood,
110 line: string,
111 errors: GrammarError[] = [],
112 arrest = 0,
113 ms = SCENE_MS[mood],
114 text = '',
115) {
116 const id = Date.now() * 1000 + Math.floor(Math.random() * 1000)
117 await update($, scene, () => ({ id, mood, line, errors, arrest, text }))
118 if (ms > 0) {
119 $.clock.after(ms, () => {
120 void update($, scene, now => (now?.id === id ? null : now))
121 })
122 }
123}
124
125// Send the blocked prompt with no check: the plugin's own prompt skips the check.
126// The box wins over the stored text, so an edit the person made is kept.
127async function sendAnyway($: EngineInterface, stored: string) {
128 const box = await $.prompt.read()
129 const text = box.text.trim() !== '' ? box.text : stored
130 await update($, scene, () => null)
131 await $.prompt.fill({ text: '', mode: 'replace' })
132 await $.prompt.submit({ text, asUser: true })
133}
134
135// "Not a mistake": the word goes in the dictionary. With no charge left, the prompt goes.
136async function notMistake($: EngineInterface, wrong: string) {
137 await addIgnored($, wrong)
138 const now = await read($, scene)
139 if (now === null) return
140 const left = now.errors.filter(one => one.wrong !== wrong)
141 if (left.length > 0) {
142 await update($, scene, current => (current === null ? null : { ...current, errors: left }))
143 return
144 }
145 await sendAnyway($, now.text)
146}
147
148async function setOn($: EngineInterface, value: boolean) {
149 await update($, isOn, () => value)
150 await $.store.set('isOn', value)
151 await showStatus($)
152 await play($, value ? 'happy' : 'sleepy', pick(value ? LINES.onDuty : LINES.offDuty))
153}
154
155export const register: Register = (on, options) => {
156 const model = typeof options.model === 'string' && options.model.trim() !== '' ? options.model : 'haiku'
157
158 on('session.start', async ($, e, next) => {
159 const storedOn = await $.store.get('isOn')
160 const storedLevel = await $.store.get('level')
161 if (typeof storedOn === 'boolean') await update($, isOn, () => storedOn)
162 if (storedLevel === 'chill' || storedLevel === 'strict') {
163 await update($, level, () => storedLevel)
164 }
165 await $.command.register({
166 name: 'grammar',
167 description: `${NAME}: toggle, set the patrol, or keep your own dictionary`,
168 argumentHint: '[on | off | chill | strict | stats | ignore <word> | unignore <word> | ignored]',
169 immediate: true,
170 })
171 await showStatus($)
172 return next(e)
173 })
174
175 on('command.run', { command: 'grammar' }, async ($, e) => {
176 const arg = e.args.trim().toLowerCase()
177 const [verb = '', ...rest] = arg.split(/\s+/)
178 const word = rest.join(' ')
179
180 if (verb === 'ignore' || verb === 'unignore') {
181 if (word === '') return { text: `👮 Name the word: /grammar ${verb} <word>` }
182 if (verb === 'ignore') await addIgnored($, word)
183 else await $.store.set('ignored', (await readIgnored($)).filter(one => one !== word))
184 return {
185 text:
186 verb === 'ignore'
187 ? `👮 "${word}" is in your dictionary. ${NAME} will not flag it again.`
188 : `👮 "${word}" left your dictionary.`,
189 }
190 }
191
192 if (verb === 'ignored') {
193 const list = await readIgnored($)
194 return {
195 text:
196 list.length === 0
197 ? '👮 Your dictionary is empty.'
198 : `👮 Your dictionary (${list.length}): ${list.join(', ')}`,
199 }
200 }
201
202 if (arg === 'stats') {
203 const arrests = Number((await $.store.get('arrests')) ?? 0)
204 const streak = Number((await $.store.get('streak')) ?? 0)
205 const best = Number((await $.store.get('best')) ?? 0)
206 return {
207 text: `👮 Your file: ${arrests} arrests. Clean streak: ${streak} prompts (best ${best}).`,
208 }
209 }
210
211 if (arg === 'chill' || arg === 'strict') {
212 await update($, level, () => arg)
213 await $.store.set('level', arg)
214 await update($, isOn, () => true)
215 await $.store.set('isOn', true)
216 await showStatus($)
217 await play(
218 $,
219 arg === 'strict' ? 'arrest' : 'happy',
220 arg === 'strict'
221 ? 'Strict patrol. Every typo, article, capital letter, and apostrophe. 🧐'
222 : 'Chill patrol. Only the clear crimes. 😎',
223 [],
224 0,
225 5000,
226 )
227 return { text: `👮 ${NAME}: ${arg} patrol.` }
228 }
229
230 const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isOn))
231 await setOn($, value)
232 return { text: value ? `👮 ${NAME} is on duty.` : `🍩 ${NAME} is off duty.` }
233 })
234
235 on('prompt.submit', async ($, e, next) => {
236 const text = e.text.trim()
237 const isPerson = PERSON.has(e.origin.kind)
238 const isCommand = text.startsWith('/') || text.startsWith('!')
239 if (!isPerson || isCommand || !(await read($, isOn))) return next(e)
240
241 // No surface means no person to fix the prompt: a `claude -p` script or an SDK tool.
242 if ((await $.session.surfaces()).length === 0) return next(e)
243
244 const prose = proseOf(e.text)
245 if (prose === '') return next(e)
246
247 $.ui.status(`🔍 ${NAME} inspects your prompt…`)
248 const ignored = await readIgnored($)
249 const dictionary =
250 ignored.length > 0
251 ? `\n\nNever flag these words or phrases. They are correct for this writer: ${ignored.join(', ')}.`
252 : ''
253 let found: GrammarError[] | null = null
254 try {
255 const reply = await $.model.complete({
256 model,
257 system: SYSTEM,
258 prompt: `${RULES[await read($, level)]}${dictionary}\n\nThe text:\n<<<\n${prose}\n>>>`,
259 maxTokens: 1200,
260 timeoutMs: 10000,
261 })
262 found = reply.isAnswered ? parseErrors(reply.text, e.text) : null
263 } catch {
264 // The engine refused the request (a blocked model, for one): the prompt passes.
265 found = null
266 } finally {
267 await showStatus($)
268 }
269
270 const errors = found === null ? null : found.filter(one => !isIgnored(one, ignored))
271 if (errors === null) {
272 $.ui.toast(`☕ ${NAME} is on a coffee break. You may pass.`)
273 return next(e)
274 }
275
276 if (errors.length === 0) {
277 const now = await read($, scene)
278 const wasBlocked = now !== null && now.errors.length > 0
279 const streak = Number((await $.store.get('streak')) ?? 0) + 1
280 const best = Math.max(streak, Number((await $.store.get('best')) ?? 0))
281 await $.store.set('streak', streak)
282 await $.store.set('best', best)
283 await $.store.set('rap', 0)
284 if (streak % 10 === 0) await play($, 'medal', pick(LINES.medal, { n: streak }))
285 else if (wasBlocked) await play($, 'happy', pick(LINES.cleared))
286 return next(e)
287 }
288
289 const arrest = Number((await $.store.get('arrests')) ?? 0) + 1
290 const rap = Number((await $.store.get('rap')) ?? 0) + 1
291 await $.store.set('arrests', arrest)
292 await $.store.set('rap', rap)
293 await $.store.set('streak', 0)
294
295 const n = errors.length
296 const mood: GrammarMood = n >= 4 ? 'shocked' : 'arrest'
297 const line =
298 rap >= 3
299 ? pick(LINES.repeat, { rap })
300 : n >= 4
301 ? pick(LINES.shocked)
302 : n >= 2
303 ? pick(LINES.many, { n })
304 : pick(LINES.one)
305 await play($, mood, line, errors, arrest, SCENE_MS[mood], e.text)
306
307 // Give the prompt back, with each crime underlined in red.
308 $.clock.after(120, () => {
309 void $.prompt.fill({ text: e.text, mode: 'replace', decorations: marks(e.text, errors) })
310 })
311
312 // The app always shows its own card for a block; the band below holds the rest.
313 return { drop: '👮' }
314 })
315
316 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
317 const now = await read($, scene)
318 if (e.props.hasSurvey || now === null) return next(e)
319
320 const isCrime = now.errors.length > 0
321 const charges = now.errors.map(one => ({ ...one }))
322 const close = () => update($, scene, () => null)
323
324 if (e.surface === 'desktop') {
325 const { Box, Button, Svg, Text } = $.ui.resolve(e)
326 return (
327 <Box flexDirection="row" gap={2} borderStyle="round" borderColor={BORDER[now.mood]} paddingX={1}>
328 <Svg source={mascot(now.mood, now.id)} alt={`${NAME}, ${now.mood}`} width={112} height={105} isInteractive />
329 <Box flexDirection="column" flexShrink={1} gap={0}>
330 <Text dimColor>
331 👮 {NAME}
332 {isCrime ? ` · arrest #${now.arrest}` : ''}
333 </Text>
334 <Text bold color={BORDER[now.mood] === 'gray' ? undefined : BORDER[now.mood]}>
335 “{now.line}”
336 </Text>
337 {charges.map((one, i) => (
338 <Box gap={1} alignItems="center">
339 <Text color="red" strikethrough>
340 {one.wrong}
341 </Text>
342 <Text>→</Text>
343 <Text color="green" bold>
344 {one.right}
345 </Text>
346 <Text dimColor>{one.why}</Text>
347 <Button
348 key={`ignore-${i}`}
349 label="Not a mistake"
350 hotkey={i < 9 ? String(i + 1) : undefined}
351 onPress={() => notMistake($, one.wrong)}
352 />
353 </Box>
354 ))}
355 <Box gap={2}>
356 <Button key="dismiss" label={isCrime ? 'Dismiss' : 'Bye!'} hotkey="d" role="dismiss" onPress={close} />
357 {isCrime && <Button key="send" label="Send anyway" hotkey="s" onPress={() => sendAnyway($, now.text)} />}
358 {isCrime && (
359 <Button key="off" label="Off duty (I am in a rush)" hotkey="o" onPress={() => setOn($, false)} />
360 )}
361 </Box>
362 </Box>
363 </Box>
364 )
365 }
366
367 const { Box, Button, Text } = $.ui.resolve(e)
368 return (
369 <Box flexDirection="row" gap={2} borderStyle="round" borderColor={BORDER[now.mood]} paddingX={1}>
370 <Box flexDirection="column">
371 {ASCII[now.mood].map(row => (
372 <Text color={BORDER[now.mood]}>{row}</Text>
373 ))}
374 </Box>
375 <Box flexDirection="column" flexShrink={1}>
376 <Text dimColor>
377 {NAME}
378 {isCrime ? ` · arrest #${now.arrest}` : ''}
379 </Text>
380 <Text bold>“{now.line}”</Text>
381 {charges.map((one, i) => (
382 <Box gap={1}>
383 <Text color="red" strikethrough>
384 {one.wrong}
385 </Text>
386 <Text>→</Text>
387 <Text color="green" bold>
388 {one.right}
389 </Text>
390 <Text dimColor>{one.why}</Text>
391 <Button
392 key={`ignore-${i}`}
393 label="Not a mistake"
394 hotkey={i < 9 ? String(i + 1) : undefined}
395 onPress={() => notMistake($, one.wrong)}
396 />
397 </Box>
398 ))}
399 <Box gap={2}>
400 <Button key="dismiss" label={isCrime ? 'Dismiss' : 'Bye!'} hotkey="d" onPress={close} />
401 {isCrime && <Button key="send" label="Send anyway" hotkey="s" onPress={() => sendAnyway($, now.text)} />}
402 {isCrime && <Button key="off" label="Off duty" hotkey="o" onPress={() => setOn($, false)} />}
403 </Box>
404 </Box>
405 </Box>
406 )
407 })
408}
409hooks/logic.ts 61 lines1import type { GrammarError } from '../types'
2
3// The pure parts of the mod: no `$`, so the tests import them directly.
4
5// The model reads at most this much prose of a prompt.
6export const PROSE_LIMIT = 2000
7
8// The writer's own prose: no code blocks, no quoted lines, no more than PROSE_LIMIT characters.
9export function proseOf(text: string): string {
10 return text
11 .replace(/```[\s\S]*?(```|$)/g, ' ')
12 .split('\n')
13 .filter(row => !row.trimStart().startsWith('>'))
14 .join('\n')
15 .trim()
16 .slice(0, PROSE_LIMIT)
17}
18
19export function pick(list: string[], values: Record<string, number> = {}): string {
20 const line = list[Math.floor(Math.random() * list.length)] ?? ''
21 return line.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ''))
22}
23
24export function parseErrors(reply: string, text: string): GrammarError[] | null {
25 const start = reply.indexOf('{')
26 const end = reply.lastIndexOf('}')
27 if (start < 0 || end <= start) return null
28 try {
29 const parsed = JSON.parse(reply.slice(start, end + 1)) as { errors?: unknown }
30 if (!Array.isArray(parsed.errors)) return null
31 return (parsed.errors as Partial<GrammarError>[]).flatMap(one => {
32 const { wrong, right, why } = one ?? {}
33 const isValid =
34 typeof wrong === 'string' &&
35 typeof right === 'string' &&
36 wrong.length > 0 &&
37 wrong !== right &&
38 text.includes(wrong)
39 return isValid ? [{ wrong, right, why: String(why ?? '') }] : []
40 })
41 } catch {
42 return null
43 }
44}
45
46// The words the writer marked as correct, in lowercase.
47export function isIgnored(one: GrammarError, ignored: readonly string[]): boolean {
48 return ignored.includes(one.wrong.trim().toLowerCase())
49}
50
51export function marks(text: string, errors: GrammarError[]) {
52 let from = 0
53 return errors.flatMap(one => {
54 let at = text.indexOf(one.wrong, from)
55 if (at < 0) at = text.indexOf(one.wrong)
56 if (at < 0) return []
57 from = at + one.wrong.length
58 return [{ start: at, end: at + one.wrong.length, color: 'red', underline: true, bold: true }]
59 })
60}
61hooks/mascot.ts 245 lines1import type { GrammarMood } from '../types'
2
3// Sgt. Apostrophe: a round officer with a siren on the hat. 1 SVG per mood.
4
5const INK = '#3a2e1f'
6const SKIN = '#FFD166'
7const NAVY = '#2B3A67'
8const MOUTH = '#8b2e3c'
9
10function siren(isOn: boolean): string {
11 const flash = isOn
12 ? '<animate attributeName="fill" values="#ff4d4d;#4d7cff;#ff4d4d" dur="0.6s" repeatCount="indefinite"/>'
13 : ''
14 const glow = isOn
15 ? `<circle cx="80" cy="13" r="12" fill="#ff4d4d" opacity="0.35">${flash}
16 <animate attributeName="r" values="9;16;9" dur="0.6s" repeatCount="indefinite"/></circle>`
17 : ''
18 return `${glow}
19 <path d="M73 21 Q80 4 87 21 Z" fill="${isOn ? '#ff4d4d' : '#9aa0aa'}" stroke="${INK}" stroke-width="2">${flash}</path>
20 <rect x="71" y="20" width="18" height="6" rx="2" fill="#5b6070" stroke="${INK}" stroke-width="2"/>`
21}
22
23function hat(tilt: number, isSirenOn: boolean): string {
24 return `<g transform="rotate(${tilt} 80 56)">
25 <path d="M50 60 L54 30 Q80 20 106 30 L110 60 Z" fill="${NAVY}" stroke="${INK}" stroke-width="3" stroke-linejoin="round"/>
26 <rect x="49" y="52" width="62" height="9" rx="3" fill="#1b2547"/>
27 <path d="M80 33 L82.4 38.6 L88.5 39 L83.8 42.8 L85.3 48.7 L80 45.4 L74.7 48.7 L76.2 42.8 L71.5 39 L77.6 38.6 Z" fill="#FFC300" stroke="#a87b00" stroke-width="1"/>
28 <path d="M42 60 Q80 74 118 60 Q118 66 112 67 Q80 78 48 67 Q42 66 42 60 Z" fill="#141a33" stroke="${INK}" stroke-width="2"/>
29 ${siren(isSirenOn)}
30 </g>`
31}
32
33function face(mood: GrammarMood): string {
34 const blush = `<ellipse cx="57" cy="104" rx="7" ry="4" fill="#ff8fa3" opacity="0.6"/>
35 <ellipse cx="103" cy="104" rx="7" ry="4" fill="#ff8fa3" opacity="0.6"/>`
36 const smile = `<path d="M68 105 Q80 122 92 105 Z" fill="${MOUTH}" stroke="${INK}" stroke-width="2" stroke-linejoin="round"/>`
37 const happyEyes = `<path d="M59 93 Q66 82 73 93 M87 93 Q94 82 101 93" stroke="${INK}" stroke-width="4" fill="none" stroke-linecap="round"/>`
38
39 switch (mood) {
40 case 'arrest':
41 return `
42 <ellipse cx="66" cy="90" rx="7" ry="8" fill="#fff" stroke="${INK}" stroke-width="2"/>
43 <ellipse cx="94" cy="90" rx="7" ry="8" fill="#fff" stroke="${INK}" stroke-width="2"/>
44 <circle cx="67" cy="92" r="3.5" fill="${INK}"/>
45 <circle cx="93" cy="92" r="3.5" fill="${INK}"/>
46 <path d="M56 76 L74 83 M104 76 L86 83" stroke="${INK}" stroke-width="4" stroke-linecap="round"/>
47 ${blush}
48 <rect x="72" y="106" width="15" height="8" rx="3" fill="#c9d1e0" stroke="${INK}" stroke-width="2"/>
49 <circle cx="90" cy="110" r="5.5" fill="#c9d1e0" stroke="${INK}" stroke-width="2"/>
50 <path d="M100 104 q4 6 0 12 M106 100 q6 10 0 20" stroke="${INK}" stroke-width="2" fill="none" stroke-linecap="round">
51 <animate attributeName="opacity" values="0;1;0" dur="0.5s" repeatCount="indefinite"/>
52 </path>`
53 case 'shocked':
54 return `
55 <circle cx="66" cy="89" r="10" fill="#fff" stroke="${INK}" stroke-width="2"/>
56 <circle cx="94" cy="89" r="10" fill="#fff" stroke="${INK}" stroke-width="2"/>
57 <circle cx="66" cy="89" r="2.5" fill="${INK}"/>
58 <circle cx="94" cy="89" r="2.5" fill="${INK}"/>
59 <path d="M56 74 Q66 68 75 74 M85 74 Q94 68 104 74" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
60 ${blush}
61 <ellipse cx="80" cy="113" rx="5" ry="7" fill="${MOUTH}" stroke="${INK}" stroke-width="2"/>`
62 case 'sleepy':
63 return `
64 <path d="M59 90 Q66 97 73 90 M87 90 Q94 97 101 90" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
65 ${blush}
66 <ellipse cx="80" cy="110" rx="3.5" ry="3" fill="${MOUTH}"/>`
67 case 'happy':
68 case 'medal':
69 return `${happyEyes}${blush}${smile}`
70 }
71}
72
73const restArm = `<path d="M36 104 Q26 112 28 121" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
74 <circle cx="28" cy="124" r="6" fill="${SKIN}" stroke="${INK}" stroke-width="2"/>`
75
76const waveArm = `<g>
77 <animateTransform attributeName="transform" type="rotate" values="-14 124 100;14 124 100;-14 124 100" dur="0.5s" repeatCount="indefinite"/>
78 <path d="M124 100 Q138 86 138 72" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
79 <circle cx="138" cy="66" r="7" fill="${SKIN}" stroke="${INK}" stroke-width="2"/>
80</g>`
81
82const saluteArm = `<path d="M124 100 Q134 78 116 66" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
83 <circle cx="113" cy="64" r="6" fill="${SKIN}" stroke="${INK}" stroke-width="2"/>`
84
85const donutArm = `<path d="M124 104 Q134 112 132 120" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
86 <circle cx="141" cy="117" r="7" fill="none" stroke="#e8a87c" stroke-width="6"/>
87 <circle cx="141" cy="117" r="7" fill="none" stroke="#ff8fb1" stroke-width="4" stroke-dasharray="30 14"/>
88 <path d="M137 112 l2 1 M144 113 l1 2 M140 121 l2 0" stroke="#4d7cff" stroke-width="1.5" stroke-linecap="round"/>
89 <circle cx="132" cy="122" r="6" fill="${SKIN}" stroke="${INK}" stroke-width="2"/>`
90
91function exclaim(): string {
92 return [
93 [16, 52, 0],
94 [30, 34, 0.3],
95 ]
96 .map(
97 ([x, y, begin]) => `<text x="${x}" y="${y}" font-family="Arial Black, Arial, sans-serif" font-weight="900" font-size="22" fill="#ff4d4d" stroke="${INK}" stroke-width="1">!
98 <animate attributeName="opacity" values="1;0.15;1" dur="0.6s" begin="${begin}s" repeatCount="indefinite"/></text>`,
99 )
100 .join('')
101}
102
103const sweat = `<path d="M112 72 q5 8 0 11 q-5 -3 0 -11 Z" fill="#7cc7ff" stroke="${INK}" stroke-width="1.5">
104 <animateTransform attributeName="transform" type="translate" values="0 0;0 14" dur="1.1s" repeatCount="indefinite"/>
105 <animate attributeName="opacity" values="1;0" dur="1.1s" repeatCount="indefinite"/>
106</path>`
107
108function sparkles(): string {
109 return [
110 [14, 70, 0],
111 [134, 40, 0.4],
112 [20, 132, 0.8],
113 [146, 100, 1.1],
114 ]
115 .map(
116 ([x, y, begin]) => `<text x="${x}" y="${y}" font-size="15" fill="#FFC300" opacity="0">✦
117 <animate attributeName="opacity" values="0;1;0" dur="1.2s" begin="${begin}s" repeatCount="indefinite"/></text>`,
118 )
119 .join('')
120}
121
122function zzz(): string {
123 return [0, 0.8, 1.6]
124 .map(
125 begin => `<text x="112" y="56" font-family="Arial, sans-serif" font-weight="700" font-size="14" fill="#8aa0c8" opacity="0">z
126 <animate attributeName="opacity" values="0;1;0" dur="2.4s" begin="${begin}s" repeatCount="indefinite"/>
127 <animateTransform attributeName="transform" type="translate" values="0 0;14 -36" dur="2.4s" begin="${begin}s" repeatCount="indefinite"/></text>`,
128 )
129 .join('')
130}
131
132const medal = `<path d="M56 110 L63 124 L70 110" stroke="#4d7cff" stroke-width="5" fill="none" stroke-linejoin="round"/>
133 <circle cx="63" cy="129" r="8" fill="#FFC300" stroke="#a87b00" stroke-width="2"/>
134 <path d="M63 124 L64.4 127.3 L68 127.6 L65.3 129.9 L66.1 133.4 L63 131.5 L59.9 133.4 L60.7 129.9 L58 127.6 L61.6 127.3 Z" fill="#fff6c2"/>`
135
136function confetti(): string {
137 const colors = ['#ff4d4d', '#4d7cff', '#FFC300', '#3ecf8e', '#ff8fb1']
138 return [12, 34, 58, 96, 118, 140, 150, 24]
139 .map((x, i) => {
140 const dur = (1.6 + (i % 3) * 0.4).toFixed(1)
141 const begin = (i * 0.25).toFixed(2)
142 return `<rect x="${x}" y="-8" width="5" height="8" rx="1" fill="${colors[i % colors.length]}">
143 <animate attributeName="y" values="-8;150" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></rect>`
144 })
145 .join('')
146}
147
148function motion(mood: GrammarMood): string {
149 switch (mood) {
150 case 'arrest':
151 case 'shocked':
152 return '<animateTransform attributeName="transform" type="translate" values="0 0;-3 0;3 0;-3 0;3 0;0 0" dur="0.5s" repeatCount="3"/>'
153 case 'happy':
154 case 'medal':
155 return '<animateTransform attributeName="transform" type="translate" values="0 0;0 -7;0 0" dur="0.45s" repeatCount="5"/>'
156 case 'sleepy':
157 return '<animateTransform attributeName="transform" type="translate" values="0 0;0 1.5;0 0" dur="3s" repeatCount="indefinite"/>'
158 }
159}
160
161// The frame of an animated SVG has a white background, so each mood fills it with a scene.
162function backdrop(mood: GrammarMood): string {
163 switch (mood) {
164 case 'arrest':
165 case 'shocked': {
166 const lines = [30, 52, 74, 96, 118]
167 .map(
168 (y, i) => `<line x1="0" y1="${y}" x2="160" y2="${y}" stroke="#9c9381" stroke-width="1.5"/>
169 <text x="4" y="${y - 3}" font-family="Arial, sans-serif" font-size="8" fill="#7d7464">${7 - i}'</text>`,
170 )
171 .join('')
172 return `<rect width="160" height="150" fill="#e9e3d3"/>${lines}
173 <rect x="0" y="136" width="160" height="14" fill="#cfc6b0"/>`
174 }
175 case 'happy':
176 return `<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
177 <stop offset="0" stop-color="#9fd8ff"/><stop offset="1" stop-color="#e6f6ff"/></linearGradient></defs>
178 <rect width="160" height="150" fill="url(#sky)"/>
179 <g fill="#fff" opacity="0.9">
180 <ellipse cx="26" cy="30" rx="16" ry="7"/><ellipse cx="36" cy="25" rx="10" ry="7"/>
181 <ellipse cx="134" cy="88" rx="16" ry="7"/><ellipse cx="142" cy="83" rx="9" ry="6"/>
182 </g>
183 <rect x="0" y="138" width="160" height="12" fill="#8fd17a"/>`
184 case 'sleepy':
185 return `<defs><linearGradient id="night" x1="0" y1="0" x2="0" y2="1">
186 <stop offset="0" stop-color="#1b2445"/><stop offset="1" stop-color="#34436f"/></linearGradient></defs>
187 <rect width="160" height="150" fill="url(#night)"/>
188 <circle cx="134" cy="26" r="11" fill="#fff3c4"/><circle cx="139" cy="22" r="10" fill="#222c50"/>
189 <g fill="#fff">
190 <circle cx="20" cy="22" r="1.4"><animate attributeName="opacity" values="1;0.2;1" dur="2s" repeatCount="indefinite"/></circle>
191 <circle cx="44" cy="12" r="1"/><circle cx="104" cy="16" r="1.2"/>
192 <circle cx="150" cy="58" r="1"><animate attributeName="opacity" values="0.2;1;0.2" dur="2.6s" repeatCount="indefinite"/></circle>
193 <circle cx="12" cy="70" r="1"/>
194 </g>
195 <rect x="0" y="138" width="160" height="12" fill="#2a3558"/>`
196 case 'medal':
197 return `<defs><linearGradient id="stage" x1="0" y1="0" x2="0" y2="1">
198 <stop offset="0" stop-color="#3b2a6b"/><stop offset="1" stop-color="#6b3fa0"/></linearGradient></defs>
199 <rect width="160" height="150" fill="url(#stage)"/>
200 <path d="M66 0 L94 0 L136 150 L24 150 Z" fill="#fff6c2" opacity="0.22">
201 <animate attributeName="opacity" values="0.14;0.3;0.14" dur="1.6s" repeatCount="indefinite"/>
202 </path>
203 <rect x="0" y="138" width="160" height="12" fill="#2b1d4f"/>`
204 }
205}
206
207export function mascot(mood: GrammarMood, id: number): string {
208 const isSirenOn = mood === 'arrest' || mood === 'shocked' || mood === 'medal'
209 const arm =
210 mood === 'sleepy' ? donutArm : mood === 'happy' || mood === 'medal' ? saluteArm : waveArm
211 const before = mood === 'medal' ? confetti() : ''
212 const after =
213 mood === 'arrest'
214 ? exclaim()
215 : mood === 'shocked'
216 ? exclaim() + sweat
217 : mood === 'sleepy'
218 ? zzz()
219 : sparkles() + (mood === 'medal' ? medal : '')
220
221 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 150" width="160" height="150" preserveAspectRatio="xMidYMid slice"><!-- scene ${id} -->
222 ${backdrop(mood)}
223 ${before}
224 <g>${motion(mood)}
225 <ellipse cx="66" cy="141" rx="10" ry="4.5" fill="${INK}"/>
226 <ellipse cx="94" cy="141" rx="10" ry="4.5" fill="${INK}"/>
227 <path d="M80 48 C114 48 126 74 126 98 C126 124 106 140 80 140 C54 140 34 124 34 98 C34 74 46 48 80 48 Z" fill="${SKIN}" stroke="${INK}" stroke-width="3"/>
228 ${restArm}
229 ${face(mood)}
230 ${hat(mood === 'sleepy' ? -14 : 0, isSirenOn)}
231 ${arm}
232 ${after}
233 </g>
234</svg>`
235}
236
237// The terminal has no SVG: a small face in text.
238export const ASCII: Record<GrammarMood, string[]> = {
239 arrest: [' _[!]_ ', ' /_____\\', ' ( >_< )', ' /| |\\ '],
240 shocked: [' _[!]_ ', ' /_____\\', ' ( O_O )', ' \\| |/ '],
241 happy: [' _[*]_ ', ' /_____\\', ' ( ^_^ )', ' /| |\\ '],
242 sleepy: [' _[ ]_', ' /_____\\', ' ( -_- ) z', ' /| |o '],
243 medal: [' _[*]_ ', ' /_____\\', ' ( ^o^ )', ' /|@|\\ '],
244}
245types/index.d.ts 26 lines1export type GrammarError = { wrong: string; right: string; why: string }
2
3export type GrammarLevel = 'chill' | 'strict'
4
5export type GrammarMood = 'arrest' | 'shocked' | 'happy' | 'sleepy' | 'medal'
6
7export type GrammarScene = {
8 id: number
9 mood: GrammarMood
10 line: string
11 errors: GrammarError[]
12 arrest: number
13 /** The blocked prompt, so that a press can send it without a check. */
14 text: string
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'grammar-police': {
20 isOn: boolean
21 level: GrammarLevel
22 scene: GrammarScene | null
23 }
24 }
25}
26