Grammar fixes for prompts you type or dictate: optional auto-fix on send (LanguageTool rules or a light Haiku fix), Haiku drafts that know your project's…

A Claude Code mod for people who write their prompts in English as a second language, often by dictation.
Claude understands broken English fine, so it never tells you about the mistakes. Grammar Guard fixes them before the prompt is sent, if you want it to, and helps you find the right word when you don't have it.
Why I built it. I'm Italian, and I dictate most of my prompts. Dictation drops words and mishears others, and I wanted to send correct English and get better at it, without leaving Claude Code. It's a personal tool that I'm sharing as it is. Issues and ideas are welcome.
Being open about this, since it's a young project:
| Part | Status |
|---|---|
/grammar-guard auto on and auto ai in the VS Code extension | Work: the corrected text is what gets sent |
/grammar-guard draft light (Haiku) in VS Code | Works |
/grammar-guard terms, project terms in drafts | Work |
draft medium | Works; may need some tuning (once turned a dictated "complete" into "Claude") |
draft complete | Works, but still needs more tuning: it can add words of its own, guessed from the conversation |
| LanguageTool idle stop and restart | Works |
| Term list deleted when a session ends | Works |
| Terminal: underlines, band fixes, Autocorrect, Undo, word tools, draft keys, auto-fix button | Work |
| Platforms | Built on Ubuntu 24.04 under WSL2. macOS and other Linux distributions are untested |
Built with Claude Code 2.1.288. It needs a version that loads mods (function hooks).
| Level | Your message | Project terms | Recent conversation |
|---|---|---|---|
light, and auto ai | yes | no | no |
| medium | yes | yes | no |
| complete | yes | yes | yes (last 8 messages) |
Light and auto ai are pure grammar fixes: they use only the words of your own message. Medium also rearranges clauses for clarity; complete rewrites freely. None of them may add requests, questions or constraints you didn't write.
Everything except Haiku runs on your machine. The apt line is the only step that needs root.
# spelling fallback and synonyms
sudo apt install hunspell hunspell-en-us wordnet
# optional: an offline dictionary from your language to English (FreeDict),
# e.g. ita (Italian), deu (German), fra (French), spa (Spanish), por (Portuguese)
sudo apt install dict-freedict-ita-eng
# LanguageTool (grammar and spelling), about 250 MB; it needs Java 17 or later
mkdir -p ~/.local/share/languagetool && cd ~/.local/share/languagetool
curl -LO https://languagetool.org/download/LanguageTool-stable.zip
unzip -q LanguageTool-stable.zip && rm LanguageTool-stable.zip
Each one is optional, and the mod says what's missing:
| Tool | Used for | Without it |
|---|---|---|
| LanguageTool | grammar and spelling checks, auto on, Autocorrect | hunspell: spelling only, no grammar |
| hunspell + en-US | fallback checker; picks unusual words for project terms | project words are picked by shape only (camelCase, snake_case) |
WordNet (wn) | synonyms | "not installed" |
FreeDict <language>-eng | your language→English, offline | translations go to Haiku |
| Python 3 | the helper, bin/gp.py (standard library only) | required |
From a Claude Code session:
/plugin marketplace add Ev3nt1ne/grammar-guard
/plugin install grammar-guard@grammar-guard
Or clone it and load it in every session through ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/grammar-guard" } }
To try it once without changing settings: claude --plugin-dir /path/to/grammar-guard.
The mod starts LanguageTool itself when a session starts (it takes about 10 seconds to come up; checks use hunspell until then). To check the pieces by hand:
P=/path/to/grammar-guard/bin/gp.py
python3 $P lt-start # {"status": "starting"} or "running"
echo "I has a apple" | python3 $P fix # engine: languagetool, "I have an apple"
python3 $P syn big # WordNet synonyms
GP_NATIVE_LANG=it python3 $P tr cane # FreeDict: dog
python3 $P vocab . # this folder's project terms
/grammar-guard auto on fix what you send with grammar rules (LanguageTool, no AI)
/grammar-guard auto ai fix what you send with Haiku's light fix (catches dictation slips)
/grammar-guard auto off
/grammar-guard auto the setting, and what happened to the last message sent
/grammar-guard draft light <text> Haiku: fix errors only, including misheard dictation
/grammar-guard draft medium <text> ...and rearrange clauses for clarity, using project terms
/grammar-guard draft complete <text> ...rewrite freely, using the chat and project terms
/grammar-guard terms the project terms medium and complete send to Haiku
A draft shows as the command's output for you to copy. Claude also reads that output, as it reads any command output.
Auto-fix details. The switch is per session: every session starts with it off, and parallel sessions keep their own. auto ai waits up to 15 s for Haiku, then uses the rules fix instead. It also falls back to the rules fix when Haiku's version looks like an answer rather than an edit (length far from the original), when it changed a code span, or when the message has a fenced code block. Auto-fix skips messages starting with / and messages over 6,000 characters (pasted logs), and never changes code spans, fenced code, URLs, paths, file names or identifier-looking words.
Tell the mod which language you translate from, with an ISO code in the env of ~/.claude/settings.json:
{ "env": { "GP_NATIVE_LANG": "it" } }
Two-letter (it, de, fr, es…) and three-letter (ita, deu…) codes both work. The translate tool then uses the FreeDict <language>-eng dictionary if you installed it, and Haiku otherwise; the band shows the pair, e.g. Translate IT→EN. Without GP_NATIVE_LANG, Haiku detects the language by itself (Translate any→EN). English is always the language you write in.
The VS Code extension gives mods no drawing surface, so this part is terminal only.
1 2 3 apply the fix shown for the first three problemsa Autocorrect: apply every spelling and grammar fix (no AI)l / m / r Light / Medium / Rewrite: Haiku's draft replaces the boxu Undo the last changew word tools for the word under the cursor (see below)x cycle auto-fix on send: off → on (rules) → ai → offw. The band switches to that word: t Translate to English (dictionary, else Haiku), s Synonyms, p Project term (Haiku matches by meaning), b Back. Then 1–9, or a click, puts a result in place of the word.A lowercase letter at the start of a sentence is not flagged (or fixed) by default: many people type prompts that way, and Claude doesn't mind. To flag it, set GP_SENTENCE_CAPS=1 in the env of ~/.claude/settings.json. A lowercase "i" is still flagged.
To stop a word being flagged as misspelled: python3 bin/gp.py ignore WORD (saved in ~/.config/grammar-guard/ignore.txt).
Project terms are often plain English phrases ("chip tree", "interview profile") rather than unusual words, so a dictionary alone can't find them. gp.py vocab looks for three things in the folder's files (git's file list, or a walk of the folder), up to 200 entries:
chipTree, chip_tree, ChipTree) that also appears in plain words in docs or comments ("chip tree"). Also a phrase the docs put in a heading, bold or backticks, or one that appears all over the code.const, async) and short lowercase abbreviations (cmd) are dropped./grammar-guard terms shows the current list. A concept that only lives in prose, without emphasis, isn't found; a concept used only in code is found only when it's frequent.
.env, *.key, *.pem, lock files and anything named secret, credential or token are skipped, and key-shaped strings (long, with digits or random-looking capitals) are removed before anything is counted.~/.cache/grammar-guard/terms-<hash of the folder>.json (readable only by you; rebuilt after 10 minutes, deleted when the session ends, and any list older than a day is deleted at the next session start), small timestamp files in the same folder, and ~/.config/grammar-guard/ignore.txt if you use ignore.python3 bin/gp.py for each check, java for the LanguageTool server, and a small watcher (gp.py lt-watch).127.0.0.1:8081 only. Its log (server.log) records the length and timing of each check, not the text.$.process.run, $.model.complete (Haiku), $.session.messages (complete drafts only), $.session.cwd, $.prompt.read / $.prompt.fill (terminal), state, commands and UI.auto on, a send waits for a LanguageTool check: about 0.3 s, or 1–2 s for the first one after a pause. With auto ai, a send waits for Haiku, up to 15 s./grammar-guard auto says so). Change the 30 minutes with the GP_LT_IDLE_MIN environment variable. The watcher finds the server through /proc, so the idle stop works on Linux and WSL only.auto ai catches those.underline), there's no right-click menu, and the band sits above the prompt box (there's no slot below).hooks/register.tsx: the mod (command, auto-fix hook, drafts, box underlines, band with word tools)bin/gp.py: the no-AI helper, standard-library Python; every command prints JSONtypes/index.d.ts: the mod's state contracttests/grammar-guard.test.tsx: tests, run with claude plugin test .claude plugin validate .
claude plugin test .
claude --plugin-dir . # loads it for one session; reload with /reload-plugins
LanguageTool (LGPL, installed separately, not bundled), hunspell, WordNet and FreeDict do the offline work.
hooks/register.tsx 633 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AutoMode, Check, Issue, Proposal, WordResult, WordTarget } from '../types'
5
6const HAIKU = 'haiku'
7// Prompts longer than this (pasted logs, files) are sent as they are.
8const AUTOFIX_MAX = 6000
9// How long a send may wait for Haiku in `auto ai` before the rules fix is used instead.
10const AUTO_AI_TIMEOUT_MS = 15_000
11
12const check = atom({ plugin: 'grammar-guard', key: 'check' } as const, null)
13const autoMode = atom({ plugin: 'grammar-guard', key: 'autoMode' } as const, 'off')
14const busy = atom({ plugin: 'grammar-guard', key: 'busy' } as const, null)
15const note = atom({ plugin: 'grammar-guard', key: 'note' } as const, '')
16const undo = atom({ plugin: 'grammar-guard', key: 'undo' } as const, null)
17const word = atom({ plugin: 'grammar-guard', key: 'word' } as const, null)
18const wordResult = atom({ plugin: 'grammar-guard', key: 'wordResult' } as const, null)
19
20type $ = EngineInterface
21
22// --- the helper (bin/gp.py): the no-AI work --------------------------------
23
24async function helper($: $, args: string[], stdin?: string, timeoutMs = 30_000): Promise<Record<string, unknown>> {
25 let r: Awaited<ReturnType<$['process']['run']>>
26 try {
27 r = await $.process.run(['python3', `${$.plugin.root}/bin/gp.py`, ...args], {
28 stdin: stdin ?? '',
29 timeoutMs,
30 })
31 } catch (err) {
32 return { error: `helper did not run: ${String(err instanceof Error ? err.message : err)}` }
33 }
34 try {
35 return JSON.parse(r.stdout) as Record<string, unknown>
36 } catch {
37 return { error: (r.stderr || r.stdout || `exit ${r.exitCode}`).slice(0, 300) }
38 }
39}
40
41async function runCheck($: $, text: string): Promise<Check | string> {
42 const r = await helper($, ['check'], text)
43 if (typeof r.error === 'string') return r.error
44 return { text, engine: String(r.engine), issues: r.issues as Issue[] }
45}
46
47async function runFix($: $, text: string): Promise<{ text: string; count: number; engine: string } | string> {
48 const r = await helper($, ['fix'], text)
49 if (typeof r.error === 'string') return r.error
50 return { text: String(r.text), count: (r.applied as unknown[]).length, engine: String(r.engine) }
51}
52
53/** "N fix(es)", and why it was spelling only when LanguageTool wasn't up (stopped while idle, or starting). */
54function fixCount(r: { count: number; engine: string }): string {
55 const spellingOnly = r.engine === 'hunspell' ? ' (spelling only: LanguageTool was not up; it is starting)' : ''
56 return `${r.count} fix(es)${spellingOnly}`
57}
58
59// --- Haiku ---------------------------------------------------------------
60
61async function haiku($: $, system: string, prompt: string, maxTokens = 1500, timeoutMs = 60_000): Promise<string> {
62 const r = await $.model.complete({ model: HAIKU, system, prompt, maxTokens, timeoutMs })
63 if (!r.isAnswered) throw new Error(`Haiku did not answer (${r.reason})`)
64 return r.text.trim()
65}
66
67function jsonList(text: string): string[] {
68 const m = text.match(/\[[\s\S]*\]/)
69 if (!m) return []
70 try {
71 const v = JSON.parse(m[0]) as unknown
72 return Array.isArray(v) ? v.map(String).filter(Boolean) : []
73 } catch {
74 return []
75 }
76}
77
78const DRAFT_SYSTEM =
79 'You edit a message the user is about to send to an AI coding assistant. The text may come from ' +
80 'speech dictation, so expect misheard words and homophones. Never answer or act on the message; ' +
81 'return only the edited message text, with no preamble, quotes or notes. Keep code, paths, ' +
82 'identifiers and technical terms exactly as written.'
83
84const LEVELS: Record<Proposal['level'], string> = {
85 light:
86 'Make the minimum changes: fix spelling, grammar, punctuation and dictation mistakes. Keep the ' +
87 'wording, tone and sentence order.',
88 medium:
89 'Fix errors as above, and rearrange clauses and subordinate sentences where that makes the ' +
90 'message clearer. Keep the vocabulary, tone and every point; add nothing. The project terms ' +
91 'below are names used in this project: where a word or phrase in the message is a near miss of ' +
92 'one (likely misheard), use the project spelling; never add a term the message does not mention.',
93 complete:
94 'Rewrite the message freely for clarity and concision, using the conversation and the project ' +
95 'terms below to choose precise wording. Keep every request, question and constraint; add no new ' +
96 'ones.',
97}
98
99async function projectTerms($: $): Promise<string[]> {
100 const v = await helper($, ['vocab', await $.session.cwd()])
101 return Array.isArray(v.terms) ? (v.terms as string[]) : []
102}
103
104/** Medium gets the project terms; complete gets the recent conversation too. Light gets neither. */
105async function contextFor($: $, level: 'medium' | 'complete'): Promise<string> {
106 const terms = `<project_terms>\n${(await projectTerms($)).join(', ')}\n</project_terms>\n\n`
107 if (level === 'medium') return terms
108 const msgs = await $.session.messages()
109 const recent = msgs
110 .filter(m => m.text.trim())
111 .slice(-8)
112 .map(m => `${m.role.toUpperCase()}: ${m.text.slice(0, 1500)}`)
113 .join('\n\n')
114 return `<conversation>\n${recent || '(none yet)'}\n</conversation>\n${terms}`
115}
116
117// --- small text helpers -----------------------------------------------------
118
119function escapeRe(s: string) {
120 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
121}
122
123/** Replaces the first whole-word `from` in `text`, or appends `to` when absent. */
124function swapWord(text: string, from: string, to: string): string {
125 const re = new RegExp(`(^|[^\\p{L}\\p{N}_])${escapeRe(from)}(?![\\p{L}\\p{N}_])`, 'iu')
126 if (from && re.test(text)) return text.replace(re, (_m, pre: string) => pre + to)
127 return text ? `${text.replace(/\s+$/, '')} ${to}` : to
128}
129
130/** The language the translate tool works from (GP_NATIVE_LANG, e.g. "it"); empty means Haiku detects it. */
131async function nativeLang($: $): Promise<string> {
132 return ((await $.env.get('GP_NATIVE_LANG')) ?? '').trim().toLowerCase()
133}
134
135const langPair = (lang: string) => `${lang ? lang.toUpperCase() : 'any'}→EN`
136
137/** Pulls English candidates out of a FreeDict entry ("1. dog", "dog; hound"). */
138function entryWords(entry: string): string[] {
139 const lines = entry.split('\n').slice(1)
140 const out: string[] = []
141 for (const l of lines) {
142 const body = l.replace(/^\s*\d+\.\s*/, '').replace(/\(.*?\)|\[.*?\]|<.*?>/g, '').trim()
143 if (!body || /^(see|syn|note|pl\.)/i.test(body)) continue
144 for (const w of body.split(/[;,]/)) {
145 const t = w.trim()
146 if (t && t.length < 40 && !out.includes(t)) out.push(t)
147 }
148 }
149 return out.slice(0, 12)
150}
151
152// --- word tools and drafts -------------------------------------------------
153
154type WordTool = 'tr' | 'syn' | 'project'
155const WORD_TOOL_NAMES: Record<WordTool, string> = { tr: 'Translate', syn: 'Synonyms', project: 'Project term' }
156
157async function lookupWord($: $, tool: WordTool, q: string, draft: string): Promise<WordResult> {
158 if (!q) throw new Error('type a word or phrase first')
159 let res: WordResult
160 if (tool === 'tr') {
161 const r = await helper($, ['tr', q])
162 const lang = await nativeLang($)
163 const entries = Array.isArray(r.entries) ? (r.entries as string[]) : []
164 const fromDict = entries.flatMap(entryWords)
165 if (fromDict.length) {
166 res = { title: `${q} (${langPair(lang)}, dictionary)`, items: [...new Set(fromDict)], detail: entries.join('\n\n') }
167 } else {
168 const from = lang ? `from the language with ISO code "${lang}"` : 'from whatever language it is in'
169 const ai = await haiku(
170 $,
171 `Translate the user's word or phrase ${from} into English. Reply with a JSON array of up to 5 English ` +
172 'options, most natural first, nothing else.',
173 q,
174 300,
175 )
176 res = { title: `${q} (${langPair(lang)}, Haiku)`, items: jsonList(ai) }
177 }
178 } else if (tool === 'syn') {
179 const r = await helper($, ['syn', q])
180 if (typeof r.error === 'string') throw new Error(r.error)
181 const groups = (r.groups ?? {}) as Record<string, string[]>
182 res = {
183 title: `Synonyms of ${q}`,
184 items: [...new Set(Object.values(groups).flat())].slice(0, 24),
185 detail: Object.entries(groups).map(([pos, ws]) => `${pos}: ${ws.join(', ')}`).join('\n'),
186 }
187 } else {
188 const terms = await projectTerms($)
189 if (!terms.length) throw new Error('no project terms found in this folder')
190 const ai = await haiku(
191 $,
192 'You match meaning, not spelling. From the project term list, pick the terms whose meaning best ' +
193 'matches what the user describes (in English or in their own language). Reply with a JSON ' +
194 'array of up to 6 terms copied exactly from the list, best first; [] if none fit.',
195 `<project_terms>\n${terms.join(', ')}\n</project_terms>\n<looking_for>\n${q}\n</looking_for>\n<draft>\n${draft}\n</draft>`,
196 300,
197 )
198 res = { title: `Project terms for “${q}”`, items: jsonList(ai) }
199 }
200 if (!res.items.length) res.detail = res.detail ?? 'No results.'
201 return res
202}
203
204async function makeDraft($: $, level: Proposal['level'], text: string): Promise<string> {
205 if (!text.trim()) throw new Error('nothing to rewrite yet')
206 const ctx = level === 'light' ? '' : await contextFor($, level)
207 return haiku($, `${DRAFT_SYSTEM}\n\n${LEVELS[level]}`, `${ctx}<message>\n${text}\n</message>`)
208}
209
210// --- the prompt box (terminal) ----------------------------------------------
211
212// The live check's debounce; a module variable, so a reload simply drops it.
213let checkTimer: { cancel: () => void } | null = null
214// What `/grammar-guard auto` reports about the last prompt sent.
215let lastSend = 'nothing sent since the mod loaded'
216
217const LEVEL_NAMES = ['light', 'medium', 'complete'] as const
218
219function isLevel(s: string): s is Proposal['level'] {
220 return (LEVEL_NAMES as readonly string[]).includes(s)
221}
222
223/** The checked issues, moved onto `text` when it changed since the check. */
224function placeIssues(c: Check, text: string): Issue[] {
225 const delta = text.length - c.text.length
226 const out: Issue[] = []
227 let from = 0
228 for (const it of c.issues) {
229 const len = it.end - it.start
230 if (!it.word) continue
231 let at = -1
232 for (const s of [it.start, it.start + delta]) {
233 if (s >= from && text.slice(s, s + len) === it.word) {
234 at = s
235 break
236 }
237 }
238 if (at < 0) {
239 const near = text.indexOf(it.word, Math.max(from, it.start - Math.abs(delta) - 8))
240 if (near >= 0 && Math.abs(near - it.start) <= Math.abs(delta) + 8) at = near
241 }
242 if (at < 0) continue
243 out.push({ ...it, start: at, end: at + len })
244 from = at + len
245 }
246 return out
247}
248
249function decorationsFor(issues: Issue[]) {
250 return issues.map(it => ({
251 start: it.start,
252 end: it.end,
253 underline: true,
254 color: it.kind === 'style' ? 'warning' : 'error',
255 }))
256}
257
258/** Checks the box; repaints it at once when the caret is at the end, else at the next key. */
259async function checkBox($: $) {
260 const box = await $.prompt.read()
261 if (!box.text.trim()) {
262 await update($, check, () => null)
263 return
264 }
265 const c = await runCheck($, box.text)
266 if (typeof c === 'string') {
267 await update($, note, () => c)
268 return
269 }
270 await update($, check, () => c)
271 const now = await $.prompt.read()
272 if (now.text === c.text && now.cursor === now.text.length && c.issues.length) {
273 await $.prompt.fill({ text: c.text, mode: 'replace', decorations: decorationsFor(c.issues) })
274 }
275}
276
277/** Replaces the box, keeping the old text for Undo, and checks the new one. */
278async function replaceBox($: $, text: string) {
279 const box = await $.prompt.read()
280 await update($, undo, () => box.text)
281 await $.prompt.fill({ text, mode: 'replace' })
282 await checkBox($)
283}
284
285async function withBusy($: $, label: string, fn: () => Promise<void>) {
286 if (await read($, busy)) return
287 await update($, busy, () => label)
288 await update($, note, () => '')
289 try {
290 await fn()
291 } catch (err) {
292 await update($, note, () => `${label} failed: ${String(err instanceof Error ? err.message : err)}`)
293 } finally {
294 await update($, busy, () => null)
295 }
296}
297
298/** The word the caret is on or just after, as offsets into the box. */
299function wordAt(text: string, cursor: number): WordTarget | null {
300 for (const m of text.matchAll(/[\p{L}\p{N}][\p{L}\p{N}'_-]*/gu)) {
301 const start = m.index ?? 0
302 const end = start + m[0].length
303 if (cursor >= start && cursor <= end) return { word: m[0], start, end }
304 }
305 return null
306}
307
308// Per session on purpose: parallel sessions each keep their own switch, off at start.
309async function setAutoMode($: $, mode: AutoMode) {
310 await update($, autoMode, () => mode)
311}
312
313const MODE_NAMES: Record<AutoMode, string> = {
314 off: 'OFF',
315 on: 'ON (rules, no AI)',
316 ai: 'AI (Haiku light fix, rules as fallback)',
317}
318
319/** Why Haiku's version of `before` can't be sent in its place, or null when it can. */
320function rejectAiFix(before: string, after: string): string | null {
321 if (!after.trim()) return 'empty reply'
322 const ratio = after.length / before.length
323 // Short messages swing more in length from a single fix.
324 const slack = before.length < 40 ? 0.6 : 0.35
325 if (ratio < 1 - slack || ratio > 1 + slack) return 'reply length far from the message (an answer, not an edit?)'
326 for (const code of before.match(/`[^`\n]+`/g) ?? []) {
327 if (!after.includes(code)) return `code span ${code} changed`
328 }
329 return null
330}
331
332/** The `auto ai` fix: Haiku's light draft, else the rules fix. */
333async function aiFix($: $, text: string): Promise<{ text: string; how: string }> {
334 if (text.includes('```')) {
335 const r = await runFix($, text)
336 return typeof r === 'string' ? { text, how: `sent unchanged: ${r}` } : { text: r.text, how: `rules (has code block), ${fixCount(r)}` }
337 }
338 let why: string
339 try {
340 const out = (await haiku($, `${DRAFT_SYSTEM}\n\n${LEVELS.light}`, `<message>\n${text}\n</message>`, 2000, AUTO_AI_TIMEOUT_MS))
341 .replace(/^<message>\s*|\s*<\/message>$/g, '')
342 const reject = rejectAiFix(text, out)
343 if (!reject) return { text: out, how: out === text ? 'Haiku: no changes' : 'Haiku light fix applied' }
344 why = reject
345 } catch (err) {
346 why = String(err instanceof Error ? err.message : err)
347 }
348 const r = await runFix($, text)
349 if (typeof r === 'string') return { text, how: `Haiku not used (${why}); sent unchanged: ${r}` }
350 return { text: r.text, how: `Haiku not used (${why}); rules fix, ${fixCount(r)}` }
351}
352
353// --- /grammar-guard -------------------------------------------------------------
354
355const HELP = [
356 'grammar-guard commands:',
357 ' /grammar-guard auto on|ai|off fix what you send, in this session: on = grammar rules,',
358 ' ai = Haiku light fix (catches dictation slips)',
359 ' /grammar-guard auto show the setting and what the last send did',
360 ' /grammar-guard draft light|medium|complete <text> a Haiku draft of <text>',
361 ' /grammar-guard terms the project terms medium and complete drafts use',
362 'In the terminal: issues are underlined in the prompt box, and the band above it has',
363 'Autocorrect, drafts, word tools and the auto-fix switch (ctrl+x tab to reach it).',
364].join('\n')
365
366async function termsReport($: $): Promise<string> {
367 const v = await helper($, ['vocab', await $.session.cwd()])
368 if (typeof v.error === 'string') return `Project terms failed: ${v.error}`
369 const group = (name: string, key: string) => {
370 const xs = Array.isArray(v[key]) ? (v[key] as string[]) : []
371 return `${name} (${xs.length}): ${xs.join(', ') || '(none)'}`
372 }
373 return [
374 `Project terms for ${String(v.root)}, as medium and complete drafts send them to Haiku:`,
375 group('Phrases', 'phrases'),
376 group('Files', 'files'),
377 group('Words', 'words'),
378 v.dictionary ? '' : 'hunspell is missing, so words are kept by shape only (camelCase, snake_case, digits).',
379 `Stored in ${String(v.cache)} (only you can read it). Rebuilt after 10 minutes; deleted when the`,
380 'session ends, or after a day if a session ended without cleaning up.',
381 ]
382 .filter(Boolean)
383 .join('\n')
384}
385
386async function runCommand($: $, args: string): Promise<string> {
387 const [sub = '', ...rest] = args.trim().split(/\s+/)
388 if (sub === 'auto') {
389 const want = rest[0]?.toLowerCase()
390 if (want === 'on' || want === 'off' || want === 'ai') await setAutoMode($, want)
391 else if (want) return 'Usage: /grammar-guard auto on|ai|off'
392 const mode = await read($, autoMode)
393 return `Auto-fix on send is ${MODE_NAMES[mode]} in this session. Last send: ${lastSend}.`
394 }
395 if (sub === 'draft') {
396 const level = (rest[0] ?? '').toLowerCase()
397 const text = args.trim().replace(/^draft\s+\S+\s*/, '')
398 if (!isLevel(level) || !text) return 'Usage: /grammar-guard draft light|medium|complete <your text>'
399 try {
400 return await makeDraft($, level, text)
401 } catch (err) {
402 return `Draft failed: ${String(err instanceof Error ? err.message : err)}`
403 }
404 }
405 if (sub === 'terms') return termsReport($)
406 const mode = await read($, autoMode)
407 return `${HELP}\n\nAuto-fix on send is ${MODE_NAMES[mode]} in this session.`
408}
409
410export const register: Register = on => {
411 on('session.start', async ($, e, next) => {
412 await $.command.register({
413 name: 'grammar-guard',
414 description: 'Grammar guard: auto on|ai|off, draft light|medium|complete <text>',
415 argumentHint: 'auto on|ai|off · draft <level> <text>',
416 })
417 // Deletes term lists older than a day, and starts LanguageTool if it is installed and not
418 // running (checks use hunspell until it is up). The helper stops it after 30 idle minutes.
419 await helper($, ['session-start'])
420 return next(e)
421 })
422
423 // The folder's term list goes with the session. Exit gives the whole chain a short time
424 // budget, so the helper gets 2 s; a list left behind is pruned at the next session start.
425 on('session.end', async ($, e, next) => {
426 await helper($, ['vocab-drop', await $.session.cwd()], '', 2_000)
427 return next(e)
428 })
429
430 on('command.run', { command: 'grammar-guard' }, async ($, e) => ({ text: await runCommand($, e.args) }))
431
432 // E: grammar auto-fix on what the person sends, when switched on.
433 on('prompt.submit', async ($, e, next) => {
434 const kind = e.origin?.kind ?? 'none'
435 const isPersons = kind === 'composer' || kind === 'sdk' || kind === 'bridge'
436 if (!isPersons) return next(e)
437 // A sent prompt ends word mode: its word offsets belonged to the old text.
438 await update($, word, () => null)
439 const mode = await read($, autoMode)
440 if (mode === 'off') {
441 lastSend = `from ${kind}, auto-fix was off`
442 return next(e)
443 }
444 if (e.text.startsWith('/') || e.text.length > AUTOFIX_MAX || !e.text.trim()) {
445 lastSend = `from ${kind}, skipped (command, empty or over ${AUTOFIX_MAX} chars)`
446 return next(e)
447 }
448 let text = e.text
449 if (mode === 'ai') {
450 const r = await aiFix($, e.text)
451 text = r.text
452 lastSend = `from ${kind}, ${r.how}`
453 } else {
454 const r = await runFix($, e.text)
455 if (typeof r === 'string') lastSend = `from ${kind}, sent unchanged: ${r}`
456 else {
457 text = r.text
458 lastSend = `from ${kind}, ${fixCount(r)} applied`
459 }
460 }
461 await update($, check, () => null)
462 await update($, undo, () => null)
463 return next({ ...e, text })
464 })
465
466 // A: issues underlined in the prompt box itself (terminal only: no other surface raises it).
467 on('prompt.edit', async ($, e, next) => {
468 const r = await next(e)
469 checkTimer?.cancel()
470 checkTimer = $.clock.after(800, () => void checkBox($))
471 const c = await read($, check)
472 if (!c || !r.text.trim()) return r
473 return { ...r, decorations: [...(r.decorations ?? []), ...decorationsFor(placeIssues(c, r.text))] }
474 })
475
476 // C, D, E and B's entry point: the band above the prompt box.
477 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
478 if (e.props.hasSurvey) return next(e)
479 const { Box, Text, Button } = $.ui.resolve(e)
480 const c = await read($, check)
481 const mode = await read($, autoMode)
482 const running = await read($, busy)
483 const msg = await read($, note)
484 const before = await read($, undo)
485 const issues = c ? c.issues : []
486
487 const fixIssue = (it: Issue, to: string) =>
488 withBusy($, 'Fix', async () => {
489 const box = await $.prompt.read()
490 const cur = c ? placeIssues({ ...c, issues: [it] }, box.text)[0] : undefined
491 if (!cur) throw new Error('the text changed; wait for the next check')
492 await replaceBox($, box.text.slice(0, cur.start) + to + box.text.slice(cur.end))
493 })
494
495 const autocorrect = () =>
496 withBusy($, 'Autocorrect', async () => {
497 const box = await $.prompt.read()
498 if (!box.text.trim()) throw new Error('the prompt box is empty')
499 const r = await runFix($, box.text)
500 if (typeof r === 'string') throw new Error(r)
501 if (!r.count) {
502 await update($, note, () => 'Autocorrect: nothing to fix.')
503 return
504 }
505 await replaceBox($, r.text)
506 await update($, note, () => `Autocorrect: ${r.count} change(s) · u undoes`)
507 })
508
509 const draft = (level: Proposal['level']) =>
510 withBusy($, `Draft (${level})`, async () => {
511 const box = await $.prompt.read()
512 await replaceBox($, await makeDraft($, level, box.text))
513 await update($, note, () => `Draft (${level}) is in the box · u undoes`)
514 })
515
516 // The word tools live in the band, not a pane: a pane only gets the keyboard over an
517 // empty prompt box, and here the box always holds text, so its keys went into the box.
518 const openWord = () =>
519 withBusy($, 'Word', async () => {
520 const box = await $.prompt.read()
521 const target = wordAt(box.text, box.cursor)
522 if (!target) throw new Error('put the cursor on a word first')
523 await update($, word, () => target)
524 await update($, wordResult, () => null)
525 })
526
527 const closeWord = async () => {
528 await update($, word, () => null)
529 await update($, wordResult, () => null)
530 }
531
532 const restore = () =>
533 withBusy($, 'Undo', async () => {
534 if (before === null) return
535 await $.prompt.fill({ text: before, mode: 'replace' })
536 await update($, undo, () => null)
537 await checkBox($)
538 })
539
540 // B: the word tools, for the word under the cursor, in place of the band's usual rows.
541 const target = await read($, word)
542 if (target) {
543 const res = await read($, wordResult)
544 const lang = await nativeLang($)
545
546 const lookup = (tool: WordTool) =>
547 withBusy($, WORD_TOOL_NAMES[tool], async () => {
548 const box = await $.prompt.read()
549 await update($, wordResult, () => null)
550 const r = await lookupWord($, tool, target.word, box.text)
551 await update($, wordResult, () => r)
552 })
553
554 const pick = (choice: string) =>
555 withBusy($, 'Replace', async () => {
556 const box = await $.prompt.read()
557 const isSame = box.text.slice(target.start, target.end) === target.word
558 const text = isSame
559 ? box.text.slice(0, target.start) + choice + box.text.slice(target.end)
560 : swapWord(box.text, target.word, choice)
561 await closeWord()
562 await replaceBox($, text)
563 })
564
565 return (
566 <Box flexDirection="column">
567 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
568 <Text bold>{target.word}</Text>
569 <Button key="tr" label={`Translate ${langPair(lang)}`} hotkey="t" onPress={() => void lookup('tr')} />
570 <Button key="syn" label="Synonyms" hotkey="s" onPress={() => void lookup('syn')} />
571 <Button key="project" label="Project term" hotkey="p" onPress={() => void lookup('project')} />
572 <Button key="back" label="Back" hotkey="b" dimColor onPress={() => void closeWord()} />
573 {running || msg ? <Text dimColor>{running ? `${running}…` : msg}</Text> : null}
574 </Box>
575 {res ? (
576 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
577 <Text dimColor>{res.items.length ? `${res.title}:` : `${res.title}: ${res.detail ?? 'no results'}`}</Text>
578 {res.items.map((it, i) => (
579 <Button
580 key={`pick-${i}`}
581 label={it}
582 hotkey={i < 9 ? String(i + 1) : undefined}
583 onPress={() => void pick(it)}
584 />
585 ))}
586 </Box>
587 ) : null}
588 </Box>
589 )
590 }
591
592 return (
593 <Box flexDirection="column">
594 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
595 {issues.length > 0 ? (
596 <Text color="error">{issues.length} issue(s):</Text>
597 ) : (
598 <Text dimColor>{c ? `no issues · ${c.engine}` : 'grammar-guard'}</Text>
599 )}
600 {issues.slice(0, 3).map((it, i) =>
601 it.replacements[0] !== undefined ? (
602 <Button
603 key={`fix-${i}`}
604 label={`${it.word}→${it.replacements[0] || '(remove)'}`}
605 hotkey={String(i + 1)}
606 onPress={() => void fixIssue(it, it.replacements[0] ?? '')}
607 />
608 ) : (
609 <Text dimColor>{it.word}?</Text>
610 ),
611 )}
612 </Box>
613 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
614 <Button key="autocorrect" label="Autocorrect" hotkey="a" onPress={() => void autocorrect()} />
615 <Button key="light" label="Light" hotkey="l" onPress={() => void draft('light')} />
616 <Button key="medium" label="Medium" hotkey="m" onPress={() => void draft('medium')} />
617 <Button key="complete" label="Rewrite" hotkey="r" onPress={() => void draft('complete')} />
618 <Button key="word" label="Word" hotkey="w" onPress={() => void openWord()} />
619 {before !== null ? <Button key="undo" label="Undo" hotkey="u" onPress={() => void restore()} /> : null}
620 <Button
621 key="autofix"
622 label={`Auto-fix: ${mode}`}
623 hotkey="x"
624 dimColor={mode === 'off'}
625 onPress={() => void setAutoMode($, mode === 'off' ? 'on' : mode === 'on' ? 'ai' : 'off')}
626 />
627 {running || msg ? <Text dimColor>{running ? `${running}…` : msg}</Text> : null}
628 </Box>
629 </Box>
630 )
631 })
632}
633types/index.d.ts 39 lines1export type Issue = {
2 start: number
3 end: number
4 word: string
5 message: string
6 replacements: string[]
7 kind: 'spelling' | 'grammar' | 'style'
8 auto: boolean
9}
10
11export type Check = { text: string; engine: string; issues: Issue[] }
12
13export type WordTarget = { word: string; start: number; end: number }
14
15export type WordResult = {
16 title: string
17 items: string[]
18 detail?: string
19}
20
21/** The auto-fix switch: off, grammar rules, or Haiku's light fix. */
22export type AutoMode = 'off' | 'on' | 'ai'
23
24export type Proposal = { level: 'light' | 'medium' | 'complete'; text: string }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'grammar-guard': {
29 check: Check | null
30 autoMode: AutoMode
31 busy: string | null
32 note: string
33 undo: string | null
34 word: WordTarget | null
35 wordResult: WordResult | null
36 }
37 }
38}
39