Underlines misspelled words in the prompt as you type, with aspell suggestions in a band above the prompt

A Claude Code mod that spellchecks your prompt as you type, and fixes typos from the keyboard.
Spelling 1: teh → the 2: recieve → receive a: add teh to dictionary
ctrl+x f fix ctrl+x a add
────────────────────────────────────────────────────────────────────────
❯ please fix teh bug and recieve it
ctrl+x f fixes the first misspelling, everywhere it appears in the prompt, keeping its capitalization. Press it again for the next one.ctrl+x a adds the first flagged word to your personal dictionary instead.Requires Claude Code 2.1.287 or later.
# 1. Get the code
git clone https://github.com/tjwds/better-spellcheck.git
cd better-spellcheck
# 2. Install the mod (the repo is its own plugin marketplace)
claude plugin marketplace add ./
claude plugin install better-spellcheck@better-spellcheck-local
# 3. Bind ctrl+x f and ctrl+x a in your keybindings.json
claude -p "/spellcheck keys"
# 4. Check where suggestions will come from
claude -p "/spellcheck"
Then start a new Claude Code session, or run /reload-plugins in an open one.
Chat block of keybindings.json in your config directory ($CLAUDE_CONFIG_DIR, or ~/.claude). It creates the file if there isn't one, and doesn't change your other bindings. If ctrl+x f or ctrl+x a is already bound to something else, it leaves that key alone and tells you.Suggestions come from the macOS spell checker. If it says No suggestion program found, see Platforms.Spotting misspellings works everywhere with nothing else installed: the word list ships with the mod. Suggested fixes come from the first of these that runs:
osascript. Built into macOS.en_US dictionary| Platform | For suggestions |
|---|---|
| macOS | Nothing to install |
| Debian, Ubuntu | sudo apt install hunspell hunspell-en-us (or aspell aspell-en) |
| Fedora | sudo dnf install hunspell hunspell-en-US (or aspell aspell-en) |
| Arch | sudo pacman -S hunspell hunspell-en_us (or aspell aspell-en) |
| Windows | hunspell with an en_US dictionary, on your PATH |
Without any of them, misspellings are still underlined and listed in the band, without a suggested fix; ctrl+x a still works. Run /spellcheck to see which one the mod found.
The mod looks for aspell and hunspell on your PATH, then in /opt/homebrew/bin and /usr/local/bin.
To update, run git pull in the clone, then /reload-plugins. The marketplace points at the clone, so don't move or delete it. To uninstall, run claude plugin marketplace remove better-spellcheck-local.
The band appears above the prompt while the draft has misspellings:
ctrl+x f applies the fix shown above it, the first misspelling with a suggestion.ctrl+x a adds the first flagged word to your dictionary.ctrl+x tab (or click the band) to move the keyboard to it, then press that word's number. Esc returns you to the prompt.A word with no suggestion is listed in red without a number. Anything other mods draw in the band stays above this row.
/spellcheck| Command | What it does |
|---|---|
/spellcheck | Show whether checking is on, where suggestions come from, and the usage |
/spellcheck on, /spellcheck off | Turn checking on or off; remembered across sessions |
/spellcheck add <word…> | Add words to your personal dictionary |
/spellcheck remove <word…> | Remove words from your personal dictionary |
/spellcheck list | Show your personal dictionary |
/spellcheck keys | Bind ctrl+x f and ctrl+x a (install step 3) |
Inline and fenced code, URLs, paths, @mentions, /commands, file.ext, snake_case, camelCase, ALLCAPS acronyms and their plurals (PRs), words with digits, and words with non-ASCII letters.
Mods can't define their own keybinding actions, so the band's buttons borrow two of Claude Code's that do nothing at the prompt: both act only on the /diff panel Claude Code used before its built-in diff mod, and neither has a default key. /spellcheck keys binds them:
{
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+x f": "app:toggleDiffNoiseFilter",
"ctrl+x a": "app:toggleDiffPreSession"
}
}
]
}
To use other keys, change them in keybindings.json; the band shows whichever keys are bound. ctrl+x chords reach Claude Code in every terminal. Cmd and Option combinations depend on the terminal: many keep Cmd+digit for their own tabs or panes, and Option types characters unless it's set to act as Meta.
| Piece | Mod API |
|---|---|
| Underlines | A prompt.edit hook returns decorations for each unknown word, in ansi256(1): palette slot 1, the terminal's own red |
| Band | ui.render on AbovePrompt, reading a $.state value the edit hook writes |
| Suggestions | osascript (NSSpellChecker), aspell -a or hunspell -a through $.process.run, 250 ms after typing pauses, cached per word |
| Fixes | $.prompt.read + $.prompt.fill, from Buttons with a hotkey and an action |
| Dictionary, on/off | $.store |
What gets flagged is decided by hooks/words.txt (aspell's en_US master list, 123,679 words including inflections), hooks/extra-words.txt (software terms), and your personal dictionary, the same on every platform. The suggestion programs only supply fixes.
The underline is drawn in the same color as the word: decorations have no separate underline color.
| Path | What's in it |
|---|---|
hooks/register.tsx | The hooks |
hooks/spell.ts | Tokenizing, lookup, aspell parsing, replacement |
hooks/checkers.ts | The suggestion programs, in the order they're tried |
hooks/keys.ts | The borrowed actions, reading and adding their keybindings |
hooks/words.txt | The word list; regenerate with scripts/build-dictionary.sh (needs aspell) |
hooks/extra-words.txt | Extra technical words, one per line |
tests/ | Run with claude plugin test . |
Check the plugin and hooks module with claude plugin validate .claude-plugin/plugin.json, and the marketplace with claude plugin validate .. Edits take effect after /reload-plugins.
hooks/register.tsx 356 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptDecoration, Register } from 'claude-code'
3
4import type { Flagged } from '../types'
5import { CHECKERS } from './checkers'
6import type { Checker } from './checkers'
7import { addShortcuts, FIX_ACTION, LEARN_ACTION, parseShortcuts } from './keys'
8import type { Shortcuts } from './keys'
9import {
10 distinctWords,
11 findMisspellings,
12 lookup,
13 parseWordList,
14 replaceMisspelling,
15} from './spell'
16import type { Misspelling } from './spell'
17
18const flaggedAtom = atom({ plugin: 'better-spellcheck', key: 'flagged' } as const, [])
19
20const SUGGESTION_DELAY_MS = 250
21const MAX_SUGGESTIONS = 3
22const MAX_SHOWN = 9
23// Palette slot 1: the terminal's own red, as its color scheme defines it.
24// A theme key such as 'error' is a fixed RGB value, a name such as 'red' is
25// mapped to a fixed hex value, and 'ansi:red' is refused for plugins.
26const RED = 'ansi256(1)'
27
28const USAGE = [
29 'Usage: /spellcheck [on | off | add <word…> | remove <word…> | list | keys]',
30 ' on, off turn checking on or off (remembered across sessions)',
31 ' add <word…> add words to your personal dictionary',
32 ' remove <word…> remove words from your personal dictionary',
33 ' list show your personal dictionary',
34 ' keys bind ctrl+x f (fix) and ctrl+x a (add) in your keybindings.json',
35].join('\n')
36
37// Module state: rebuilt when the module reloads; `$.store` keeps the
38// personal dictionary and the on/off switch across sessions.
39let dictionary = new Set<string>()
40let learned = new Set<string>()
41let isEnabled = true
42let loading: Promise<boolean> | undefined
43// The suggestion program that ran: unknown until first needed, null if none did.
44let checker: { checker: Checker; path: string } | null | undefined
45const suggestions = new Map<string, string[]>()
46let currentWords: string[] = []
47let lastPublished = '[]'
48let pending: { cancel: () => void } | undefined
49let shortcuts: Shortcuts = {}
50
51const isKnown = (word: string) => lookup(dictionary, word) || lookup(learned, word)
52
53const check = (text: string, cursor: number) => findMisspellings(text, cursor, isKnown)
54
55const decorate = (found: readonly Misspelling[]): PromptDecoration[] =>
56 found.map(m => ({ start: m.start, end: m.end, color: RED, underline: true }))
57
58async function load($: EngineInterface): Promise<boolean> {
59 try {
60 const [base, extra, saved, disabled] = await Promise.all([
61 $.fs.read(`${$.plugin.root}/hooks/words.txt`),
62 $.fs.read(`${$.plugin.root}/hooks/extra-words.txt`),
63 $.store.get('learned'),
64 $.store.get('disabled'),
65 ])
66 dictionary = new Set([...parseWordList(base), ...parseWordList(extra)].map(w => w.toLowerCase()))
67 learned = new Set(Array.isArray(saved) ? saved.map(w => String(w).toLowerCase()) : [])
68 isEnabled = disabled !== true
69 await loadShortcuts($)
70 return true
71 } catch (error) {
72 $.ui.log(`better-spellcheck: could not load the dictionary: ${String(error)}`)
73 return false
74 }
75}
76
77async function keybindingsPath($: EngineInterface): Promise<string> {
78 // Native Windows sets USERPROFILE and usually not HOME.
79 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
80 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
81 return `${dir}/keybindings.json`
82}
83
84// The keys bound to the band's actions, so its hint names the real ones.
85async function loadShortcuts($: EngineInterface) {
86 try {
87 shortcuts = parseShortcuts(await $.fs.read(await keybindingsPath($)))
88 } catch {
89 shortcuts = {}
90 }
91}
92
93// Binds the band's actions in keybindings.json, keeping everything else there.
94// Claude Code picks up the change without a restart.
95async function setUpKeys($: EngineInterface): Promise<string> {
96 const path = await keybindingsPath($)
97 const existing = (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
98 const update = addShortcuts(existing)
99 if (!update.ok) {
100 return `${path} was left unchanged: ${update.reason}. Add these to its "Chat" bindings by hand:\n "ctrl+x f": "${FIX_ACTION}",\n "ctrl+x a": "${LEARN_ACTION}"`
101 }
102 if (update.added.length > 0) await $.fs.write(path, update.text)
103 await loadShortcuts($)
104 const lines = [
105 update.added.length > 0 && `Bound ${update.added.join(' and ')} in ${path}.`,
106 update.kept.length > 0 && `Already bound: ${update.kept.join(', ')}.`,
107 update.conflicts.length > 0 &&
108 `Not bound, as ${update.conflicts.join(' and ')} already ${update.conflicts.length > 1 ? 'do' : 'does'} something else in ${path}; bind ${FIX_ACTION} / ${LEARN_ACTION} to other keys there.`,
109 ].filter(Boolean)
110 return lines.join('\n')
111}
112
113function ensureLoaded($: EngineInterface): Promise<boolean> {
114 loading ??= load($)
115 return loading
116}
117
118async function saveLearned($: EngineInterface) {
119 await $.store.set('learned', [...learned].sort())
120}
121
122// Shares the draft's misspelled words with the band, and asks the checker for
123// suggestions for any it hasn't seen once typing pauses.
124async function publish($: EngineInterface, found: readonly Misspelling[]) {
125 currentWords = distinctWords(found)
126 await publishFlagged($)
127 const missing = currentWords.filter(w => !suggestions.has(w.toLowerCase()))
128 if (missing.length === 0 || checker === null) return
129 pending?.cancel()
130 pending = $.clock.after(SUGGESTION_DELAY_MS, () => {
131 pending = undefined
132 void fetchSuggestions($)
133 })
134}
135
136async function publishFlagged($: EngineInterface) {
137 const flagged: Flagged[] = currentWords.map(word => ({
138 word,
139 suggestions: suggestions.get(word.toLowerCase()) ?? [],
140 }))
141 const serialized = JSON.stringify(flagged)
142 if (serialized === lastPublished) return
143 lastPublished = serialized
144 await update($, flaggedAtom, () => flagged)
145}
146
147// Runs the suggestion program on `words`. The first time, tries each in
148// CHECKERS until one runs; after that, only that one.
149async function runChecker($: EngineInterface, words: readonly string[]) {
150 const options = checker ? [checker] : CHECKERS.flatMap(c => c.paths.map(path => ({ checker: c, path })))
151 for (const option of options) {
152 const stdin = option.checker.stdin(words)
153 try {
154 const { exitCode, stdout } = await $.process.run([option.path, ...option.checker.args(words)], {
155 ...(stdin === undefined ? {} : { stdin }),
156 timeoutMs: 5000,
157 })
158 if (exitCode !== 0) continue
159 checker = option
160 return option.checker.parse(stdout)
161 } catch {
162 // Not installed at this path; try the next one.
163 }
164 }
165 if (checker === undefined) checker = null
166 return null
167}
168
169async function fetchSuggestions($: EngineInterface) {
170 const missing = currentWords.filter(w => !suggestions.has(w.toLowerCase()))
171 if (missing.length === 0) return
172 const asked = missing.map(w => w.replace(/’/g, "'"))
173 const parsed = await runChecker($, asked)
174 if (parsed === null) return
175 missing.forEach((word, i) => {
176 suggestions.set(word.toLowerCase(), (parsed.get(asked[i]!) ?? []).slice(0, MAX_SUGGESTIONS))
177 })
178 await publishFlagged($)
179}
180
181async function describeChecker($: EngineInterface): Promise<string> {
182 if (checker === undefined) await runChecker($, [])
183 return checker
184 ? `Suggestions come from ${checker.checker.name} (${checker.path}).`
185 : 'No suggestion program found: install aspell or hunspell with an en_US dictionary. Misspellings are still underlined.'
186}
187
188// Puts new text in the prompt box with its misspellings underlined.
189async function refill($: EngineInterface, text: string) {
190 const found = check(text, -1)
191 await $.prompt.fill({ text, mode: 'replace', decorations: decorate(found) })
192 await publish($, found)
193}
194
195async function fix($: EngineInterface, word: string, replacement: string) {
196 const box = await $.prompt.read()
197 const text = replaceMisspelling(box.text, check(box.text, -1), word, replacement)
198 if (text !== box.text) await refill($, text)
199}
200
201async function learn($: EngineInterface, word: string) {
202 learned.add(word.toLowerCase())
203 await saveLearned($)
204 const box = await $.prompt.read()
205 if (box.text !== '') await refill($, box.text)
206 else await publish($, [])
207}
208
209async function setEnabled($: EngineInterface, enabled: boolean) {
210 isEnabled = enabled
211 await $.store.set('disabled', !enabled)
212 if (!enabled) await publish($, [])
213}
214
215async function runCommand($: EngineInterface, args: string): Promise<string> {
216 await ensureLoaded($)
217 const [action = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
218 const words = rest.map(w => w.toLowerCase())
219 switch (action.toLowerCase()) {
220 case '':
221 return [
222 `Spellcheck is ${isEnabled ? 'on' : 'off'}; ${learned.size} word(s) in your dictionary.`,
223 await describeChecker($),
224 USAGE,
225 ].join('\n')
226 case 'on':
227 await setEnabled($, true)
228 return 'Spellcheck is on.'
229 case 'off':
230 await setEnabled($, false)
231 return 'Spellcheck is off.'
232 case 'add':
233 if (words.length === 0) return USAGE
234 for (const w of words) learned.add(w)
235 await saveLearned($)
236 return `Added to your dictionary: ${words.join(', ')}`
237 case 'remove': {
238 if (words.length === 0) return USAGE
239 const removed = words.filter(w => learned.delete(w))
240 await saveLearned($)
241 return removed.length
242 ? `Removed from your dictionary: ${removed.join(', ')}`
243 : 'None of those words are in your dictionary.'
244 }
245 case 'keys':
246 return setUpKeys($)
247 case 'list':
248 return learned.size
249 ? `Your dictionary (${learned.size}): ${[...learned].sort().join(', ')}`
250 : 'Your dictionary is empty. Add words with /spellcheck add <word>.'
251 default:
252 return USAGE
253 }
254}
255
256export const register: Register = on => {
257 on('session.start', async ($, e, next) => {
258 await $.command.register({
259 name: 'spellcheck',
260 description: 'Spellcheck the prompt: turn it on or off, or manage your dictionary',
261 argumentHint: '[on|off|add <word>|remove <word>|list|keys]',
262 immediate: true,
263 })
264 void ensureLoaded($)
265 return next(e)
266 })
267
268 on('prompt.edit', async ($, e, next) => {
269 const box = await next(e)
270 if (!(await ensureLoaded($)) || !isEnabled) return box
271 const found = check(box.text, box.cursor)
272 await publish($, found)
273 return { ...box, decorations: [...(box.decorations ?? []), ...decorate(found)] }
274 })
275
276 // A sent prompt empties the box, so nothing is left to flag.
277 on('prompt.submit', async ($, e, next) => {
278 if (currentWords.length > 0) await publish($, [])
279 return next(e)
280 })
281
282 on('command.run', { command: 'spellcheck' }, async ($, e) => ({
283 text: await runCommand($, e.args),
284 }))
285
286 // The band above the prompt. Fixes and "add" act on the first flagged word,
287 // so pressing a key again moves on to the next. Keys: the chords bound to
288 // FIX_ACTION and LEARN_ACTION work from the prompt, and each is shown under
289 // the control it presses; after ctrl+x tab (or a click) focuses the band, so
290 // do the digits and `a`. Esc gives the keyboard back to the prompt.
291 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
292 const flagged = await read($, flaggedAtom)
293 if (e.props.hasSurvey || flagged.length === 0) return next(e)
294
295 const { Box, Button, Text } = $.ui.resolve(e)
296 const shown = flagged.slice(0, MAX_SHOWN)
297 const hidden = flagged.length - shown.length
298 const firstFixable = shown.findIndex(f => f.suggestions[0] !== undefined)
299 const first = flagged[0]!.word
300 // Keep what other mods draw in the band, above ours.
301 const theirs = await next(e)
302
303 const fixHint = firstFixable !== -1 && shortcuts.fix ? `${shortcuts.fix} fix` : undefined
304 const learnHint = shortcuts.learn ? `${shortcuts.learn} add` : undefined
305 // A control with its bound key in a dim line underneath, left-aligned.
306 const withHint = (control: ReturnType<typeof Text>, hint: string | undefined) =>
307 hint === undefined ? (
308 control
309 ) : (
310 <Box flexDirection="column">
311 {control}
312 <Text dimColor>{hint}</Text>
313 </Box>
314 )
315
316 return (
317 <Box flexDirection="column">
318 {theirs}
319 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
320 <Text color={RED}>Spelling</Text>
321 {shown.map((f, i) => {
322 const best = f.suggestions[0]
323 if (best === undefined) return <Text color={RED}>{f.word}</Text>
324 const button = (
325 <Button
326 key={`fix-${i + 1}`}
327 hotkey={String(i + 1)}
328 plain
329 {...(i === firstFixable ? { action: FIX_ACTION } : {})}
330 label={`${f.word} → ${best}`}
331 onPress={() => void fix($, f.word, best)}
332 />
333 )
334 return withHint(button, i === firstFixable ? fixHint : undefined)
335 })}
336 {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
337 {withHint(
338 <Button
339 key="learn"
340 hotkey="a"
341 action={LEARN_ACTION}
342 plain
343 label={`add ${first} to dictionary`}
344 onPress={() => void learn($, first)}
345 />,
346 learnHint,
347 )}
348 </Box>
349 {fixHint === undefined && learnHint === undefined && (
350 <Text dimColor>ctrl+x tab to pick with the keys above</Text>
351 )}
352 </Box>
353 )
354 })
355}
356hooks/checkers.ts 73 lines1// The programs that supply suggestions, tried in order; the first that runs
2// is used for the rest of the session. Which words get flagged never depends
3// on them: that's the bundled word list.
4
5import { parseAspell } from './spell'
6
7export type Checker = {
8 /** What `/spellcheck` calls it. */
9 name: string
10 /** Where to find it: a bare name is looked up on PATH. */
11 paths: readonly string[]
12 args: (words: readonly string[]) => string[]
13 stdin: (words: readonly string[]) => string | undefined
14 parse: (stdout: string) => Map<string, string[]>
15}
16
17// NSSpellChecker, the checker macOS apps use, through JavaScript for
18// Automation: the words arrive as arguments, the guesses leave as JSON.
19const MACOS_SCRIPT = `function run(argv) {
20 ObjC.import('AppKit')
21 const checker = $.NSSpellChecker.sharedSpellChecker
22 const out = {}
23 for (const word of argv) {
24 const guesses = checker.guessesForWordRangeInStringLanguageInSpellDocumentWithTag(
25 $.NSMakeRange(0, word.length), word, 'en_US', 0)
26 out[word] = ObjC.deepUnwrap(guesses) || []
27 }
28 return JSON.stringify(out)
29}`
30
31export function parseMacGuesses(stdout: string): Map<string, string[]> {
32 const out = new Map<string, string[]>()
33 let parsed: unknown
34 try {
35 parsed = JSON.parse(stdout)
36 } catch {
37 return out
38 }
39 if (typeof parsed !== 'object' || parsed === null) return out
40 for (const [word, guesses] of Object.entries(parsed)) {
41 if (Array.isArray(guesses)) out.set(word, guesses.filter((g): g is string => typeof g === 'string'))
42 }
43 return out
44}
45
46// aspell and hunspell share ispell's pipe protocol (`-a`); `^` makes each
47// line text to check, never a command.
48const pipeInput = (words: readonly string[]) => words.map(w => `^${w}\n`).join('')
49
50export const CHECKERS: readonly Checker[] = [
51 {
52 name: 'the macOS spell checker',
53 paths: ['/usr/bin/osascript'],
54 args: words => ['-l', 'JavaScript', '-e', MACOS_SCRIPT, ...words],
55 stdin: () => undefined,
56 parse: parseMacGuesses,
57 },
58 {
59 name: 'aspell',
60 paths: ['aspell', '/opt/homebrew/bin/aspell', '/usr/local/bin/aspell'],
61 args: () => ['-a', '--lang=en_US'],
62 stdin: pipeInput,
63 parse: parseAspell,
64 },
65 {
66 name: 'hunspell',
67 paths: ['hunspell', '/opt/homebrew/bin/hunspell', '/usr/local/bin/hunspell'],
68 args: () => ['-a', '-d', 'en_US'],
69 stdin: pipeInput,
70 parse: parseAspell,
71 },
72]
73hooks/keys.ts 98 lines1// Mods can't define keybinding actions of their own, so the band's buttons
2// borrow two of Claude Code's that nothing handles at the prompt: both act
3// only on the diff panel that /diff opened before the cc-plugin-diff mod, and
4// neither has a default key. A Button naming one is pressed by whatever key
5// the person binds to it in the Chat or Global context.
6export const FIX_ACTION = 'app:toggleDiffNoiseFilter'
7export const LEARN_ACTION = 'app:toggleDiffPreSession'
8
9export type Shortcuts = { fix?: string; learn?: string }
10
11/** The keys bound to the two actions in a keybindings.json, where the prompt sees them. */
12export function parseShortcuts(text: string): Shortcuts {
13 const found: Shortcuts = {}
14 let parsed: unknown
15 try {
16 parsed = JSON.parse(text)
17 } catch {
18 return found
19 }
20 const blocks = (parsed as { bindings?: unknown })?.bindings
21 if (!Array.isArray(blocks)) return found
22 for (const block of blocks) {
23 const { context, bindings } = (block ?? {}) as { context?: unknown; bindings?: unknown }
24 if (context !== 'Chat' && context !== 'Global') continue
25 if (typeof bindings !== 'object' || bindings === null) continue
26 for (const [key, action] of Object.entries(bindings)) {
27 if (action === FIX_ACTION) found.fix ??= key
28 if (action === LEARN_ACTION) found.learn ??= key
29 }
30 }
31 return found
32}
33
34// The keys `/spellcheck keys` binds: ctrl+x chords reach Claude Code from
35// every terminal, where Cmd and Option combinations often don't.
36export const DEFAULT_KEYS: ReadonlyArray<readonly [key: string, action: string]> = [
37 ['ctrl+x f', FIX_ACTION],
38 ['ctrl+x a', LEARN_ACTION],
39]
40
41export type KeysUpdate =
42 | { ok: true; text: string; added: string[]; kept: string[]; conflicts: string[] }
43 | { ok: false; reason: string }
44
45/**
46 * Adds DEFAULT_KEYS to a keybindings.json's Chat block, keeping everything
47 * else. `existing` is the file's text, or undefined when there is no file.
48 * An action already bound anywhere the prompt sees is kept as it is, and a
49 * key already bound to something else is left alone and reported.
50 */
51export function addShortcuts(existing: string | undefined): KeysUpdate {
52 let root: Record<string, unknown> = {}
53 if (existing !== undefined && existing.trim() !== '') {
54 try {
55 const parsed: unknown = JSON.parse(existing)
56 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
57 return { ok: false, reason: 'it is not a JSON object' }
58 }
59 root = parsed as Record<string, unknown>
60 } catch {
61 return { ok: false, reason: 'it is not plain JSON' }
62 }
63 }
64 if (root.bindings !== undefined && !Array.isArray(root.bindings)) {
65 return { ok: false, reason: '"bindings" is not an array' }
66 }
67 const blocks = (root.bindings ?? []) as unknown[]
68 let chat = blocks.find(
69 (b): b is { context: string; bindings: Record<string, unknown> } =>
70 typeof b === 'object' &&
71 b !== null &&
72 (b as { context?: unknown }).context === 'Chat' &&
73 typeof (b as { bindings?: unknown }).bindings === 'object' &&
74 (b as { bindings?: unknown }).bindings !== null,
75 )
76 if (chat === undefined) {
77 chat = { context: 'Chat', bindings: {} }
78 blocks.push(chat)
79 }
80 const bound = parseShortcuts(JSON.stringify({ bindings: blocks }))
81 const added: string[] = []
82 const kept: string[] = []
83 const conflicts: string[] = []
84 for (const [key, action] of DEFAULT_KEYS) {
85 const already = action === FIX_ACTION ? bound.fix : bound.learn
86 if (already !== undefined) {
87 kept.push(already)
88 } else if (chat.bindings[key] !== undefined && chat.bindings[key] !== action) {
89 conflicts.push(key)
90 } else {
91 chat.bindings[key] = action
92 added.push(key)
93 }
94 }
95 const text = `${JSON.stringify({ ...root, bindings: blocks }, null, 2)}\n`
96 return { ok: true, text, added, kept, conflicts }
97}
98hooks/spell.ts 152 lines1// Pure spelling logic: no `$`, so the tests can call it directly.
2
3export type Misspelling = { word: string; start: number; end: number }
4
5// A run of letters, with inner apostrophes kept (don't, Joe's).
6const WORD = /\p{L}+(?:['’]\p{L}+)*/gu
7
8// A whitespace-delimited chunk that reads as code, a path or a reference
9// rather than prose: /commands, @mentions, ~/paths, #123, $VARS, URLs,
10// a/b paths, file.ts and example.com, snake_case, x=y, <tags>, f(x), digits.
11const CODE_LIKE = [
12 /^[/@~#$\\]/,
13 /:\/\//,
14 /[/\\]/,
15 /\p{L}\.\p{L}/u,
16 /[_=<>{}[\]|^+]/,
17 /\p{L}\(/u,
18 /\d/,
19]
20
21/**
22 * Character ranges of `text` inside markdown code: fenced blocks and inline
23 * spans. An unclosed fence runs to the end of the text, and an unclosed
24 * backtick to the end of its line, since the person is still typing it.
25 */
26export function codeRanges(text: string): Array<[number, number]> {
27 const ranges: Array<[number, number]> = []
28 const fences = /```[\s\S]*?(?:```|$)/g
29 for (const m of text.matchAll(fences)) ranges.push([m.index, m.index + m[0].length])
30 // Inline spans, outside the fences: `$` here is the end of a line.
31 const outside = text.replace(fences, f => ' '.repeat(f.length))
32 for (const m of outside.matchAll(/`[^`\n]*(?:`|$)/gm)) ranges.push([m.index, m.index + m[0].length])
33 return ranges
34}
35
36function overlaps(ranges: Array<[number, number]>, start: number, end: number): boolean {
37 return ranges.some(([s, e]) => start < e && end > s)
38}
39
40/** True for a word worth looking up; false for acronyms and identifiers. */
41export function isCheckable(word: string): boolean {
42 if (word.length < 2) return false
43 // Non-ASCII letters (café, naïve): the dictionary is plain en_US.
44 if (/[^\x00-\x7F’]/.test(word)) return false
45 // ALLCAPS: acronyms (PR, LLM, TODO), and their plurals (PRs, APIs).
46 if (word === word.toUpperCase()) return false
47 if (/^\p{Lu}{2,}s$/u.test(word)) return false
48 // camelCase or PascalCase with an inner capital (useState, TypeScript).
49 if (/\p{Ll}\p{Lu}/u.test(word)) return false
50 return true
51}
52
53/**
54 * The misspelled words of `text`, in order. The word the cursor sits at the
55 * end of is skipped, since the person is still typing it.
56 */
57export function findMisspellings(
58 text: string,
59 cursor: number,
60 isKnown: (word: string) => boolean,
61): Misspelling[] {
62 const code = codeRanges(text)
63 const found: Misspelling[] = []
64 for (const chunk of text.matchAll(/\S+/g)) {
65 const chunkStart = chunk.index
66 if (overlaps(code, chunkStart, chunkStart + chunk[0].length)) continue
67 if (CODE_LIKE.some(re => re.test(chunk[0]))) continue
68 for (const w of chunk[0].matchAll(WORD)) {
69 const start = chunkStart + w.index
70 const end = start + w[0].length
71 if (end === cursor) continue
72 if (!isCheckable(w[0])) continue
73 if (!isKnown(w[0])) found.push({ word: w[0], start, end })
74 }
75 }
76 return found
77}
78
79/** Looks a word up in a lowercase word set, allowing possessives. */
80export function lookup(words: ReadonlySet<string>, word: string): boolean {
81 const w = word.toLowerCase().replace(/’/g, "'")
82 if (words.has(w)) return true
83 if (w.endsWith("'s")) return words.has(w.slice(0, -2))
84 if (w.endsWith("s'")) return words.has(w.slice(0, -1))
85 return false
86}
87
88/** Parses a word list: one word per line, `#` comments and blanks skipped. */
89export function parseWordList(text: string): string[] {
90 return text
91 .split('\n')
92 .map(line => line.trim())
93 .filter(line => line !== '' && !line.startsWith('#'))
94}
95
96/**
97 * Parses `aspell -a` output into suggestions per word: `& word n off: a, b`
98 * carries suggestions, `# word off` has none, `*` lines are correct words.
99 */
100export function parseAspell(stdout: string): Map<string, string[]> {
101 const out = new Map<string, string[]>()
102 for (const line of stdout.split('\n')) {
103 const withSuggestions = /^& (\S+) \d+ \d+: (.*)$/.exec(line)
104 if (withSuggestions) {
105 out.set(withSuggestions[1]!, withSuggestions[2]!.split(', ').filter(s => s !== ''))
106 continue
107 }
108 const without = /^# (\S+) \d+$/.exec(line)
109 if (without) out.set(without[1]!, [])
110 }
111 return out
112}
113
114/** `replacement`, capitalized to match `original`'s first letter. */
115export function matchCase(original: string, replacement: string): string {
116 const first = original[0] ?? ''
117 if (first !== first.toUpperCase() || first === first.toLowerCase()) return replacement
118 return replacement.charAt(0).toUpperCase() + replacement.slice(1)
119}
120
121/**
122 * Replaces each misspelling of `word` in `text` with `replacement`, matching
123 * each occurrence's capitalization.
124 */
125export function replaceMisspelling(
126 text: string,
127 misspellings: readonly Misspelling[],
128 word: string,
129 replacement: string,
130): string {
131 const target = word.toLowerCase()
132 let next = text
133 const hits = misspellings.filter(m => m.word.toLowerCase() === target)
134 for (const m of [...hits].reverse()) {
135 next = next.slice(0, m.start) + matchCase(m.word, replacement) + next.slice(m.end)
136 }
137 return next
138}
139
140/** The distinct misspelled words, in first-seen order, compared lowercase. */
141export function distinctWords(misspellings: readonly Misspelling[]): string[] {
142 const seen = new Set<string>()
143 const words: string[] = []
144 for (const m of misspellings) {
145 const key = m.word.toLowerCase()
146 if (seen.has(key)) continue
147 seen.add(key)
148 words.push(m.word)
149 }
150 return words
151}
152types/index.d.ts 9 lines1/** One misspelled word in the draft and aspell's suggestions for it. */
2export type Flagged = { word: string; suggestions: string[] }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'better-spellcheck': { flagged: Flagged[] }
7 }
8}
9