SLOPSHOPPER

spellfix

Pick fixes for misspelled words in the prompt draft with hunspell and Haiku

newbandtoastpromptmodelprocess
A shopper browsing a rack in a slop shop
README

NixOS Configuration

Personal NixOS configuration using flakes, home-manager, and agenix for secret management.

Quick Start

All common tasks are managed via just; run just to list every recipe with its description.

just switch                  # Rebuild and switch this machine (any host class)
just switch desktop          # ...or a specific host
just switch host user@remote # ...or push a nixos host to a remote (nixos-rebuild --target-host)
just test                    # Apply until reboot
just build                   # Build without switching
just diff                    # ...and diff the build against the running system
just update                  # Update flake inputs
just fmt                     # Format everything
just gc                      # Collect garbage and delete old generations

Options Reference

This config exposes a custom fireproof.* options namespace. A generated Markdown reference — descriptions, types, defaults, and declaration links — is available at docs/fireproof-options.md. CI regenerates it after each push to main.

Installing on a New Machine

just new-host <hostname> <username>       # card, SSH key, rekey (YubiKey)
just deploy-remote <hostname> root@<ip>   # nixos-anywhere: formats the target's disks

deploy-remote installs onto a target already booted into a Linux with SSH: the generic installer image, any NixOS ISO, or a cloud VM. The target's disks come from the host's hosts/<hostname>/ cards (see the disko templates), and nixos-anywhere wipes whatever is on them.

The full procedure, including disk layouts, TPM2 unlock and installing to a fresh Mac, is in docs/hosts.md.

Secret Management

Secrets are managed with agenix + agenix-rekey, using a YubiKey as the master identity.

Layout, commands and YubiKey requirements are in docs/secrets.md; just lists the secret recipes.

Development

Development Shell

A Nix development shell is available with useful tools for working on this configuration:

# Enter the development shell
nix develop

Formatting

Code is formatted with treefmt-nix; the formatters are configured in formatter.nix.

just fmt

Theme

Heavily inspired by / stolen from Flexoki

The palette is the fireproof.theme.colors options in modules/base/theme.nix; defaults are listed in docs/fireproof-options.md.

