SLOPSHOPPER

better-spellcheck

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

newbandcommandpromptprocesstimer
v0.1.0no licenseupdated 2026-10-03tjwds/better-spellcheck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · better-spellcheck
› fix the failing auth test and add an audit log call ● better-spellcheck: better-spellcheck: could not load the dictionary: Error: ENOENT: no such file /plugins/better-spellcheck/hooks/words.txt ⏺ 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 › /spellcheck ⎿ better-spellcheck: Spellcheck is on; 0 word(s) in your dictionary. ⎿ better-spellcheck: Suggestions come from the macOS spell checker (/usr/bin/osascript). ⎿ better-spellcheck: Usage: /spellcheck [on | off | add <word…> | remove <word…> | list | keys] ⎿ better-spellcheck: on, off turn checking on or off (remembered across sessions) ⎿ better-spellcheck: add <word…> add words to your personal dictionary ⎿ better-spellcheck: remove <word…> remove words from your personal dictionary ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

better-spellcheck

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
  • Misspelled words in the prompt are underlined in your terminal's red, once you've finished typing them.
  • The band above the prompt lists each one with a suggested fix.
  • 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.

Install

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

  • Step 3 adds two bindings to the 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.
  • Step 4 prints a line such as Suggestions come from the macOS spell checker. If it says No suggestion program found, see Platforms.

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:

  1. The macOS spell checker, the one TextEdit uses, through osascript. Built into macOS.
  2. aspell, with an English dictionary
  3. hunspell, with an en_US dictionary
PlatformFor suggestions
macOSNothing to install
Debian, Ubuntusudo apt install hunspell hunspell-en-us (or aspell aspell-en)
Fedorasudo dnf install hunspell hunspell-en-US (or aspell aspell-en)
Archsudo pacman -S hunspell hunspell-en_us (or aspell aspell-en)
Windowshunspell 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.

Usage

The band

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.
  • To pick a different word, press 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

CommandWhat it does
/spellcheckShow whether checking is on, where suggestions come from, and the usage
/spellcheck on, /spellcheck offTurn 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 listShow your personal dictionary
/spellcheck keysBind ctrl+x f and ctrl+x a (install step 3)

What isn't checked

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.

Keyboard shortcuts

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.

How it works

PieceMod API
UnderlinesA prompt.edit hook returns decorations for each unknown word, in ansi256(1): palette slot 1, the terminal's own red
Bandui.render on AbovePrompt, reading a $.state value the edit hook writes
Suggestionsosascript (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.

Development

PathWhat's in it
hooks/register.tsxThe hooks
hooks/spell.tsTokenizing, lookup, aspell parsing, replacement
hooks/checkers.tsThe suggestion programs, in the order they're tried
hooks/keys.tsThe borrowed actions, reading and adding their keybindings
hooks/words.txtThe word list; regenerate with scripts/build-dictionary.sh (needs aspell)
hooks/extra-words.txtExtra 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.

Source 5 files
hooks/register.tsx 356 lines
1import { 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}
356
hooks/checkers.ts 73 lines
1// 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]
73
hooks/keys.ts 98 lines
1// 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}
98
hooks/spell.ts 152 lines
1// 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}
152
types/index.d.ts 9 lines
1/** 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