SLOPSHOPPER

jr-dictation

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.

newbandrowscommandprompttimer
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-dictation
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-dictation
› fix the failing auth test and add an audit log call ⏺ 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 › /jrdict ⎿ jr-dictation: jr dictation · no learned words yet; 34 built-in corrections are on. Teach one with /jrdict add <heard> => <ri ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

jr-dictation

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)

  • About 35 built-in corrections: cloud code → Claude Code, type script → TypeScript, package json → package.json, dot env → .env, local host → localhost, c l i → CLI…
  • Only spelled-out mishears are matched, whole words only (accents included), so correct text is never touched.
  • Your own words: teach them once with /jrdict add, or list them in /config.

Commands

Command
/jrdict add acme corp => ACMETeach a correction (kept across sessions)
/jrdict remove acme corpForget it
/jrdict listShow your taught words

Settings (/config)

OptionDefault
Your correctionsemptyheard => right pairs separated by ;
Built-in correctionsonTurn the built-in list off to use only yours

Slash commands are never rewritten.

Install

/plugin install jr-dictation --marketplace jrlprost/claude-code-mods

Access

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.

Source 5 files
hooks/register.tsx 116 lines
1import { 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}
116
hooks/dictionary.ts 55 lines
1// 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, ' ?')
55
hooks/normalize.ts 41 lines
1import 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}
41
hooks/jr/gradient.ts 75 lines
1// 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}
75
types/index.d.ts 9 lines
1export 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