Fixes what dictation mishears ("cloud code" → Claude Code) before Claude reads your prompt, and shows each fix in your prompt row: red struck, green added.

For people who dictate their prompts (Wispr Flow, Superwhisper, macOS dictation). Before Claude reads a prompt, jr-dictation fixes what speech-to-text commonly gets wrong in developer talk, and your prompt row shows each fix:
> open the cloud mdCLAUDE.md in cloud codeClaude Code and check the package jsonpackage.json
✎ dictation · 3 corrections · /jrdict add to teach a word
(red struck: what was heard; green: what Claude reads)
cloud code → Claude Code, type script → TypeScript, package json → package.json, dot env → .env, local host → localhost, c l i → CLI…/jrdict add, or list them in /config.| Command | |
|---|---|
/jrdict add acme corp => ACME | Teach a correction (kept across sessions) |
/jrdict remove acme corp | Forget it |
/jrdict list | Show your taught words |
/config)| Option | Default | |
|---|---|---|
| Your corrections | empty | heard => right pairs separated by ; |
| Built-in corrections | on | Turn the built-in list off to use only yours |
Slash commands are never rewritten.
/plugin install jr-dictation --marketplace jrlprost/claude-code-mods
Rewrites the text of the prompts you send. Keeps taught words in the plugin's own store. Reads no files, runs nothing, sends nothing over the network.
hooks/register.tsx 116 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DEFAULT_DICTIONARY, literalPattern, parsePairs } from './dictionary'
5import { normalize } from './normalize'
6import { paint, GRADIENTS } from './jr/gradient'
7
8const edits = atom({ plugin: 'jr-dictation', key: 'edits' } as const, [])
9const LEARNED = 'learned'
10const commands = { isRegistered: false }
11
12/** Registers /jrdict once: at session start, or first thing after a mid-session install. */
13async function ensureCommand($: EngineInterface) {
14 if (commands.isRegistered) return
15 commands.isRegistered = true
16 await $.command.register({ name: 'jrdict', description: 'Dictation fixes: /jrdict add <heard> => <right>, /jrdict remove <heard>, /jrdict list' })
17}
18
19/** Words taught with /jrdict add, as heard → right. */
20async function learned($: EngineInterface): Promise<Record<string, string>> {
21 return ((await $.store.get(LEARNED)) as Record<string, string> | undefined) ?? {}
22}
23
24/** Learned words first, then the settings list, then the built-in dictionary. */
25async function dictionaryOf($: EngineInterface, extra: string, useDefaults: boolean) {
26 const taught = Object.entries(await learned($)).map(([heard, right]) => [literalPattern(heard), right] as const)
27 const configured = parsePairs(extra).map(([heard, right]) => [literalPattern(heard), right] as const)
28 return [...taught, ...configured, ...(useDefaults ? DEFAULT_DICTIONARY : [])]
29}
30
31/** `/jrdict add heard => right`, `/jrdict remove heard`, `/jrdict list`. */
32async function runCommand($: EngineInterface, args: string) {
33 const [verb = 'list', ...rest] = args.trim().split(/\s+/)
34 const words = await learned($)
35 if (verb === 'add') {
36 const pair = parsePairs(rest.join(' '))[0]
37 if (!pair) return 'jr dictation · usage: /jrdict add <what you said> => <what you meant>'
38 await $.store.set(LEARNED, { ...words, [pair[0].toLowerCase()]: pair[1] })
39 return `jr dictation · learned: "${pair[0]}" → ${pair[1]}`
40 }
41 if (verb === 'remove') {
42 const heard = rest.join(' ').trim().toLowerCase()
43 if (!(heard in words)) return `jr dictation · "${heard}" is not a learned word`
44 const { [heard]: _gone, ...kept } = words
45 await $.store.set(LEARNED, kept)
46 return `jr dictation · forgot "${heard}"`
47 }
48 const lines = Object.entries(words).map(([heard, right]) => ` "${heard}" → ${right}`)
49 return lines.length
50 ? `jr dictation · learned words:\n${lines.join('\n')}\n(plus ${DEFAULT_DICTIONARY.length} built-in corrections)`
51 : `jr dictation · no learned words yet; ${DEFAULT_DICTIONARY.length} built-in corrections are on. Teach one with /jrdict add <heard> => <right>`
52}
53
54export const register: Register = (on, options) => {
55 const extra = String(options.words ?? '')
56 const useDefaults = options.builtIn !== false
57
58 on('session.start', async ($, e, next) => {
59 const started = await next(e)
60 await ensureCommand($)
61 return started
62 })
63
64 // Installed mid-session: no session.start, so register on the first draw above the prompt.
65 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
66 if (!commands.isRegistered) {
67 $.clock.after(0, () => {
68 void ensureCommand($)
69 })
70 }
71 return next(e)
72 })
73
74 on('command.run', { command: 'jrdict' }, async ($, e) => ({ text: await runCommand($, e.args) }))
75
76 on('prompt.submit', async ($, e, next) => {
77 await ensureCommand($)
78 if (e.command || e.isMeta) return next(e)
79 const edit = normalize(e.text, await dictionaryOf($, extra, useDefaults))
80 if (!edit) return next(e)
81 await update($, edits, list => [...list, edit].slice(-50))
82 return next({ ...e, text: edit.corrected })
83 })
84
85 // The prompt row: what was removed struck in red, what was added in green.
86 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'composer' } } }, async ($, e, next) => {
87 const edit = (await read($, edits)).findLast(one => one.corrected === e.props.text)
88 if (!edit) return next(e)
89 const { Box, Text } = $.ui.resolve(e)
90 const count = edit.segments.filter(s => s.kind === 'added').length
91 return (
92 <Box flexDirection="column">
93 <Text>
94 <Text dimColor>{'> '}</Text>
95 {edit.segments.map(s =>
96 s.kind === 'removed' ? (
97 <Text color="#ef4565" strikethrough>{s.text}</Text>
98 ) : s.kind === 'added' ? (
99 <Text color="#2cb67d">{s.text}</Text>
100 ) : (
101 <Text>{s.text}</Text>
102 ),
103 )}
104 </Text>
105 <Text>
106 <Text dimColor>{' ✎ '}</Text>
107 {paint('dictation', GRADIENTS.brand).map(({ char, color }) => (
108 <Text color={color}>{char}</Text>
109 ))}
110 <Text dimColor>{` · ${count} correction${count > 1 ? 's' : ''} · /jrdict add to teach a word`}</Text>
111 </Text>
112 </Box>
113 )
114 })
115}
116hooks/dictionary.ts 55 lines1// Built-in corrections for what dictation apps (Wispr Flow, Superwhisper, macOS dictation)
2// commonly mishear in developer prompts. Each entry: [pattern (regex source, case-insensitive), fix].
3// A pattern matches whole words only. Spelled-out forms are matched, not the right spelling,
4// so a correct word is never rewritten.
5export const DEFAULT_DICTIONARY: ReadonlyArray<readonly [string, string]> = [
6 ['cloud ?\\.? ?md|clod ?md|claude ?dot ?md', 'CLAUDE.md'],
7 ['agents ?dot ?md|agent ?md', 'AGENTS.md'],
8 ['cloud code|clod code|clawed code|claude coat', 'Claude Code'],
9 ['clawed|glaude', 'Claude'],
10 ['anthropique|antropic|anthropik', 'Anthropic'],
11 ['m c p s|mcps', 'MCPs'],
12 ['m c p', 'MCP'],
13 ['type script', 'TypeScript'],
14 ['java script', 'JavaScript'],
15 ['git hub', 'GitHub'],
16 ['git lab', 'GitLab'],
17 ['read me', 'README'],
18 ['package dot json|package json', 'package.json'],
19 ['dot env', '.env'],
20 ['local host', 'localhost'],
21 ['next dot js|next js', 'Next.js'],
22 ['node dot js|node js', 'Node.js'],
23 ['tail wind', 'Tailwind'],
24 ['super base', 'Supabase'],
25 ['versel|ver cell', 'Vercel'],
26 ['post gres|postgre sql', 'Postgres'],
27 ['o auth', 'OAuth'],
28 ['reg ex', 'regex'],
29 ['jason file', 'JSON file'],
30 ['yamel', 'YAML'],
31 ['dock er', 'Docker'],
32 ['kuber netties|cooper netties', 'Kubernetes'],
33 ['play right', 'Playwright'],
34 ['pnpm install|p n p m', 'pnpm'],
35 ['n p m', 'npm'],
36 ['c l i', 'CLI'],
37 ['a p i', 'API'],
38 ['u r l', 'URL'],
39 ['s q l', 'SQL'],
40]
41
42/** "heard => right; heard2 => right2" from the settings field. */
43export const parsePairs = (text: string): [string, string][] =>
44 text
45 .split(/[;\n]/)
46 .map(pair => pair.split('=>').map(part => part.trim()))
47 .filter((parts): parts is [string, string] => parts.length === 2 && parts[0] !== '' && parts[1] !== '')
48
49/** A literal heard phrase as a pattern: regex characters escaped, spaces optional. */
50export const literalPattern = (heard: string) =>
51 heard
52 .trim()
53 .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
54 .replace(/\s+/g, ' ?')
55hooks/normalize.ts 41 lines1import type { JrDictationSegment } from '../types'
2
3// Unicode word edges: \b does not see accented letters.
4const bounded = (source: string) => `(?<![\\p{L}\\p{N}_])(?:${source})(?![\\p{L}\\p{N}_])`
5
6/**
7 * Rewrites every dictionary match in one pass, left to right, and keeps the pieces so the
8 * prompt row can show what was removed and what was added. Null when nothing changed.
9 */
10export const normalize = (text: string, dictionary: ReadonlyArray<readonly [string, string]>) => {
11 const valid = dictionary.filter(([pattern]) => {
12 try {
13 new RegExp(bounded(pattern), 'iu')
14 return true
15 } catch {
16 return false
17 }
18 })
19 if (valid.length === 0) return null
20 const rules = valid.map(([pattern, replacement]) => ({ exact: new RegExp(`^${bounded(pattern)}$`, 'iu'), replacement }))
21 const any = new RegExp(valid.map(([pattern]) => bounded(pattern)).join('|'), 'giu')
22
23 const segments: JrDictationSegment[] = []
24 let corrected = ''
25 let cursor = 0
26 for (const match of text.matchAll(any)) {
27 const found = match[0]
28 const replacement = rules.find(rule => rule.exact.test(found))?.replacement
29 if (replacement === undefined || replacement === found) continue
30 const before = text.slice(cursor, match.index)
31 if (before) segments.push({ kind: 'same', text: before })
32 segments.push({ kind: 'removed', text: found }, { kind: 'added', text: replacement })
33 corrected += before + replacement
34 cursor = match.index + found.length
35 }
36 if (cursor === 0) return null
37 const rest = text.slice(cursor)
38 if (rest) segments.push({ kind: 'same', text: rest })
39 return { corrected: corrected + rest, segments }
40}
41hooks/jr/gradient.ts 75 lines1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7 const n = Number.parseInt(hex.replace('#', ''), 16)
8 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12 `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15 // The jr signature: violet to pink to amber.
16 brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17 // Calm to alarm, for gauges where full is bad.
18 heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19 // Cool, for neutral figures.
20 ice: ['#3da9fc', '#7f5af0'],
21 // Money and growth.
22 money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29 if (stops.length === 1) return stops[0]!
30 const clamped = Math.min(1, Math.max(0, t))
31 const span = clamped * (stops.length - 1)
32 const i = Math.min(stops.length - 2, Math.floor(span))
33 const a = hexToRgb(stops[i]!)
34 const b = hexToRgb(stops[i + 1]!)
35 const local = span - i
36 return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41 const chars = [...text]
42 return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53 const filled = Math.min(1, Math.max(0, ratio)) * width
54 const full = Math.floor(filled)
55 const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56 const cells: { char: string; color: string; empty?: true }[] = []
57 for (let i = 0; i < width; i++) {
58 const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59 if (i < full) cells.push({ char: '█', color })
60 else if (i === full && partial) cells.push({ char: partial, color })
61 else cells.push({ char: '·', color, empty: true })
62 }
63 return cells
64}
65
66export const formatTokens = (n: number) =>
67 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70 const minutes = Math.max(0, Math.round(ms / 60_000))
71 const h = Math.floor(minutes / 60)
72 const m = minutes % 60
73 return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75types/index.d.ts 9 lines1export type JrDictationSegment = { kind: 'same' | 'removed' | 'added'; text: string }
2export type JrDictationEdit = { corrected: string; segments: JrDictationSegment[] }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'jr-dictation': { edits: JrDictationEdit[] }
7 }
8}
9