Source 3 files
hooks/register.tsx 645 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, PluginOptions, PromptBox, Register, Timer } from "claude-code";
3
4import type { Extra, Miss, Picker, Report } from "../types";
5import {
6  addInput,
7  autoAdds,
8  candidates,
9  caseVariants,
10  CHECK,
11  checkPrompt,
12  hunspellArgs,
13  isExtra,
14  nearest,
15  parseReply,
16  parseSuggestions,
17  placeExtras,
18  recase,
19  replyBudget,
20  shift,
21  suggestInput,
22  withExtras,
23  withoutWords,
24} from "./core";
25import type { Verdict } from "./core";
26
27// Borrowed: mods can't define keybinding actions, and nothing handles this one
28// while the built-in cc-plugin-diff mod is enabled. A key bound to it in
29// keybindings.json presses the band's button from the prompt.
30const ACTION = "app:toggleDiffNoiseFilter";
31const PLUGIN = "spellfix";
32const SUGGESTIONS = 5;
33const CHECK_DELAY_MS = 300;
34
35const count = atom({ plugin: "spellfix", key: "count" } as const, 0);
36const hasDraft = atom({ plugin: "spellfix", key: "hasDraft" } as const, false);
37const picker = atom({ plugin: "spellfix", key: "picker" } as const, null);
38const report = atom({ plugin: "spellfix", key: "report" } as const, null);
39// Words the batch added to the personal dictionary on its own, until `8`, `9` or the next edit.
40const added = atom({ plugin: "spellfix", key: "added" } as const, []);
41// True while ctrl+q waits on the batch to find anything hunspell didn't.
42const isBusy = atom({ plugin: "spellfix", key: "isBusy" } as const, false);
43
44type Config = { hunspell: string; dictionaries: string; personal: string };
45
46let config: Config = { hunspell: "hunspell", dictionaries: "en_US", personal: "" };
47// Words skipped in the picker, forgotten once the draft is submitted or cleared.
48const ignored = new Set<string>();
49// Auto-added words taken back with `8`: the batch leaves them flagged for the rest of the draft.
50const unadded = new Set<string>();
51let timer: Timer | undefined;
52let checkId = 0;
53let hasWarned = false;
54
55/** One Haiku call's answers for a draft; `text` follows the picker's own fixes. */
56type Batch = {
57  text: string;
58  // The picker's fixes made while the call was out, replayed on the extras when it lands.
59  edits: { start: number; end: number; delta: number }[];
60  result?: { verdicts: Map<string, Verdict>; extras: Extra[] };
61};
62
63let batch: Batch | undefined;
64
65function readConfig(options: PluginOptions): Config {
66  const text = (key: string, fallback: string) => {
67    const value = options[key];
68    return typeof value === "string" && value !== "" ? value : fallback;
69  };
70  return {
71    hunspell: text("hunspell", "hunspell"),
72    dictionaries: text("dictionaries", "en_US"),
73    personal: text("personalDictionary", ""),
74  };
75}
76
77function hunspellArgv(mode: "-l" | "-a"): string[] {
78  return [config.hunspell, ...hunspellArgs(mode, config.dictionaries, config.personal)];
79}
80
81async function runHunspell(
82  $: EngineInterface,
83  mode: "-l" | "-a",
84  stdin: string,
85): Promise<string | undefined> {
86  try {
87    const r = await $.process.run(hunspellArgv(mode), { stdin, timeoutMs: 5000 });
88    if (r.exitCode === 0) return r.stdout;
89    throw new Error(r.stderr.trim() || `exit ${r.exitCode}`);
90  } catch (error) {
91    if (!hasWarned) {
92      hasWarned = true;
93      $.ui.toast(
94        `spellfix: hunspell failed: ${error instanceof Error ? error.message : String(error)}`,
95      );
96    }
97    return undefined;
98  }
99}
100
101/** The candidates hunspell rejects, skipped words excluded; none when hunspell fails. */
102async function findMisses($: EngineInterface, text: string): Promise<Miss[]> {
103  const words = candidates(text).filter((w) => !ignored.has(w.word));
104  if (words.length === 0) return [];
105  const stdout = await runHunspell(
106    $,
107    "-l",
108    `${[...new Set(words.map((w) => w.word))].join("\n")}\n`,
109  );
110  if (stdout === undefined) return [];
111  const bad = new Set(stdout.split("\n").filter(Boolean));
112  return words.filter((w) => bad.has(w.word));
113}
114
115/** The words of `words` hunspell accepts; none when it fails. */
116async function knownWords($: EngineInterface, words: readonly string[]): Promise<Set<string>> {
117  if (words.length === 0) return new Set();
118  const stdout = await runHunspell($, "-l", `${words.join("\n")}\n`);
119  if (stdout === undefined) return new Set();
120  const bad = new Set(stdout.split("\n"));
121  return new Set(words.filter((w) => !bad.has(w)));
122}
123
124/** hunspell's misses plus the batch's extras for `text`, in draft order. */
125async function allMisses($: EngineInterface, text: string): Promise<(Miss | Extra)[]> {
126  const extras = batch?.text === text ? (batch.result?.extras ?? []) : [];
127  return withExtras(await findMisses($, text), extras);
128}
129
130/** Haiku's fix for `m` from the batch, if it has landed and has one. */
131function fixFor(m: Miss | Extra): string | null {
132  if (isExtra(m)) return m.fix;
133  const v = batch?.result?.verdicts.get(m.word);
134  return v?.kind === "fix" ? v.word : null;
135}
136
137function summarize(found: readonly (Miss | Extra)[]): string[] {
138  return found.flatMap((m) => {
139    const fix = fixFor(m);
140    return fix ? [`${m.word}→${fix}${isExtra(m) ? "?" : ""}`] : [];
141  });
142}
143
144async function suggest($: EngineInterface, word: string): Promise<string[]> {
145  const stdout = await runHunspell($, "-a", suggestInput(word));
146  return stdout === undefined ? [] : parseSuggestions(stdout, SUGGESTIONS);
147}
148
149async function addWords($: EngineInterface, words: readonly string[]) {
150  for (const word of words) ignored.add(word);
151  if (config.personal && words.length) await runHunspell($, "-a", addInput(words));
152}
153
154/** Takes auto-added words back out of the personal dictionary; hunspell itself can't remove one. */
155async function removeWords($: EngineInterface, words: readonly string[]) {
156  for (const word of words) {
157    ignored.delete(word);
158    unadded.add(word);
159  }
160  if (!config.personal) return;
161  try {
162    await $.fs.write(config.personal, withoutWords(await $.fs.read(config.personal), words));
163  } catch (error) {
164    $.ui.toast(`spellfix: couldn't edit ${config.personal}: ${String(error)}`);
165  }
166}
167
168/** Keeps the batch on the draft through a fix the picker made to `text`. */
169function noteSplice(text: string, m: Miss, replacement: string) {
170  if (batch?.text !== text) return;
171  const delta = replacement.length - (m.end - m.start);
172  batch.text = `${text.slice(0, m.start)}${replacement}${text.slice(m.end)}`;
173  if (!batch.result) batch.edits.push({ start: m.start, end: m.end, delta });
174  else
175    batch.result.extras = batch.result.extras.flatMap((x) => shift(x, m.start, m.end, delta) ?? []);
176}
177
178/** Starts the batch for `text` unless one for it is already out or in. */
179function ensureBatch($: EngineInterface, text: string): Batch {
180  if (batch?.text === text) return batch;
181  const b: Batch = { text, edits: [] };
182  batch = b;
183  void runBatch($, b, text);
184  return b;
185}
186
187async function runBatch($: EngineInterface, b: Batch, text: string) {
188  const flagged = await findMisses($, text);
189  const distinct = new Set(flagged.map((m) => m.word));
190  const r = await $.model.complete({
191    model: "haiku",
192    system: CHECK,
193    prompt: checkPrompt(text, flagged),
194    effort: "low",
195    maxTokens: replyBudget(distinct.size),
196    timeoutMs: 20000,
197  });
198  if (!r.isAnswered) $.ui.toast(`spellfix: Haiku failed: ${r.reason}`);
199  const { verdicts, found } = parseReply(r.isAnswered ? r.text : "", text, distinct);
200  const variants = [...verdicts].flatMap(([word, v]) =>
201    v.kind === "correct" ? caseVariants(word) : [],
202  );
203  recase(verdicts, await knownWords($, variants));
204  let extras = placeExtras(text, found, flagged);
205  for (const e of b.edits) extras = extras.flatMap((x) => shift(x, e.start, e.end, e.delta) ?? []);
206  b.result = { verdicts, extras };
207  if (batch === b) await landed($, b);
208}
209
210/** Acts on a batch's answers: adds the words it calls correct and brings the picker up to date. */
211async function landed($: EngineInterface, b: Batch) {
212  const correct = autoAdds(b.result!.verdicts, (word) => unadded.has(word) || ignored.has(word));
213  await addWords($, correct);
214  if (config.personal && correct.length) {
215    await update($, added, (words) => [...new Set([...words, ...correct])]);
216  }
217
218  const { text, cursor } = await $.prompt.read();
219  if (text !== b.text) {
220    await update($, isBusy, () => false);
221    return;
222  }
223  if (await read($, isBusy)) {
224    await update($, isBusy, () => false);
225    const found = await allMisses($, text);
226    const target = nearest(found, cursor);
227    if (target) await show($, text, target);
228    else $.ui.toast(correct.length ? "No misspellings left" : "No misspellings");
229    return;
230  }
231
232  const p = await read($, picker);
233  if (!p) return;
234  if (correct.includes(p.word) && !p.isExtra) {
235    await advance($, text, p.start);
236    return;
237  }
238  const found = await allMisses($, text);
239  const answered: Picker = {
240    ...p,
241    ai: fixFor(found.find((m) => m.start === p.start) ?? p),
242    isAiDone: true,
243    wantsAi: false,
244    wantsAll: false,
245    summary: summarize(found),
246  };
247  await update($, picker, () => answered);
248  if (p.wantsAll) await applyAllLate($);
249  else if (p.wantsAi && answered.ai) await applyLate($, answered, answered.ai);
250}
251
252function scheduleCheck($: EngineInterface, box: PromptBox) {
253  timer?.cancel();
254  timer = $.clock.after(CHECK_DELAY_MS, () => void refreshCount($, box));
255}
256
257async function refreshCount($: EngineInterface, box: PromptBox) {
258  const id = ++checkId;
259  // The word still being typed waits until the cursor moves past it, as the underline does.
260  const n = (await findMisses($, box.text)).filter((m) => m.end !== box.cursor).length;
261  if (id !== checkId) return;
262  if ((await read($, count)) !== n) await update($, count, () => n);
263  const has = box.text.trim() !== "";
264  if ((await read($, hasDraft)) !== has) await update($, hasDraft, () => has);
265}
266
267/**
268 * Opens the picker on the misspelled word at or nearest the cursor, starting
269 * the batch; with none flagged, opens once the batch finds one. Closes it when open.
270 */
271async function togglePicker($: EngineInterface) {
272  if (await read($, picker)) {
273    await closePicker($);
274    return;
275  }
276  const { text, cursor } = await $.prompt.read();
277  if (text.trim() === "") {
278    $.ui.toast("No misspellings");
279    return;
280  }
281  const b = ensureBatch($, text);
282  const found = await allMisses($, text);
283  const target = nearest(found, cursor);
284  if (target) await show($, text, target);
285  else if (!b.result) await update($, isBusy, () => true);
286  else $.ui.toast("No misspellings");
287}
288
289async function show($: EngineInterface, text: string, miss: Miss | Extra) {
290  const suggestions = isExtra(miss) ? [] : await suggest($, miss.word);
291  const before = text.slice(Math.max(0, miss.start - 30), miss.start).replace(/\s+/g, " ");
292  const after = text.slice(miss.end, miss.end + 30).replace(/\s+/g, " ");
293  const isAiDone = batch?.text === text && batch.result !== undefined;
294  const shown: Picker = {
295    word: miss.word,
296    start: miss.start,
297    end: miss.end,
298    before,
299    after,
300    suggestions,
301    isExtra: isExtra(miss),
302    ai: isAiDone ? fixFor(miss) : null,
303    isAiDone,
304    wantsAi: false,
305    wantsAll: false,
306    summary: isAiDone ? summarize(await allMisses($, text)) : [],
307  };
308  await update($, picker, () => shown);
309}
310
311/** Applies a pick made before its answer arrived; outside an edit, so the cursor ends up at the end. */
312async function applyLate($: EngineInterface, p: Picker, choice: string) {
313  const box = await $.prompt.read();
314  if (box.text.slice(p.start, p.end) !== p.word) return;
315  const fixed = splice(box, p, choice);
316  noteSplice(box.text, p, choice);
317  await $.prompt.fill({ text: fixed.text, mode: "replace" });
318  await advance($, fixed.text, p.start + choice.length);
319}
320
321async function closePicker($: EngineInterface) {
322  if (await read($, picker)) await update($, picker, () => null);
323}
324
325/** Moves the picker to the next misspelled word at or after `from`, wrapping; closes it when none are left. */
326async function advance($: EngineInterface, text: string, from: number) {
327  const found = await allMisses($, text);
328  const next = found.find((m) => m.start >= from) ?? found[0];
329  if (next) {
330    await show($, text, next);
331    return;
332  }
333  await closePicker($);
334  $.ui.toast("No misspellings left");
335}
336
337/** `p` replaced by `replacement` in `box`, the cursor kept on the same text. */
338function splice(box: PromptBox, p: Miss, replacement: string): PromptBox {
339  const delta = replacement.length - (p.end - p.start);
340  const cursor =
341    box.cursor >= p.end ? box.cursor + delta : Math.min(box.cursor, p.start + replacement.length);
342  return { text: `${box.text.slice(0, p.start)}${replacement}${box.text.slice(p.end)}`, cursor };
343}
344
345/** Every fix the landed batch has, applied to `box` and reported for undo; undefined with none. */
346async function applyAll($: EngineInterface, box: PromptBox): Promise<PromptBox | undefined> {
347  const fixes = (await allMisses($, box.text)).flatMap((m) => {
348    const fix = fixFor(m);
349    return fix ? [{ ...m, fix }] : [];
350  });
351  await closePicker($);
352  if (fixes.length === 0) {
353    $.ui.toast("Nothing for Haiku to fix");
354    return undefined;
355  }
356  let fixed = box;
357  for (const f of [...fixes].reverse()) {
358    noteSplice(fixed.text, f, f.fix);
359    fixed = splice(fixed, f, f.fix);
360  }
361  const done: Report = {
362    original: box,
363    fixed: fixed.text,
364    changes: fixes.map((f) => `${f.word}→${f.fix}`),
365  };
366  await update($, report, () => done);
367  return fixed;
368}
369
370/** `a` pressed before the batch landed, applied now that it has. */
371async function applyAllLate($: EngineInterface) {
372  const box = await $.prompt.read();
373  const fixed = await applyAll($, box);
374  if (fixed) await $.prompt.fill({ text: fixed.text, mode: "replace" });
375}
376
377/**
378 * Answers one picker key against the draft in `box`: the box afterwards, or
379 * undefined when `key` is not a picker key (the caller closes the picker).
380 */
381async function pick(
382  $: EngineInterface,
383  key: string,
384  box: PromptBox,
385): Promise<PromptBox | undefined> {
386  const p = await read($, picker);
387  if (!p || box.text.slice(p.start, p.end) !== p.word) return undefined;
388  if (key === "s" || (key === "d" && !p.isExtra && config.personal)) {
389    if (key === "d") await addWords($, [p.word]);
390    else if (p.isExtra && batch?.result) {
391      batch.result.extras = batch.result.extras.filter((x) => x.start !== p.start);
392    } else ignored.add(p.word);
393    await advance($, box.text, p.end);
394    return box;
395  }
396  if (key === "8") {
397    const words = await read($, added);
398    if (words.length === 0) return undefined;
399    await update($, added, () => []);
400    await removeWords($, words);
401    await advance($, box.text, p.start);
402    return box;
403  }
404  if (key === "a") {
405    if (p.isAiDone) return (await applyAll($, box)) ?? box;
406    const waiting: Picker = { ...p, wantsAll: true };
407    await update($, picker, () => waiting);
408    return box;
409  }
410  if (!/^[1-9]$/.test(key)) return undefined;
411
412  // The edit hook can't wait out Haiku (its budget is milliseconds): the answer applies when it lands.
413  if (key === "1" && !p.isAiDone) {
414    const waiting: Picker = { ...p, wantsAi: true };
415    await update($, picker, () => waiting);
416    return box;
417  }
418  const choice = key === "1" ? p.ai : p.suggestions[Number(key) - 2];
419  // A digit with nothing behind it is swallowed, never typed into the draft.
420  if (!choice) return box;
421
422  const fixed = splice(box, p, choice);
423  noteSplice(box.text, p, choice);
424  await advance($, fixed.text, p.start + choice.length);
425  return fixed;
426}
427
428/** A picker key pressed on the band itself (a click, or its hotkey while the band holds the keys). */
429async function pickFromBand($: EngineInterface, key: string) {
430  const box = await $.prompt.read();
431  const fixed = await pick($, key, box);
432  if (fixed && fixed.text !== box.text) await $.prompt.fill({ text: fixed.text, mode: "replace" });
433}
434
435/**
436 * Answers a key while the fix-all report or auto-added notice shows, like a picker key:
437 * `8` takes back the fix-all (while the draft is still exactly what it produced) and the
438 * auto-added words, `9` dismisses. The box afterwards, or undefined for any other key.
439 */
440async function answerReport(
441  $: EngineInterface,
442  key: string,
443  box: PromptBox,
444): Promise<PromptBox | undefined> {
445  if (key !== "8" && key !== "9") return undefined;
446  const r = await read($, report);
447  const words = await read($, added);
448  if (!r && words.length === 0) return undefined;
449  await dismiss($);
450  if (key === "9") return box;
451  if (words.length) await removeWords($, words);
452  return r && box.text === r.fixed ? r.original : box;
453}
454
455/** A report key pressed on the band itself. */
456async function answerFromBand($: EngineInterface, key: string) {
457  const box = await $.prompt.read();
458  const answered = await answerReport($, key, box);
459  if (answered && answered.text !== box.text) {
460    await $.prompt.fill({ text: answered.text, mode: "replace" });
461  }
462}
463
464/** Clears the fix-all report and the auto-added notice. */
465async function dismiss($: EngineInterface) {
466  if (await read($, report)) await update($, report, () => null);
467  if ((await read($, added)).length) await update($, added, () => []);
468}
469
470export const register: Register = (on, options) => {
471  config = readConfig(options);
472
473  on("prompt.edit", async ($, e, next) => {
474    const key = e.key && !e.key.ctrl && !e.key.meta ? e.key.key : undefined;
475    const box = { text: e.text, cursor: e.cursor };
476
477    if (key && (await read($, picker))) {
478      const picked = await pick($, key, box);
479      if (picked) {
480        scheduleCheck($, picked);
481        return picked;
482      }
483    }
484    await closePicker($);
485
486    const answered = key ? await answerReport($, key, box) : undefined;
487    if (answered) {
488      scheduleCheck($, answered);
489      return answered;
490    }
491    await dismiss($);
492
493    const edited = await next(e);
494    scheduleCheck($, edited);
495    return edited;
496  });
497
498  on("prompt.change", async ($, e, next) => {
499    if (e.origin.kind === "clear") {
500      ignored.clear();
501      unadded.clear();
502    }
503    // The picker's offsets and the report only hold for the draft they were made on.
504    if (!(e.origin.kind === "plugin" && e.origin.name === PLUGIN)) {
505      await closePicker($);
506      await dismiss($);
507    }
508    scheduleCheck($, e);
509    return next(e);
510  });
511
512  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
513    if (e.props.hasSurvey) return next(e);
514    const p = await read($, picker);
515    const done = await read($, report);
516    const words = await read($, added);
517    const busy = await read($, isBusy);
518    const n = await read($, count);
519    const draft = await read($, hasDraft);
520    const isQuiet = !p && !done && !busy && n === 0 && words.length === 0;
521    if (isQuiet && !draft) return next(e);
522
523    const { Box, Button, Text } = $.ui.resolve(e);
524    // Kept mounted whenever there's a draft: the key bound to ACTION reaches only a mounted Button.
525    const toggle = (
526      <Button key="spellfix" plain action={ACTION} onPress={() => togglePicker($)}>
527        {p ? "close" : busy ? "Checking spelling…" : n ? `${n} misspelled` : "check spelling"}
528        <Text dimColor> ctrl+q</Text>
529      </Button>
530    );
531    // With nothing flagged it stays mounted for ctrl+q (Haiku may still find a typo), drawn in no cells.
532    const hidden = (
533      <Box key="hidden" width={0} height={0} overflow="hidden">
534        {toggle}
535      </Box>
536    );
537    if (isQuiet) return hidden;
538
539    if (p) {
540      const slot = p.isAiDone ? (p.ai ?? "no idea") : p.wantsAi ? "waiting…" : "…";
541      // Ideally these would be marked inline in the prompt itself; mods can't decorate the
542      // composer's text yet, so the band lists them instead.
543      const summary = [...p.summary, ...(words.length ? [`added: ${words.join(", ")}`] : [])];
544      return (
545        <Box flexDirection="column">
546          <Text wrap="truncate-end">
547            <Text dimColor>…{p.before}</Text>
548            <Text weight="bold" underline color={p.isExtra ? "yellow" : "red"}>
549              {p.word}
550            </Text>
551            <Text dimColor>{p.after}…</Text>
552          </Text>
553          <Box flexWrap="wrap" columnGap={2}>
554            <Button key="ai" hotkey="1" plain onPress={() => pickFromBand($, "1")}>
555              {slot}
556              <Text dimColor> (Haiku)</Text>
557            </Button>
558            {p.suggestions.map((s, i) => (
559              <Button
560                key={`s${i}`}
561                hotkey={String(i + 2)}
562                plain
563                onPress={() => pickFromBand($, String(i + 2))}
564              >
565                {s}
566              </Button>
567            ))}
568            <Button key="skip" hotkey="s" plain label="skip" onPress={() => pickFromBand($, "s")} />
569            {config.personal && !p.isExtra ? (
570              <Button
571                key="add"
572                hotkey="d"
573                plain
574                label="add to dictionary"
575                onPress={() => pickFromBand($, "d")}
576              />
577            ) : null}
578            <Button
579              key="all"
580              hotkey="a"
581              plain
582              label={p.wantsAll ? "waiting…" : "fix all"}
583              onPress={() => pickFromBand($, "a")}
584            />
585            {toggle}
586          </Box>
587          {summary.length > 0 && (
588            <Box columnGap={2}>
589              <Text wrap="truncate-end" dimColor>
590                {summary.join(" · ")}
591              </Text>
592              {words.length > 0 && (
593                <Button
594                  key="undo"
595                  hotkey="8"
596                  plain
597                  label="undo add"
598                  onPress={() => pickFromBand($, "8")}
599                />
600              )}
601            </Box>
602          )}
603        </Box>
604      );
605    }
606
607    const notes: [string, string][] = [
608      ...(done ? [["fixed: ", done.changes.join(", ")] as [string, string]] : []),
609      ...(words.length ? [["added: ", words.join(", ")] as [string, string]] : []),
610    ];
611    return (
612      <Box columnGap={2}>
613        {notes.length > 0 && (
614          <>
615            <Text wrap="truncate-end">
616              {notes.flatMap(([label, list], i) => [
617                <Text key={`n${i}`} dimColor>
618                  {i ? ` · ${label}` : label}
619                </Text>,
620                list,
621              ])}
622            </Text>
623            <Button
624              key="undo"
625              hotkey="8"
626              plain
627              label="undo"
628              onPress={() => answerFromBand($, "8")}
629            />
630            <Button
631              key="dismiss"
632              hotkey="9"
633              plain
634              label="dismiss"
635              role="dismiss"
636              onPress={() => answerFromBand($, "9")}
637            />
638          </>
639        )}
640        {busy || n > 0 ? toggle : hidden}
641      </Box>
642    );
643  });
644};
645
hooks/core.ts 238 lines
1/**
2 * Spellfix's logic that needs no engine: which words to check, the model's
3 * prompt and reply, where its extras land, and the personal dictionary format.
4 * Plain ES with no Node or engine API, so pi's spellfix can run the same rules
5 * on the files both agents write.
6 */
7// Spelled out: pi type-checks this file under NodeNext, which needs the extension.
8import type { Extra, Miss } from "../types/index.d.ts";
9
10export type Verdict = { kind: "fix"; word: string } | { kind: "correct" } | { kind: "unsure" };
11
12/** A typo the model named by word and occurrence, before it is placed in the draft. */
13export type Found = { word: string; nth: number; fix: string };
14
15export const CHECK = `You check spelling in a draft message a developer is about to send to a coding assistant. The draft may be in English or Danish, or mix both; never translate.
16
17A spell checker flagged the words marked ⟦like this⟧. Give each distinct flagged word one line:
18<word text="teh" verdict="fix">the</word> when it is a typo: the word they meant, in the same language and casing.
19<word text="Jepsen" verdict="correct"/> when it is a real word, name, product or technical term spelled as intended.
20<word text="xqzt" verdict="unsure"/> when you can't tell.
21
22Then give one line per typo outside the marked words, which the spell checker can't see: a word joined to another by a slash ("dense/recieve"), or a correctly spelled word that is clearly not the one meant ("form" for "from"). "word" is the typo exactly as written in the draft, and nth counts that word's occurrences in the draft from 1:
23<extra word="recieve" nth="1">receive</extra>
24
25Rules:
26- Spelling only: never grammar, style, punctuation or wording.
27- Never touch code, file paths, URLs, identifiers, command names, flags, @mentions, /slash-commands, or anything inside backticks.
28- Reply with these lines and nothing else.`;
29
30const SKIPPED_SCRIPTS =
31  /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
32const WORD = /^\p{L}+(?:['’]\p{L}+)*$/u;
33
34/** hunspell's arguments after the executable: `-l` lists rejected words, `-a` is pipe mode. */
35export function hunspellArgs(mode: "-l" | "-a", dictionaries: string, personal: string): string[] {
36  return [mode, "-d", dictionaries, ...(personal ? ["-p", personal] : [])];
37}
38
39/** `text` with code blocks and spans blanked out, offsets kept. */
40export function maskCode(text: string): string {
41  return text.replace(/```[\s\S]*?(?:```|$)|`[^`\n]*`?/g, (m) => " ".repeat(m.length));
42}
43
44/** The words to check, by the built-in spellcheck's documented rules: code-like text is left out. */
45export function candidates(text: string): Miss[] {
46  const out: Miss[] = [];
47  for (const tok of maskCode(text).matchAll(/\S+/g)) {
48    // Commands, mentions, flags, URLs, paths, and anything with digits or underscores.
49    if (/^[/@-]|[/\\_~\d]|:\/\/|\.\p{L}/u.test(tok[0])) continue;
50    for (const m of tok[0].matchAll(/\p{L}+(?:['’]\p{L}+)*/gu)) {
51      const word = m[0];
52      if (word.length < 2 || /.\p{Lu}/u.test(word) || SKIPPED_SCRIPTS.test(word)) continue;
53      const start = tok.index + m.index;
54      out.push({ word, start, end: start + word.length });
55    }
56  }
57  return out;
58}
59
60/** hunspell `-a` input asking for `word`'s suggestions; `^` keeps the word from being read as a command. */
61export function suggestInput(word: string): string {
62  return `^${word}\n`;
63}
64
65/** The first `limit` suggestions in hunspell `-a` output; none when the word was accepted. */
66export function parseSuggestions(stdout: string, limit: number): string[] {
67  const line = stdout.split("\n").find((l) => l.startsWith("& "));
68  return line?.split(": ")[1]?.split(", ").filter(Boolean).slice(0, limit) ?? [];
69}
70
71/** hunspell `-a` input adding `words` to the personal dictionary (`*`) and saving it (`#`). */
72export function addInput(words: readonly string[]): string {
73  return `${words.map((w) => `*${w}\n`).join("")}#\n`;
74}
75
76/** The personal dictionary `text` without `words`; hunspell itself can't remove one. */
77export function withoutWords(text: string, words: readonly string[]): string {
78  // A line may carry affix flags after a slash (`word/S`).
79  return text
80    .split("\n")
81    .filter((l) => !words.includes(l.split("/")[0]!))
82    .join("\n");
83}
84
85/** The cased spellings worth trying for an all-lowercase `word`, capitalised first; none for any other word. */
86export function caseVariants(word: string): string[] {
87  if (word !== word.toLowerCase() || word === word.toUpperCase()) return [];
88  const [first = "", ...rest] = word;
89  return [`${first.toUpperCase()}${rest.join("")}`, word.toUpperCase()];
90}
91
92/**
93 * Turns a `correct` verdict on an all-lowercase word into a fix when one of its
94 * `caseVariants` is in `accepted` (the variants hunspell passes): `claude` or
95 * `i'm` is a casing slip, not a word for the personal dictionary. A model fix
96 * that differs only in case already stays a fix.
97 */
98export function recase(verdicts: Map<string, Verdict>, accepted: ReadonlySet<string>) {
99  for (const [word, v] of verdicts) {
100    if (v.kind !== "correct") continue;
101    const cased = caseVariants(word).find((c) => accepted.has(c));
102    if (cased) verdicts.set(word, { kind: "fix", word: cased });
103  }
104}
105
106/** The words `verdicts` calls correct, for the personal dictionary, minus those `skip` holds back. */
107export function autoAdds(
108  verdicts: ReadonlyMap<string, Verdict>,
109  skip: (word: string) => boolean,
110): string[] {
111  return [...verdicts].filter(([word, v]) => v.kind === "correct" && !skip(word)).map(([w]) => w);
112}
113
114/** `text` with every flagged word wrapped for the model, as CHECK describes. */
115export function mark(text: string, flagged: readonly Miss[]): string {
116  let marked = text;
117  for (const m of [...flagged].sort((a, b) => b.start - a.start)) {
118    marked = `${marked.slice(0, m.start)}⟦${m.word}⟧${marked.slice(m.end)}`;
119  }
120  return marked;
121}
122
123/** The user message for CHECK. */
124export function checkPrompt(text: string, flagged: readonly Miss[]): string {
125  return `<draft>\n${mark(text, flagged)}\n</draft>`;
126}
127
128/** Enough output tokens for a line per distinct flagged word plus a few extras. */
129export function replyBudget(distinct: number): number {
130  return Math.min(4096, 48 * (distinct + 10));
131}
132
133/**
134 * A reply to CHECK for `text`: a verdict per flagged word in `distinct` and the
135 * typos it found outside them, still by word and occurrence.
136 */
137export function parseReply(
138  reply: string,
139  text: string,
140  distinct: ReadonlySet<string>,
141): { verdicts: Map<string, Verdict>; found: Found[] } {
142  const verdicts = new Map<string, Verdict>();
143  const found: Found[] = [];
144  for (const m of reply.matchAll(
145    /<word text="([^"]+)" verdict="(fix|correct|unsure)"\s*(?:\/>|>([^<]*)<\/word>)/g,
146  )) {
147    const [, word = "", kind, fix = ""] = m;
148    // The model sometimes answers an extra as a verdict; taken as one when the draft has it once.
149    if (!distinct.has(word)) {
150      if (kind === "fix" && locate(text, word, 2) === undefined) found.push({ word, nth: 1, fix });
151      continue;
152    }
153    // A "fix" back to the same word says nothing either way.
154    const isFix = kind === "fix" && fix.trim() !== "" && fix.trim() !== word;
155    verdicts.set(
156      word,
157      isFix
158        ? { kind: "fix", word: fix.trim() }
159        : kind === "correct"
160          ? { kind }
161          : { kind: "unsure" },
162    );
163  }
164  for (const m of reply.matchAll(/<extra word="([^"]+)" nth="(\d+)">([^<]+)<\/extra>/g)) {
165    const [, word = "", nth = "", fix = ""] = m;
166    found.push({ word, nth: Number(nth), fix });
167  }
168  return { verdicts, found };
169}
170
171/**
172 * `word`'s nth whole-word occurrence in `text`, counting code too, as the model
173 * does; undefined when that lands in code, a URL, a command, a mention or a
174 * flag. Unlike `candidates`, a word in a slashed token (`dense/consice`) counts.
175 */
176export function locate(text: string, word: string, nth: number): Miss | undefined {
177  if (!WORD.test(word) || nth < 1) return undefined;
178  const at = [...text.matchAll(new RegExp(`(?<![\\p{L}'’])${word}(?![\\p{L}'’])`, "gu"))][nth - 1];
179  if (at === undefined) return undefined;
180  const end = at.index + word.length;
181  const tok = [...maskCode(text).matchAll(/\S+/g)].find(
182    (t) => t.index <= at.index && end <= t.index + t[0].length,
183  );
184  if (!tok || /^[/@-]|:\/\//.test(tok[0])) return undefined;
185  return { word, start: at.index, end };
186}
187
188/**
189 * `found` placed in `text`, the draft the model saw: one extra per offset, none
190 * on a flagged word, and none that would leave the word as it is.
191 */
192export function placeExtras(
193  text: string,
194  found: readonly Found[],
195  flagged: readonly Miss[],
196): Extra[] {
197  const extras: Extra[] = [];
198  for (const { word, nth, fix } of found) {
199    const at = locate(text, word, nth);
200    if (!at || fix.trim() === word || flagged.some((f) => f.start === at.start)) continue;
201    if (!extras.some((x) => x.start === at.start)) extras.push({ ...at, fix: fix.trim() });
202  }
203  return extras;
204}
205
206/** `m` past an edit that replaced start..end with `delta` more characters; undefined when the edit touched it. */
207export function shift<T extends Miss>(
208  m: T,
209  start: number,
210  end: number,
211  delta: number,
212): T | undefined {
213  if (m.end <= start) return m;
214  if (m.start >= end) return { ...m, start: m.start + delta, end: m.end + delta };
215  return undefined;
216}
217
218export function isExtra(m: Miss | Extra): m is Extra {
219  return "fix" in m;
220}
221
222/** `misses` with the `extras` that overlap none of them, in draft order. */
223export function withExtras(misses: readonly Miss[], extras: readonly Extra[]): (Miss | Extra)[] {
224  const found: (Miss | Extra)[] = [...misses];
225  for (const x of extras) {
226    if (!found.some((m) => m.start < x.end && x.start < m.end)) found.push(x);
227  }
228  return found.sort((a, b) => a.start - b.start);
229}
230
231export function nearest<T extends Miss>(found: readonly T[], cursor: number): T | undefined {
232  const distance = (m: Miss) => (cursor < m.start ? m.start - cursor : Math.max(0, cursor - m.end));
233  return found.reduce<T | undefined>(
234    (best, m) => (!best || distance(m) < distance(best) ? m : best),
235    undefined,
236  );
237}
238
types/index.d.ts 38 lines
1export type Miss = { word: string; start: number; end: number };
2
3/** A typo Haiku found that hunspell didn't flag: a real word (`form` for `from`) or one hunspell skipped (`dense/consice`). */
4export type Extra = Miss & { fix: string };
5
6export type Picker = Miss & {
7  before: string;
8  after: string;
9  suggestions: string[];
10  isExtra: boolean;
11  ai: string | null;
12  isAiDone: boolean;
13  wantsAi: boolean;
14  wantsAll: boolean;
15  /** Every fix the batch has for the draft, as `old→new`; `?` marks an extra. */
16  summary: string[];
17};
18
19/** `original` is the draft and cursor fix-all started from. */
20export type Report = {
21  original: { text: string; cursor: number };
22  fixed: string;
23  changes: string[];
24};
25
26declare module "claude-code" {
27  interface PluginState {
28    spellfix: {
29      count: number;
30      hasDraft: boolean;
31      picker: Picker | null;
32      report: Report | null;
33      added: string[];
34      isBusy: boolean;
35    };
36  }
37}
38