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

Personal NixOS configuration using flakes, home-manager, and agenix for secret management.
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
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.
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.
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.
A Nix development shell is available with useful tools for working on this configuration:
# Enter the development shell
nix develop
Code is formatted with treefmt-nix; the formatters are configured in formatter.nix.
just fmt
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.
hooks/register.tsx 645 lines1import { 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};
645hooks/core.ts 238 lines1/**
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}
238types/index.d.ts 38 lines1export 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