SLOPSHOPPER

HAL Grammar Check

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

newbandpromptprocesstimer
★ 138v0.1.2MITupdated 2026-10-09vinta/hal-9000/plugins/hal-grammar-check
A shopper browsing a rack in a slop shop
README

Claude Code Grammar Check

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

Installation

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.

Usage

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.

Privacy

It sends the first 500 characters of your draft to your local Ollama at localhost:11434. Fully offline, nothing leaves your machine.

Screenshots

Claude Code Grammar Check example

Source 2 files
hooks/register.tsx 260 lines
1import { 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> =&gt; {issue.slice(arrow + 4)}</Text>
252              </Text>
253            )
254          })}
255        </Box>
256      </Box>
257    )
258  })
259}
260
types/index.d.ts 8 lines
1export 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