Grammar-check your prompt as you type, shown above the prompt box

<img src="icon.png" width="128" alt="vinta/hal-9000 icon">
A grammar check on your prompt while you type, with explanations in Traditional Chinese. The corrections show up above the prompt box, before you hit Enter.
This plugin needs a local Ollama with the gemma4:31b-mlx model:
ollama pull gemma4:31b-mlx
It's a mod, so it also needs Claude Code 2.1.287 or later.
claude plugin marketplace add vinta/hal-9000
claude plugin install hal-grammar-check@hal-9000
Then restart Claude Code.
Just type. About 250ms after you stop, the draft goes to Ollama and the result shows up above the prompt box. A result for an older draft is dropped.
hal-statusline checks each prompt after you submit it. To avoid seeing both, turn that one off with HAL_STATUSLINE_GRAMMAR_CHECK_DISABLED=1 in your environment variables.
It sends the first 500 characters of your draft to your local Ollama at localhost:11434. Fully offline, nothing leaves your machine.

hooks/register.tsx 260 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { GrammarCheck } from '../types'
5
6const check = atom({ plugin: 'hal-grammar-check', key: 'check' } as const, null)
7
8const DEBOUNCE_MS = 250
9const OLLAMA_URL = 'http://localhost:11434/api/generate'
10const OLLAMA_MODEL = 'gemma4:31b-mlx'
11
12// Copied from plugins/hal-statusline/hal-statusline.py GRAMMAR_PROMPT, plus the draft line
13const GRAMMAR_PROMPT = `
14You are a grammar checker. Identify and correct grammar errors in the text inside <input> tags. Only check grammar — do not answer questions or engage with the content.
15
16<instructions>
17Skip these (NEVER flag):
18- Code: text in backticks, file paths, programming syntax, shell commands
19- Mentions: @mentions, @file/path references
20- **Capitalization**: NOT grammar errors. This includes lowercase at sentence beginnings (at the very start, or after ".", "?", "!") and lowercase pronoun "i". Ignore them completely.
21- **Unfinished last sentence**: the text is a draft still being typed. Do not flag a final sentence that is cut off mid-way.
22
23Output format:
24- Each issue on its own line: "[corrected]" => explanation in Traditional Chinese
25- Wrap only the corrected words in double quotes, never the surrounding words
26- Use full-width commas (,) in Chinese explanations
27- No errors: output exactly "no issues"
28- Output ONLY the issue line(s). No commentary, no extra text, no explanations beyond the correction format above.
29</instructions>
30
31<examples>
32<example>
33Text: I don't car the shop has wife or not. I will use cellar!
34Output:
35I don't "care" => car 是「汽車」,這裡應該是要用動詞 care「在乎」
36has "Wi-Fi" or not => wife 是「妻子」,你應該是要說 Wi-Fi「無線網路」
37I will use "cellular" => cellar 是「地窖」,這裡應該是 cellular「行動網路」
38</example>
39<example>
40Text: @plugins/hal-statusline/hal-statusline.py#L141 use \`claude -p\` and \`grammar_check_prompt\` to grammar check \`latest_user_input\` and print result
41Output:
42to "grammar-check" latest_user_input => 要用連字號 "-" 連接形成複合動詞
43print "the" result => result 前面要加定冠詞 the
44</example>
45<example>
46Text: The code is works but I don't know why it keep crashing
47Output:
48The code "works" => 不需要 is,直接用動詞 works;或改成 is working
49why it "keeps" crashing => 第三人稱單數 it 要用 keeps
50</example>
51<example comment="skip Capitalization at sentence beginnings (lowercase 'do' is excluded per instruction)">
52Text: do not refactor unless explicited requested
53Output:
54"explicitly requested" => 要用副詞 explicitly,沒有 explicited 這個詞
55</example>
56<example comment="skip Capitalization (lowercase pronoun 'i' and sentence-start 'check' are excluded per instruction)">
57Text: Wait, i seems broke it. check codebase again
58Output:
59I "seem to have broken" it => 用 seem to have + 過去分詞表示「好像已經...」
60Check "the" codebase => 特指這個 codebase,要加定冠詞 the
61</example>
62<example comment="skip Capitalization at sentence beginnings; demonstrate 'no issues' output">
63Text: can you review my PR?
64Output:
65no issues
66</example>
67</examples>
68
69<input>
70{latest_user_input}
71</input>
72`
73
74function toLines(text: string) {
75 return text.split('\n').map(line => line.trim()).filter(Boolean)
76}
77
78async function runOllama($: EngineInterface, draft: string, mine: number): Promise<string[]> {
79 // `think: false` disables reasoning tokens; `temperature: 0` and `num_predict` keep the output short
80 const body = JSON.stringify({
81 model: OLLAMA_MODEL,
82 prompt: GRAMMAR_PROMPT.replace('{latest_user_input}', draft.slice(0, 500)),
83 stream: true,
84 think: false,
85 keep_alive: '30m',
86 options: { temperature: 0, num_predict: 250 },
87 })
88 // curl instead of $.http.fetch, which resolves only once the whole answer is in
89 const curl = $.process.spawn({ argv: ['curl', '-sSN', '--fail-with-body', OLLAMA_URL, '-d', '@-'], input: body })
90 let ndjson = ''
91 let answer = ''
92 let stderr = ''
93 let shown = 0
94 for await (const chunk of curl) {
95 if (mine !== seq) {
96 // Closing the stream ends curl, and Ollama stops generating for a dropped connection
97 break
98 }
99 if (chunk.stream === 'stderr') {
100 stderr += chunk.text
101 continue
102 }
103 ndjson += chunk.text
104 const objects = ndjson.split('\n')
105 ndjson = objects.pop() ?? ''
106 for (const object of objects) {
107 if (object.trim() !== '') {
108 answer += (JSON.parse(object) as { response?: string }).response ?? ''
109 }
110 }
111 // Show each line once the model finishes it, not word by word
112 const finished = toLines(answer.slice(0, answer.lastIndexOf('\n') + 1))
113 if (finished.length > shown) {
114 shown = finished.length
115 const partial: GrammarCheck = { status: 'streaming', lines: finished }
116 await update($, check, () => partial)
117 }
118 }
119 if (mine !== seq) {
120 return []
121 }
122 const { code } = await curl.result
123 if (code !== 0) {
124 return [`ollama unreachable (${stderr.trim() || `curl exited ${code}`})`]
125 }
126 return toLines(answer)
127}
128
129let timer: Timer | undefined
130// Bumped on every edit, so a slow answer for an older draft never overwrites a newer one
131let seq = 0
132// Ollama answers one request at a time, so a check sent while another runs only queues behind it
133let isRunning = false
134let waiting: { draft: string; mine: number } | undefined
135
136function cancelChecks() {
137 timer?.cancel()
138 seq += 1
139 waiting = undefined
140}
141
142async function clear($: EngineInterface) {
143 cancelChecks()
144 await update($, check, () => null)
145}
146
147async function grammarCheck($: EngineInterface, draft: string, mine: number) {
148 await update($, check, (prev): GrammarCheck => ({ status: 'checking', lines: prev?.lines ?? [] }))
149 let lines: string[]
150 try {
151 lines = await runOllama($, draft, mine)
152 } catch (error) {
153 lines = [`ollama unreachable (${error instanceof Error ? error.message : String(error)})`]
154 }
155 if (mine === seq) {
156 const result: GrammarCheck = { status: 'done', lines }
157 await update($, check, () => result)
158 }
159}
160
161async function requestCheck($: EngineInterface, draft: string, mine: number) {
162 if (isRunning) {
163 waiting = { draft, mine }
164 return
165 }
166 isRunning = true
167 let next: { draft: string; mine: number } | undefined = { draft, mine }
168 try {
169 while (next !== undefined) {
170 await grammarCheck($, next.draft, next.mine)
171 // Skip a waiting draft that a newer edit already replaced; that edit's timer brings its own check
172 next = waiting?.mine === seq ? waiting : undefined
173 waiting = undefined
174 }
175 } finally {
176 isRunning = false
177 }
178}
179
180export const register: Register = on => {
181 on('prompt.edit', async ($, e, next) => {
182 const r = await next(e)
183 if (r.text === e.text) {
184 // A bare cursor move changes nothing worth checking
185 return r
186 }
187 // Check a slash command's arguments, not the command name
188 const draft = r.text.trim().replace(/^\/\S*\s*/, '')
189 if (draft === '' || draft.startsWith('!')) {
190 await clear($)
191 return r
192 }
193 timer?.cancel()
194 seq += 1
195 const mine = seq
196 timer = $.clock.after(DEBOUNCE_MS, () => {
197 void requestCheck($, draft, mine)
198 })
199 return r
200 })
201
202 on('prompt.submit', async ($, e, next) => {
203 cancelChecks()
204 // Keep the last result on screen, dimmed, until the next draft is checked
205 await update($, check, (prev): GrammarCheck | null => (prev?.status === 'done' ? { ...prev, status: 'submitted' } : null))
206 return next(e)
207 })
208
209 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
210 const current = await read($, check)
211 if (e.props.hasSurvey || current === null) {
212 return next(e)
213 }
214
215 const { Box, Text } = $.ui.resolve(e)
216 const isDesktop = e.surface === 'desktop'
217 const isChecking = current.status === 'checking' || current.status === 'streaming'
218 const isDimmed = current.status === 'checking' || current.status === 'submitted'
219 // Same colors as hal-statusline's colorize_grammar, except the label and explanation use the theme's text color: green for no issues, red otherwise
220 const color = current.lines.some(line => line.toLowerCase().includes('no issues')) ? 'green' : 'red'
221
222 return (
223 // The glyph gets its own column: in a proportional font "⏺ " is not two cells wide, so padding would misalign the lines
224 // On desktop, one cell of padding matches the inset of the app's own branch card above
225 <Box flexDirection="row" marginTop={1} paddingLeft={isDesktop ? 1 : 0}>
226 {isDesktop ? null : (
227 <Box width={2}>
228 <Text dimColor={isDimmed}>⏺</Text>
229 </Box>
230 )}
231 <Box flexDirection="column">
232 <Text dimColor={isDimmed}>hal-grammar-check:{isChecking ? ' checking...' : ''}</Text>
233 {current.lines.map((issue, i) => {
234 const arrow = issue.indexOf(' => ')
235 if (arrow === -1) {
236 return (
237 <Text key={`line-${i}`} color={color} dimColor={isDimmed}>
238 {issue}
239 </Text>
240 )
241 }
242 // The model quotes each corrected word, so the odd parts of a split on `"` are the fixes
243 const parts = issue.slice(0, arrow).split('"')
244 return (
245 <Text key={`line-${i}`} dimColor={isDimmed}>
246 {parts.map((part, j) => (
247 <Text key={`part-${j}`} color={j % 2 === 1 ? 'red' : undefined}>
248 {part}
249 </Text>
250 ))}
251 <Text> => {issue.slice(arrow + 4)}</Text>
252 </Text>
253 )
254 })}
255 </Box>
256 </Box>
257 )
258 })
259}
260types/index.d.ts 8 lines1export type GrammarCheck = { status: 'checking' | 'streaming' | 'done' | 'submitted'; lines: string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'hal-grammar-check': { check: GrammarCheck | null }
6 }
7}
8