SLOPSHOPPER

Grammar Guard

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…

newbandcommandpromptmodelprocess
v0.2.0MITupdated 2026-10-04Ev3nt1ne/grammar-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grammar-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /grammar-guard ⎿ grammar-guard: grammar-guard commands: ⎿ grammar-guard: /grammar-guard auto on|ai|off fix what you send, in this session: on = grammar rules, ⎿ grammar-guard: ai = Haiku light fix (catches dictation slips) ⎿ grammar-guard: /grammar-guard auto show the setting and what the last send did ⎿ grammar-guard: /grammar-guard draft light|medium|complete <text> a Haiku draft of <text> ⎿ grammar-guard: /grammar-guard terms the project terms medium and complete drafts use grammar-guard [ Autocorrect ] [ Light ] [ Medium ] [ Rewrite ] [ Word ] [ Auto-fix: off ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
grammar-guard [ Autocorrect ] [ Light ] [ Medium ] [ Rewrite ] [ Word ] [ Auto-fix: off ]
README

Grammar Guard

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.

  • Auto-fix on send (off by default, per session): grammar rules from LanguageTool, or a light Haiku fix that also catches dictation slips like "their going" or "my names".
  • Drafts at three levels (light, medium, complete). Medium and complete know your project's terms, so "chip three" comes back as "chip tree".
  • Word tools: from your own language to English (Italian, German, Spanish, any language), synonyms, and "which project term means this?".
  • In the terminal: mistakes underlined in the prompt box, and a band of one-key fixes.

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.

Status: what has been tested

Being open about this, since it's a young project:

PartStatus
/grammar-guard auto on and auto ai in the VS Code extensionWork: the corrected text is what gets sent
/grammar-guard draft light (Haiku) in VS CodeWorks
/grammar-guard terms, project terms in draftsWork
draft mediumWorks; may need some tuning (once turned a dictated "complete" into "Claude")
draft completeWorks, but still needs more tuning: it can add words of its own, guessed from the conversation
LanguageTool idle stop and restartWorks
Term list deleted when a session endsWorks
Terminal: underlines, band fixes, Autocorrect, Undo, word tools, draft keys, auto-fix buttonWork
PlatformsBuilt 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).

What each level sends to Haiku

LevelYour messageProject termsRecent conversation
light, and auto aiyesnono
mediumyesyesno
completeyesyesyes (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.

Install

1. The local tools (Ubuntu / Debian)

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:

ToolUsed forWithout it
LanguageToolgrammar and spelling checks, auto on, Autocorrecthunspell: spelling only, no grammar
hunspell + en-USfallback checker; picks unusual words for project termsproject words are picked by shape only (camelCase, snake_case)
WordNet (wn)synonyms"not installed"
FreeDict <language>-engyour language→English, offlinetranslations go to Haiku
Python 3the helper, bin/gp.py (standard library only)required

2. The mod

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

Use

Commands (VS Code and terminal)

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

Your language

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.

In the terminal

The VS Code extension gives mods no drawing surface, so this part is terminal only.

  • Underlines in the box: after you stop typing for 0.8 s, problems are underlined: red for spelling and grammar, yellow for style.
  • The band above the box: reach it with ctrl+x tab or a click, then:
  • 1 2 3 apply the fix shown for the first three problems
  • a Autocorrect: apply every spelling and grammar fix (no AI)
  • l / m / r Light / Medium / Rewrite: Haiku's draft replaces the box
  • u Undo the last change
  • w word tools for the word under the cursor (see below)
  • x cycle auto-fix on send: off → on (rules) → ai → off
  • Word tools: move the cursor onto a word in the box, press ctrl+x tab, then w. 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.
  • Keys go to the band only while it has the keyboard. If a letter lands in the box instead, press ctrl+x tab again first.

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

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:

  • Phrases (up to 80): a concept named in the code (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.
  • Files (up to 30): the folder's name, and file names the files mention.
  • Words: words an English dictionary (hunspell en_US) doesn't know, used at least twice, those that also appear in docs or comments first. Code keywords (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.

Privacy, storage and what it can access

  • What leaves your machine: your message (and, for medium and complete, the project terms; for complete, the last 8 messages of the conversation) goes to Haiku through your own Claude Code login, the same place your conversation already goes. Nothing goes anywhere else. LanguageTool, hunspell, WordNet and FreeDict run locally.
  • Keys stay out of the term list. Files such as .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.
  • Files it writes: term lists in ~/.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.
  • Files it reads: the text files of the project folder (for project terms), and nothing else of yours.
  • Processes: python3 bin/gp.py for each check, java for the LanguageTool server, and a small watcher (gp.py lt-watch).
  • Network: LanguageTool listens on 127.0.0.1:8081 only. Its log (server.log) records the length and timing of each check, not the text.
  • Mods API calls: $.process.run, $.model.complete (Haiku), $.session.messages (complete drafts only), $.session.cwd, $.prompt.read / $.prompt.fill (terminal), state, commands and UI.

Resources

  • With auto-fix off, sending a prompt costs nothing. With 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.
  • A session start waits about 0.1 s for the helper.
  • The LanguageTool server uses about 600 MB of memory. A watcher stops it after 30 minutes without a check and then exits; the next session start or check starts both again (that one check is spelling only, and /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.

Known limits

  • LanguageTool misses dictation errors made of correctly spelled words ("my names", "their going"). auto ai catches those.
  • In VS Code there are no underlines or band: only the commands and auto-fix.
  • In the terminal, the band's keys work only after ctrl+x tab gives it the keyboard.
  • In the terminal the underline is straight, not a zigzag (the API offers only underline), there's no right-click menu, and the band sits above the prompt box (there's no slot below).

Files

  • 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 JSON
  • types/index.d.ts: the mod's state contract
  • tests/grammar-guard.test.tsx: tests, run with claude plugin test .

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .        # loads it for one session; reload with /reload-plugins

Credits

LanguageTool (LGPL, installed separately, not bundled), hunspell, WordNet and FreeDict do the offline work.

License

MIT

Source 2 files
hooks/register.tsx 633 lines
1import { 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}
633
types/index.d.ts 39 lines
1export 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