SLOPSHOPPER

Typing Speed

A live typing speedometer above the prompt, offline spell check in your system language, a result card after each prompt (WPM, CPM, accuracy, typos, personal…

newpanebandcommandpromptprocess
★ 4v0.2.0MITupdated 2026-10-04borabiricik/claude-mods/plugins/typing-speed
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · typing-speed
│ ┃ ⌨ Typing stats ✕ › fix the failing auth test and add an audit log call │ ┃ ⌨ Nothing measured yet │ ┃ Type a prompt of 10 or more characters and ● typing-speed: spell check unavailable: ENOENT: no such file /plugin │ ┃ press Enter. ⏺ 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 │ │ › /typing │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ⌨ Typing stats
⌨ Nothing measured yet Type a prompt of 10 or more characters and press Enter.
README

Typing Speed

A Claude Code mod that measures how fast you type your prompts.

What you get

While you type, a live speedometer above the prompt:

⣾ 🏃  58 WPM ▰▰▰▰▰▰▱▱▱▱▱▱ ▂▃▅▆▅▇▆ Quick  ⏱ 12s  ✎ 142  🎯 98%

The icon, colour and label follow your speed: 🐢 Warming up, 🚶 Steady, 🏃 Quick, 🚀 Rocket, 🔥 On fire, ⚡ Lightning.

After you press Enter, a result card that counts up to your speed and celebrates a new personal best:

╭──────────────────────────────────────────────────────────╮
│ 🚀 72 WPM  ⌨  360 CPM  🎯 96%  ⏱ 23s  📝 27 words          │
│ ✦ New personal best! 68 → 72 WPM ✧                        │
╰──────────────────────────────────────────────────────────╯

Spell check, offline, in your system language. A misspelled word is underlined in red in the prompt once you have finished it, and when you pause, the band suggests fixes:

✗  yanlız → yalnız   recieve → receive

The card counts the typos of each prompt (✗ 2 typos or ✓ no typos).

/typing opens a stats pane: your average and best, accuracy, words and time typed, a bar chart of the last 7 days and a sparkline of your recent prompts. /typing reset clears everything.

How it measures

  • WPM is the standard measure: typed characters ÷ 5 ÷ minutes.
  • Pastes are not typing. An insertion of 8 or more characters at once (a paste, a history recall) is shown as pasted and left out.
  • Thinking time is not typing. A pause of more than 5 seconds between keys is left out of the clock.
  • Accuracy is 1 − deleted ÷ typed characters.
  • Slash command names are not typing. In /btw why is this slow? only the arguments are measured; a bare command such as /clear is not measured at all.
  • Prompts shorter than 10 typed characters and prompts you did not type are not measured.

Spell check

  • Languages come from the system: macOS's preferred languages (defaults read -g AppleLanguages), Windows's display and input languages (PowerShell), and LANGUAGE, LC_ALL, LC_MESSAGES and LANG everywhere. English and Turkish dictionaries ship with the mod; English always loads, since prompts to a coding agent are full of it. A system language without a dictionary falls back to English.
  • Left out: code spans and fences, paths, URLs, flags, file names, camelCase and snake_case identifiers, acronyms, words with digits, @mentions, slash command names, and the word you are still typing. A capitalized word followed by an apostrophe (Claude'un, İstanbul'da) counts as a name.
  • /typing spell off turns it off and /typing spell on back on; the choice is kept. /typing spell says what it is checking with.
  • /typing spell add <word> teaches it a word, /typing spell remove <word> forgets one.
  • It runs on nspell with the Hunspell dictionaries of wooorm/dictionaries, all bundled: no network and no service. The dictionaries load in the background in about a second when a session starts.
  • The Turkish dictionary does not know every suffix chain, so some long correct words (kitaplıklarımızdan) are underlined; add the ones you use with /typing spell add.

Stats are kept in the mod's own store: all time, the last 30 days and your last 30 prompts.

Install

Requires Claude Code v2.1.287 or later.

claude plugin marketplace add borabiricik/claude-mods
claude plugin install typing-speed@borabiricik-mods

Or inside a session: /plugin install typing-speed --marketplace borabiricik/claude-mods. Run /reload-plugins if a session was already open.

The band and pane draw in the terminal and in the Desktop app's Code tab.

What it can reach

A mod runs with your permissions, so check before you install one. This mod reads the clock, keeps its numbers in its own store, registers /typing and draws in the interface. For spell check it also reads its own dictionary files, reads the OS, LANGUAGE, LC_ALL, LC_MESSAGES and LANG environment variables, and runs one command when a session starts to learn the system languages (defaults read -g AppleLanguages, or powershell.exe on Windows). With spell check off it does none of that. It writes no files and does not use the network. To verify it yourself:

claude plugin validate ./plugins/typing-speed

Develop

claude --plugin-dir ./plugins/typing-speed   # load it for one session
claude plugin test ./plugins/typing-speed    # run the tests
node plugins/typing-speed/scripts/vendor-spell.mjs   # re-vendor nspell and the dictionaries

A hooks module cannot import npm packages and each module file is capped at 1 MiB, so scripts/vendor-spell.mjs bundles nspell into hooks/vendor/nspell.js and cuts each dictionary into chunks under dictionaries/, which the mod reads at run time. The dictionaries keep their own licenses (dictionaries/*/license).

Source 7 files
hooks/register.tsx 575 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptEditResult, Register, Timer } from 'claude-code'
3
4import type { PromptStats, Totals } from '../types'
5import {
6  addToTotals,
7  applyEdit,
8  argumentsOf,
9  averageWpm,
10  dayKey,
11  emptyDraft,
12  emptyTotals,
13  formatDuration,
14  formatNumber,
15  formatPrompt,
16  formatTotals,
17  isCommandNameEdit,
18  recordDay,
19  summarize,
20  wpmOf,
21} from './stats'
22import { DICTIONARY_CHUNKS } from './dictionaries'
23import type { Language } from './dictionaries'
24import { SpellChecker, languagesOf } from './spell'
25import type { Speller, Word } from './spell'
26import nspell from './vendor/nspell.js'
27import {
28  GAUGE_MAX_WPM,
29  RAINBOW,
30  SPARKLES,
31  SPINNER,
32  burstWpm,
33  easeOut,
34  gauge,
35  lastDays,
36  pick,
37  sparkline,
38  tierOf,
39  weekdayOf,
40} from './ui'
41
42const session = atom({ plugin: 'typing-speed', key: 'session' } as const, emptyTotals())
43const last = atom({ plugin: 'typing-speed', key: 'last' } as const, null)
44
45const ALL_TIME_KEY = 'allTime'
46const DAYS_KEY = 'days'
47const RECENT_KEY = 'recent'
48const SPELL_KEY = 'spellCheck'
49const PERSONAL_WORDS_KEY = 'personalWords'
50const PANE = 'typing-stats'
51
52const TICK_MS = 100
53const TYPING_FADE_MS = 2_500
54const CARD_DIM_MS = 6_000
55const CARD_HIDE_MS = 9_000
56const COUNT_UP_MS = 700
57const SAMPLE_EVERY_TICKS = 3
58const SAMPLES_KEPT = 24
59const RECENT_KEPT = 30
60const MIN_CHARS_FOR_HUD = 3
61const HINT_DELAY_MS = 400
62const HINT_STEP_MS = 30
63const HINTS_SHOWN = 3
64const TYPO_COLOR = '#f7768e'
65const FIX_COLOR = '#9ece6a'
66const SPELL_USAGE = 'Usage: /typing spell [on | off | add <word> | remove <word>]'
67
68type Book = { allTime: Totals; days: Record<string, Totals>; recent: number[] }
69type Card = {
70  stats: PromptStats
71  isNewBest: boolean
72  previousBest: number
73  average: number
74  shownAt: number
75  typos: number | null
76}
77type Hint = { word: string; fix: string | undefined }
78
79const emptyBook = (): Book => ({ allTime: emptyTotals(), days: {}, recent: [] })
80
81async function loadBook($: EngineInterface): Promise<Book> {
82  const [allTime, days, recent] = await Promise.all([
83    $.store.get(ALL_TIME_KEY),
84    $.store.get(DAYS_KEY),
85    $.store.get(RECENT_KEY),
86  ])
87  return {
88    allTime: { ...emptyTotals(), ...(allTime as Partial<Totals> | undefined) },
89    days: (days as Record<string, Totals> | undefined) ?? {},
90    recent: (recent as number[] | undefined) ?? [],
91  }
92}
93
94let book = emptyBook()
95let isBookLoaded = false
96let draft = emptyDraft()
97let keyTimes: number[] = []
98let samples: number[] = []
99let keystrokes = 0
100let lastKeyAt = 0
101let card: Card | null = null
102let ticker: Timer | null = null
103let ticks = 0
104// Spell check: null until the setting is read from the store.
105let isSpellOn: boolean | null = null
106let speller: SpellChecker | null = null
107let spellLoad: Promise<SpellChecker | null> | null = null
108let isSpellBroken = false
109let typos: Word[] = []
110let hints: Hint[] = []
111let hintTimer: Timer | null = null
112
113const isTyping = (now: number) => draft.typedChars >= MIN_CHARS_FOR_HUD && now - lastKeyAt < TYPING_FADE_MS
114const isCardShown = (now: number) => card !== null && now - card.shownAt < CARD_HIDE_MS
115
116async function tick($: EngineInterface) {
117  const now = await $.clock.now()
118  ticks += 1
119  if (isTyping(now) && ticks % SAMPLE_EVERY_TICKS === 0) {
120    samples = [...samples, burstWpm(keyTimes, now)].slice(-SAMPLES_KEPT)
121  }
122  if (!isTyping(now) && !isCardShown(now)) {
123    ticker?.cancel()
124    ticker = null
125  }
126  $.ui.invalidate('ui.render')
127}
128
129function animate($: EngineInterface) {
130  $.ui.invalidate('ui.render')
131  ticker ??= $.clock.every(TICK_MS, () => void tick($))
132}
133
134const WINDOWS_LANGUAGES =
135  '[Globalization.CultureInfo]::CurrentUICulture.Name; try { (Get-WinUserLanguageList).LanguageTag } catch {}'
136const SYSTEM_QUERY_TIMEOUT_MS = 5_000
137
138// The system's preferred languages: macOS and Windows keep them outside the environment, Linux in it.
139async function systemLanguages($: EngineInterface): Promise<Language[]> {
140  const [os, language, all, messages, lang] = await Promise.all([
141    $.env.get('OS'),
142    $.env.get('LANGUAGE'),
143    $.env.get('LC_ALL'),
144    $.env.get('LC_MESSAGES'),
145    $.env.get('LANG'),
146  ])
147  const argv =
148    os === 'Windows_NT'
149      ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_LANGUAGES]
150      : ['defaults', 'read', '-g', 'AppleLanguages']
151  const system = await $.process.run(argv, { timeoutMs: SYSTEM_QUERY_TIMEOUT_MS }).then(
152    r => (r.exitCode === 0 ? r.stdout : ''),
153    () => '',
154  )
155
156  return languagesOf([system, language, all, messages, lang].filter((v): v is string => Boolean(v)))
157}
158
159// A read is capped at 4 MiB, so a dictionary ships in chunks. They are joined and parsed once:
160// nspell's dictionary() rebuilds the compound rules from their compiled form on every later call,
161// and a second call leaves rules that accept every word.
162async function loadSpeller($: EngineInterface, language: Language): Promise<Speller> {
163  const dir = `${$.plugin.root}/dictionaries/${language}`
164  const aff = await $.fs.read(`${dir}/index.aff`)
165  const chunks: string[] = []
166  for (let i = 0; i < DICTIONARY_CHUNKS[language]; i++) {
167    const chunk = await $.fs.read(`${dir}/${i}.dic`)
168    chunks.push(i === 0 ? chunk : chunk.slice(chunk.indexOf('\n') + 1))
169  }
170  return nspell(aff, chunks.join(''))
171}
172
173async function loadSpellChecker($: EngineInterface, personal: readonly string[]): Promise<SpellChecker> {
174  const languages = await systemLanguages($)
175  const spellers: Speller[] = []
176  for (const language of languages) spellers.push(await loadSpeller($, language))
177  return new SpellChecker(spellers, languages, personal)
178}
179
180function resetTypos() {
181  typos = []
182  hints = []
183  hintTimer?.cancel()
184  hintTimer = null
185}
186
187function resetDraft() {
188  draft = emptyDraft()
189  keyTimes = []
190  samples = []
191  lastKeyAt = 0
192  resetTypos()
193}
194
195async function spellSetting($: EngineInterface): Promise<boolean> {
196  isSpellOn ??= (await $.store.get(SPELL_KEY)) !== false
197  return isSpellOn
198}
199
200// Loads once, in the background; a failed load is not retried until spell check is turned on again.
201function startSpellCheck($: EngineInterface): Promise<SpellChecker | null> {
202  spellLoad ??= (async () => {
203    if (!(await spellSetting($))) return null
204    const personal = ((await $.store.get(PERSONAL_WORDS_KEY)) as string[] | undefined) ?? []
205    const loaded = await loadSpellChecker($, personal)
206    if (isSpellOn) speller = loaded
207    return speller
208  })().catch((error: unknown) => {
209    isSpellBroken = true
210    try {
211      $.ui.log(`spell check unavailable: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
212    } catch {}
213    return null
214  })
215  return spellLoad
216}
217
218// Suggestions cost up to a few hundred milliseconds each, so they wait for a pause and are worked
219// out one word per timer step: a key pressed meanwhile is answered between steps.
220function scheduleHints($: EngineInterface) {
221  hintTimer?.cancel()
222  hintTimer = null
223  const checker = speller
224  const recent = typos.slice(-HINTS_SHOWN)
225  if (!checker || recent.length === 0) {
226    hints = []
227    return
228  }
229  const step = () => {
230    const pending = recent.find(t => !checker.hasSuggested(t.word))
231    if (pending) {
232      checker.suggest(pending.word)
233      hintTimer = $.clock.after(HINT_STEP_MS, step)
234      return
235    }
236    hintTimer = null
237    hints = recent.map(t => ({ word: t.word, fix: checker.suggest(t.word)[0] }))
238    $.ui.invalidate('ui.render')
239  }
240  hintTimer = $.clock.after(HINT_DELAY_MS, step)
241}
242
243function markTypos($: EngineInterface, box: PromptEditResult): PromptEditResult {
244  if (!speller) {
245    void startSpellCheck($)
246    return box
247  }
248  const before = typos.length
249  typos = speller.misspelled(box.text, box.cursor)
250  scheduleHints($)
251  if (typos.length !== before) $.ui.invalidate('ui.render')
252  if (typos.length === 0) return box
253
254  const marks = typos.map(t => ({ start: t.start, end: t.end, color: TYPO_COLOR, underline: true }))
255  return { ...box, decorations: [...(box.decorations ?? []), ...marks] }
256}
257
258const typoCountOf = (text: string) => (speller ? speller.misspelled(argumentsOf(text), -1).length : null)
259
260function spellSummary(): string {
261  if (isSpellOn === false) return 'off · /typing spell on turns it on'
262  if (!speller) return isSpellBroken ? 'on, but its dictionaries did not load; the debug log says why' : 'on · loading dictionaries'
263  const own = speller.personalWords.length
264  return `on · ${speller.languages.join(', ')}${own > 0 ? ` · ${own} words of your own` : ''} · /typing spell off turns it off`
265}
266
267async function spellCommand($: EngineInterface, args: readonly string[]): Promise<string> {
268  const [action, word, ...extra] = args
269  const isWordAction = action === 'add' || action === 'remove'
270  if (extra.length > 0 || (isWordAction ? !word : word !== undefined)) return SPELL_USAGE
271  if (action !== undefined && action !== 'on' && action !== 'off' && !isWordAction) return SPELL_USAGE
272
273  if (action === 'off') {
274    isSpellOn = false
275    speller = null
276    spellLoad = null
277    resetTypos()
278    await $.store.set(SPELL_KEY, false)
279    $.ui.invalidate('ui.render')
280    return '✎ Spell check off. /typing spell on turns it back on.'
281  }
282  if (action === 'on' && (!(await spellSetting($)) || isSpellBroken)) {
283    isSpellOn = true
284    isSpellBroken = false
285    spellLoad = null
286    await $.store.set(SPELL_KEY, true)
287  }
288  if (!(await spellSetting($))) return '✎ Spell check is off. /typing spell on turns it on.'
289
290  const checker = await startSpellCheck($)
291  if (!checker) return '✎ Spell check could not load its dictionaries; the debug log says why.'
292  if (isWordAction) {
293    if (action === 'add') checker.learn(word!)
294    else checker.forget(word!)
295    await $.store.set(PERSONAL_WORDS_KEY, checker.personalWords)
296    return action === 'add' ? `✎ "${word}" is now in your dictionary.` : `✎ "${word}" is out of your dictionary.`
297  }
298  return `✎ Spell check ${spellSummary()}`
299}
300
301async function ensureBook($: EngineInterface) {
302  if (isBookLoaded) return
303  book = await loadBook($)
304  isBookLoaded = true
305}
306
307async function record($: EngineInterface, stats: PromptStats, now: number, typos: number | null): Promise<Card> {
308  await ensureBook($)
309  const previousBest = book.allTime.bestWpm
310  book = {
311    allTime: addToTotals(book.allTime, stats),
312    days: recordDay(book.days, dayKey(now), stats),
313    recent: [...book.recent, stats.wpm].slice(-RECENT_KEPT),
314  }
315  await Promise.all([
316    $.store.set(ALL_TIME_KEY, book.allTime),
317    $.store.set(DAYS_KEY, book.days),
318    $.store.set(RECENT_KEY, book.recent),
319    update($, session, t => addToTotals(t, stats)),
320    update($, last, () => stats),
321  ])
322
323  return { stats, isNewBest: stats.wpm > previousBest, previousBest, average: averageWpm(book.allTime), shownAt: now, typos }
324}
325
326export const register: Register = on => {
327  on('session.start', async ($, e, next) => {
328    await $.command.register({
329      name: 'typing',
330      description: 'Open your typing stats; reset them; turn spell check on or off, or teach it a word',
331      argumentHint: '[reset | spell on|off|add <word>|remove <word>]',
332    })
333    await ensureBook($)
334    void startSpellCheck($)
335
336    return next(e)
337  })
338
339  on('prompt.edit', async ($, e, next) => {
340    const [now, box] = await Promise.all([$.clock.now(), next(e)])
341    if (box.text === '') {
342      resetDraft()
343      $.ui.invalidate('ui.render')
344      return box
345    }
346    const marked = markTypos($, box)
347    if (isCommandNameEdit(box.text, e.start)) return marked
348
349    const before = draft
350    draft = applyEdit(draft, e, now)
351    const isTypedKey = draft.typedChars > before.typedChars
352    if (isTypedKey || draft.deletedChars > before.deletedChars) {
353      if (before.typedChars === 0) samples = []
354      if (isTypedKey) keyTimes = [...keyTimes.filter(t => now - t <= 3_000), now]
355      keystrokes += 1
356      lastKeyAt = now
357      card = null
358      animate($)
359    }
360
361    return marked
362  })
363
364  on('prompt.submit', async ($, e, next) => {
365    const finished = draft
366    resetDraft()
367    const entered = await next(e)
368
369    const stats = e.origin.kind === 'composer' ? summarize(finished, argumentsOf(e.text)) : null
370    if (stats) card = await record($, stats, await $.clock.now(), typoCountOf(e.text))
371    animate($)
372
373    return entered
374  })
375
376  on('command.run', { command: 'typing' }, async ($, e) => {
377    const [subcommand, ...args] = e.args.trim().split(/\s+/)
378    if (subcommand === 'spell') return { text: await spellCommand($, args) }
379    if (e.args.trim() === 'reset') {
380      book = emptyBook()
381      isBookLoaded = true
382      card = null
383      await Promise.all([
384        $.store.delete(ALL_TIME_KEY),
385        $.store.delete(DAYS_KEY),
386        $.store.delete(RECENT_KEY),
387        update($, session, () => emptyTotals()),
388        update($, last, () => null),
389      ])
390      $.ui.invalidate('ui.render')
391
392      return { text: '🧹 Typing stats cleared.' }
393    }
394
395    await ensureBook($)
396    const opened = await $.ui.open({ id: PANE, title: '⌨ Typing stats' })
397    if (opened.isPlaced) return {}
398
399    const [lastStats, sessionTotals] = await Promise.all([read($, last), read($, session)])
400    return {
401      text: [
402        `Last prompt  ${lastStats ? formatPrompt(lastStats) : 'nothing measured yet'}`,
403        `Session      ${formatTotals(sessionTotals)}`,
404        `All time     ${formatTotals(book.allTime)}`,
405      ].join('\n'),
406    }
407  })
408
409  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
410    if (e.props.hasSurvey) return next(e)
411    const now = await $.clock.now()
412    const { Box, Text } = $.ui.resolve(e)
413    const width = e.props.bodyColumns
414
415    if (isTyping(now)) {
416      const wpm = draft.activeMs >= 1_000 ? wpmOf(draft.typedChars, draft.activeMs) : burstWpm(keyTimes, now)
417      const tier = tierOf(wpm)
418      const bar = gauge(wpm, 12)
419      const isBlazing = wpm >= 85
420      const accuracy = Math.round(Math.max(0, 1 - draft.deletedChars / draft.typedChars) * 100)
421
422      return (
423        <Box flexDirection="row" gap={1}>
424          <Text color={tier.color} bold>{pick(SPINNER, keystrokes)}</Text>
425          <Text>{tier.icon}</Text>
426          <Text color={tier.color} bold>{`${String(Math.round(wpm)).padStart(3)} WPM`}</Text>
427          <Box flexDirection="row">
428            {bar.filled !== '' && <Text color={isBlazing ? pick(RAINBOW, ticks) : tier.color}>{bar.filled}</Text>}
429            {bar.empty !== '' && <Text dimColor>{bar.empty}</Text>}
430          </Box>
431          {width >= 72 && samples.length > 0 && <Text color={tier.color}>{sparkline(samples, GAUGE_MAX_WPM)}</Text>}
432          <Text color={tier.color} italic>{tier.label}</Text>
433          {typos.length > 0 && <Text color={TYPO_COLOR}>{`✗ ${typos.length}`}</Text>}
434          {width >= 100 && (
435            <Text dimColor>{`⏱ ${formatDuration(draft.activeMs)}  ✎ ${draft.typedChars}  🎯 ${accuracy}%`}</Text>
436          )}
437        </Box>
438      )
439    }
440
441    if (card && isCardShown(now)) {
442      const age = now - card.shownAt
443      const { stats } = card
444      const tier = tierOf(stats.wpm)
445      const isDim = age > CARD_DIM_MS
446      const accent = card.isNewBest ? pick(RAINBOW, ticks) : tier.color
447      const shownWpm = Math.round(stats.wpm * easeOut(age / COUNT_UP_MS))
448      const delta = Math.round(stats.wpm - card.average)
449
450      return (
451        <Box flexDirection="column" borderStyle="round" borderColor={accent} borderDimColor={isDim} paddingX={1}>
452          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
453            <Text color={tier.color} bold dimColor={isDim}>{`${tier.icon} ${shownWpm} WPM`}</Text>
454            <Text dimColor={isDim}>{`⌨  ${formatNumber(stats.cpm)} CPM`}</Text>
455            <Text dimColor={isDim}>{`🎯 ${Math.round(stats.accuracy * 100)}%`}</Text>
456            <Text dimColor={isDim}>{`⏱ ${formatDuration(stats.activeMs)}`}</Text>
457            <Text dimColor={isDim}>{`📝 ${stats.words} words`}</Text>
458            {card.typos !== null && (
459              <Text color={card.typos > 0 ? TYPO_COLOR : FIX_COLOR} dimColor={isDim}>
460                {card.typos > 0 ? `✗ ${card.typos} typo${card.typos === 1 ? '' : 's'}` : '✓ no typos'}
461              </Text>
462            )}
463            {stats.pastedChars > 0 && <Text dimColor>{`📋 +${formatNumber(stats.pastedChars)} pasted`}</Text>}
464          </Box>
465          {card.isNewBest ? (
466            <Text color={accent} bold>
467              {`${pick(SPARKLES, ticks)} New personal best! ${
468                card.previousBest > 0 ? `${Math.round(card.previousBest)} → ${Math.round(stats.wpm)} WPM` : 'First record set'
469              } ${pick(SPARKLES, ticks + 3)}`}
470            </Text>
471          ) : (
472            <Box flexDirection="row" columnGap={1}>
473              <Text color={tier.color} italic dimColor={isDim}>{tier.label}</Text>
474              <Text color={delta >= 0 ? '#9ece6a' : '#f7768e'} dimColor={isDim}>
475                {`${delta >= 0 ? '▲' : '▼'} ${Math.abs(delta)}`}
476              </Text>
477              <Text dimColor>{`vs your ${Math.round(card.average)} WPM average · 🏆 ${Math.round(book.allTime.bestWpm)}`}</Text>
478            </Box>
479          )}
480        </Box>
481      )
482    }
483
484    const shown = hints.filter(hint => typos.some(t => t.word === hint.word))
485    if (shown.length > 0) {
486      return (
487        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
488          <Text color={TYPO_COLOR} bold>✗</Text>
489          {shown.map((hint, i) => (
490            <Box key={`${i}-${hint.word}`} flexDirection="row" columnGap={1}>
491              <Text color={TYPO_COLOR}>{hint.word}</Text>
492              <Text dimColor>→</Text>
493              {hint.fix ? <Text color={FIX_COLOR}>{hint.fix}</Text> : <Text dimColor>no suggestion</Text>}
494            </Box>
495          ))}
496          {typos.length > shown.length && <Text dimColor>{`+${typos.length - shown.length} more`}</Text>}
497        </Box>
498      )
499    }
500
501    return next(e)
502  })
503
504  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
505    const { Box, Text } = $.ui.resolve(e)
506    const [now, sessionTotals] = await Promise.all([$.clock.now(), read($, session)])
507    const all = book.allTime
508
509    if (all.prompts === 0) {
510      return (
511        <Box flexDirection="column">
512          <Text bold>⌨  Nothing measured yet</Text>
513          <Text dimColor>Type a prompt of 10 or more characters and press Enter.</Text>
514        </Box>
515      )
516    }
517
518    const average = averageWpm(all)
519    const tier = tierOf(average)
520    const accuracy = Math.round(Math.max(0, 1 - all.deletedChars / Math.max(1, all.typedChars)) * 100)
521    const week = lastDays(dayKey, now, 7)
522    const weekWpm = week.map(d => (book.days[d] ? averageWpm(book.days[d]!) : 0))
523    const weekMax = Math.max(1, ...weekWpm)
524    const barCells = Math.max(6, Math.min(24, (e.viewport?.columns ?? 60) - 34))
525
526    return (
527      <Box flexDirection="column" gap={1}>
528        <Box flexDirection="column">
529          <Text color={tier.color} bold>{`${tier.icon}  ${Math.round(average)} WPM average · ${tier.label}`}</Text>
530          <Text dimColor>
531            {`🏆 best ${Math.round(all.bestWpm)}  🎯 ${accuracy}%  📝 ${formatNumber(all.words)} words  ⏱ ${formatDuration(all.activeMs)}  ✉ ${all.prompts} prompts`}
532          </Text>
533        </Box>
534
535        <Box flexDirection="column">
536          <Text bold>Last 7 days</Text>
537          {week.map((d, i) => {
538            const wpm = weekWpm[i] ?? 0
539            const filled = Math.round((wpm / weekMax) * barCells)
540            const totals = book.days[d]
541            return (
542              <Box key={d} flexDirection="row" columnGap={1}>
543                <Text dimColor>{weekdayOf(d)}</Text>
544                <Box flexDirection="row">
545                  {filled > 0 && <Text color={tierOf(wpm).color}>{'█'.repeat(filled)}</Text>}
546                  {filled < barCells && <Text dimColor>{'·'.repeat(barCells - filled)}</Text>}
547                </Box>
548                <Text dimColor={!totals}>{totals ? `${Math.round(wpm)} WPM · ${totals.prompts} prompts` : '—'}</Text>
549              </Box>
550            )
551          })}
552        </Box>
553
554        {book.recent.length > 1 && (
555          <Box flexDirection="column">
556            <Text bold>Recent prompts</Text>
557            <Text color={tier.color}>{sparkline(book.recent, GAUGE_MAX_WPM)}</Text>
558            <Text dimColor>{`last ${book.recent.length}, oldest → newest`}</Text>
559          </Box>
560        )}
561
562        <Box flexDirection="column">
563          <Text bold>This session</Text>
564          <Text dimColor>{formatTotals(sessionTotals)}</Text>
565        </Box>
566
567        <Box flexDirection="column">
568          <Text bold>Spell check</Text>
569          <Text dimColor>{spellSummary()}</Text>
570        </Box>
571      </Box>
572    )
573  })
574}
575
hooks/stats.ts 144 lines
1import type { PromptStats, Totals } from '../types'
2
3// An insertion this long in one edit is a paste or a history recall, not typing.
4export const PASTE_MIN_CHARS = 8
5// A longer pause between keys is thinking time and is left out of the clock.
6export const IDLE_GAP_MS = 5_000
7export const MIN_TYPED_CHARS = 10
8const MIN_ACTIVE_MS = 1_000
9const CHARS_PER_WORD = 5
10const DAYS_KEPT = 30
11
12export type Draft = {
13  typedChars: number
14  pastedChars: number
15  deletedChars: number
16  activeMs: number
17  lastAt: number | null
18}
19
20export type Edit = { start: number; end: number; inputText: string }
21
22export const emptyDraft = (): Draft => ({
23  typedChars: 0,
24  pastedChars: 0,
25  deletedChars: 0,
26  activeMs: 0,
27  lastAt: null,
28})
29
30export const emptyTotals = (): Totals => ({
31  prompts: 0,
32  typedChars: 0,
33  pastedChars: 0,
34  deletedChars: 0,
35  words: 0,
36  activeMs: 0,
37  bestWpm: 0,
38})
39
40export function applyEdit(draft: Draft, edit: Edit, now: number): Draft {
41  const deleted = edit.end - edit.start
42  const inserted = edit.inputText.length
43  if (deleted === 0 && inserted === 0) return draft
44
45  if (inserted >= PASTE_MIN_CHARS) {
46    return { ...draft, pastedChars: draft.pastedChars + inserted, deletedChars: draft.deletedChars + deleted }
47  }
48
49  const gap = draft.lastAt === null ? 0 : now - draft.lastAt
50  return {
51    ...draft,
52    typedChars: draft.typedChars + inserted,
53    deletedChars: draft.deletedChars + deleted,
54    activeMs: draft.activeMs + (gap <= IDLE_GAP_MS ? gap : 0),
55    lastAt: now,
56  }
57}
58
59// A slash command's name is typed from muscle memory and completed by Tab; only its arguments count.
60function commandNameEnd(text: string): number {
61  if (!text.startsWith('/')) return -1
62  const space = text.search(/\s/)
63  return space === -1 ? text.length : space
64}
65
66export const isCommandNameEdit = (textAfter: string, start: number) => start <= commandNameEnd(textAfter)
67
68export function argumentsOf(text: string): string {
69  const end = commandNameEnd(text)
70  return end === -1 ? text : text.slice(end)
71}
72
73export const wpmOf = (chars: number, ms: number) => (ms > 0 ? chars / CHARS_PER_WORD / (ms / 60_000) : 0)
74
75export function summarize(draft: Draft, submitted: string): PromptStats | null {
76  if (draft.typedChars < MIN_TYPED_CHARS || draft.activeMs < MIN_ACTIVE_MS) return null
77
78  return {
79    typedChars: draft.typedChars,
80    pastedChars: draft.pastedChars,
81    deletedChars: draft.deletedChars,
82    words: submitted.trim().split(/\s+/).filter(Boolean).length,
83    activeMs: draft.activeMs,
84    wpm: wpmOf(draft.typedChars, draft.activeMs),
85    cpm: draft.typedChars / (draft.activeMs / 60_000),
86    accuracy: Math.max(0, 1 - draft.deletedChars / draft.typedChars),
87  }
88}
89
90export const addToTotals = (t: Totals, s: PromptStats): Totals => ({
91  prompts: t.prompts + 1,
92  typedChars: t.typedChars + s.typedChars,
93  pastedChars: t.pastedChars + s.pastedChars,
94  deletedChars: t.deletedChars + s.deletedChars,
95  words: t.words + s.words,
96  activeMs: t.activeMs + s.activeMs,
97  bestWpm: Math.max(t.bestWpm, s.wpm),
98})
99
100export const averageWpm = (t: Totals) => wpmOf(t.typedChars, t.activeMs)
101
102export function dayKey(ms: number): string {
103  const d = new Date(ms)
104  const pad = (n: number) => String(n).padStart(2, '0')
105  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
106}
107
108export function recordDay(days: Record<string, Totals>, key: string, s: PromptStats): Record<string, Totals> {
109  const next = { ...days, [key]: addToTotals(days[key] ?? emptyTotals(), s) }
110  return Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b)).slice(-DAYS_KEPT))
111}
112
113export const formatNumber = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
114
115export function formatDuration(ms: number): string {
116  const seconds = Math.round(ms / 1000)
117  if (seconds < 60) return `${seconds}s`
118  const minutes = Math.floor(seconds / 60)
119  if (minutes < 60) return `${minutes}m ${seconds % 60}s`
120  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
121}
122
123export function formatPrompt(s: PromptStats): string {
124  const parts = [
125    `${Math.round(s.wpm)} WPM`,
126    `${formatNumber(s.cpm)} CPM`,
127    `${Math.round(s.accuracy * 100)}% accuracy`,
128    `${formatNumber(s.typedChars)} chars, ${s.words} words in ${formatDuration(s.activeMs)}`,
129  ]
130  if (s.pastedChars > 0) parts.push(`+${formatNumber(s.pastedChars)} pasted`)
131  return parts.join(' · ')
132}
133
134export function formatTotals(t: Totals): string {
135  if (t.prompts === 0) return 'nothing measured yet'
136  return [
137    `${t.prompts} prompt${t.prompts === 1 ? '' : 's'}`,
138    `${Math.round(averageWpm(t))} WPM avg`,
139    `best ${Math.round(t.bestWpm)}`,
140    `${formatNumber(t.typedChars)} chars, ${formatNumber(t.words)} words`,
141    `${formatDuration(t.activeMs)} typing`,
142  ].join(' · ')
143}
144
hooks/dictionaries.ts 5 lines
1// Written by scripts/vendor-spell.mjs: the dictionaries under dictionaries/ and their chunk counts.
2export const DICTIONARY_CHUNKS = {"en":2,"tr":18} as const
3
4export type Language = keyof typeof DICTIONARY_CHUNKS
5
hooks/spell.ts 160 lines
1import { DICTIONARY_CHUNKS } from './dictionaries'
2import type { Language } from './dictionaries'
3
4export type Word = { start: number; end: number; word: string }
5export type Speller = { correct(word: string): boolean; suggest(word: string): string[] }
6
7export const SUPPORTED_LANGUAGES = Object.keys(DICTIONARY_CHUNKS) as Language[]
8// Prompts to a coding agent are full of English whatever the system language, so English always loads.
9export const BASE_LANGUAGE: Language = 'en'
10const CACHE_LIMIT = 10_000
11const SUGGESTIONS_KEPT = 3
12// nspell's suggestions grow costly with length; a word this long gets none.
13const SUGGEST_MAX_LENGTH = 20
14
15const isSupported = (code: string): code is Language => (SUPPORTED_LANGUAGES as string[]).includes(code)
16
17// Locale lists as the platforms print them: `("en-TR", "tr-TR")`, `tr_TR.UTF-8`, `tr:en`, one per line.
18export function languagesOf(locales: readonly string[]): Language[] {
19  const found: Language[] = []
20  for (const token of locales.flatMap(l => l.split(/[\s,()":;]+/))) {
21    const code = /^[A-Za-z]{2,3}(?![A-Za-z])/.exec(token)?.[0].toLowerCase()
22    if (code && isSupported(code) && !found.includes(code)) found.push(code)
23  }
24  if (!found.includes(BASE_LANGUAGE)) found.push(BASE_LANGUAGE)
25  return found
26}
27
28// Code spans and fences are not prose.
29function codeRanges(text: string): [number, number][] {
30  return [...text.matchAll(/```[\s\S]*?(?:```|$)|`[^`\n]*(?:`|$)/g)].map(m => [m.index, m.index + m[0].length])
31}
32
33const LEADING = /^["'([{<«“‘]+/
34const TRAILING = /["')\]}>»”’.,;:!?…]+$/
35// Paths, flags, identifiers, numbers, mentions, URLs and file names: not words to spell.
36const CODE_LIKE = /[\d_/\\@#$%^&*=+<>{}[\]|~`:]|\.\p{L}|^-/u
37const WORD_SHAPE = /^[\p{L}\p{M}]+(?:['’][\p{L}\p{M}]+)*$/u
38
39// The words of a draft worth spelling, leaving out the one the caret is still in.
40export function wordsOf(text: string, cursor: number): Word[] {
41  const code = codeRanges(text)
42  const words: Word[] = []
43  for (const chunk of text.matchAll(/\S+/g)) {
44    const start = chunk.index
45    const end = start + chunk[0].length
46    if (cursor >= start && cursor <= end) continue
47    if (code.some(([s, e]) => start < e && end > s)) continue
48
49    const lead = LEADING.exec(chunk[0])?.[0].length ?? 0
50    const core = chunk[0].slice(lead).replace(TRAILING, '')
51    if (core === '' || CODE_LIKE.test(core)) continue
52
53    let offset = start + lead
54    for (const part of core.split(/[-–—]/)) {
55      const isWord = part.length >= 2 && WORD_SHAPE.test(part)
56      const isCamelCase = /\p{Ll}\p{Lu}/u.test(part)
57      const isAcronym = /^\p{Lu}+$/u.test(part)
58      if (isWord && !isCamelCase && !isAcronym) words.push({ start: offset, end: offset + part.length, word: part })
59      offset += part.length + 1
60    }
61  }
62  return words
63}
64
65const turkishLower = (word: string) => word.replace(/I/g, 'ı').replace(/İ/g, 'i').toLowerCase()
66
67function variantsOf(word: string): string[] {
68  const straight = word.replace(/’/g, "'")
69  return [...new Set([word, straight, straight.toLowerCase(), turkishLower(straight)])]
70}
71
72// Edit distance with swapped neighbours at half cost: the typo fingers make most (recieve, yanlız).
73function distance(a: string, b: string): number {
74  const rows = Array.from({ length: a.length + 1 }, (_, i) =>
75    Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
76  )
77  for (let i = 1; i <= a.length; i++) {
78    for (let j = 1; j <= b.length; j++) {
79      const row = rows[i]!
80      const above = rows[i - 1]!
81      row[j] = Math.min(above[j]! + 1, row[j - 1]! + 1, above[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
82      if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
83        row[j] = Math.min(row[j]!, rows[i - 2]![j - 2]! + 0.5)
84      }
85    }
86  }
87  return rows[a.length]![b.length]!
88}
89
90export class SpellChecker {
91  private readonly known = new Map<string, boolean>()
92  private readonly suggestions = new Map<string, string[]>()
93  private readonly personal: Set<string>
94
95  constructor(
96    private readonly spellers: readonly Speller[],
97    readonly languages: readonly Language[],
98    personal: Iterable<string> = [],
99  ) {
100    this.personal = new Set([...personal].map(w => w.toLowerCase()))
101  }
102
103  get personalWords(): string[] {
104    return [...this.personal].sort()
105  }
106
107  learn(word: string) {
108    this.personal.add(word.toLowerCase())
109    this.known.clear()
110  }
111
112  forget(word: string) {
113    this.personal.delete(word.toLowerCase())
114    this.known.clear()
115  }
116
117  isCorrect(word: string): boolean {
118    let isKnown = this.known.get(word)
119    if (isKnown === undefined) {
120      isKnown = this.lookUp(word)
121      if (this.known.size >= CACHE_LIMIT) this.known.clear()
122      this.known.set(word, isKnown)
123    }
124    return isKnown
125  }
126
127  misspelled(text: string, cursor: number): Word[] {
128    return wordsOf(text, cursor).filter(w => !this.isCorrect(w.word))
129  }
130
131  hasSuggested(word: string): boolean {
132    return this.suggestions.has(word) || word.length > SUGGEST_MAX_LENGTH
133  }
134
135  suggest(word: string): string[] {
136    const cached = this.suggestions.get(word)
137    if (cached) return cached
138    if (word.length > SUGGEST_MAX_LENGTH) return []
139    const seen = new Set<string>()
140    const ranked = this.spellers
141      .flatMap((s, rank) => s.suggest(word).map(candidate => ({ candidate, rank })))
142      .filter(({ candidate }) => !seen.has(candidate) && seen.add(candidate))
143      .map(s => ({ ...s, cost: distance(word.toLowerCase(), s.candidate.toLowerCase()) }))
144      .sort((a, b) => a.cost - b.cost || a.rank - b.rank)
145      .slice(0, SUGGESTIONS_KEPT)
146      .map(s => s.candidate)
147    if (this.suggestions.size >= CACHE_LIMIT) this.suggestions.clear()
148    this.suggestions.set(word, ranked)
149    return ranked
150  }
151
152  private lookUp(word: string): boolean {
153    if (this.personal.has(word.toLowerCase())) return true
154    if (variantsOf(word).some(v => this.spellers.some(s => s.correct(v)))) return true
155    // Turkish puts a proper name's suffix after an apostrophe: Claude'un, Ankara'da.
156    const apostrophe = word.search(/['’]/)
157    return apostrophe > 0 && /^\p{Lu}/u.test(word)
158  }
159}
160
hooks/vendor/nspell.js 1008 lines
1// nspell 2.1.5 (MIT, https://github.com/wooorm/nspell), bundled by scripts/vendor-spell.mjs.
2var __create = Object.create;
3var __defProp = Object.defineProperty;
4var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5var __getOwnPropNames = Object.getOwnPropertyNames;
6var __getProtoOf = Object.getPrototypeOf;
7var __hasOwnProp = Object.prototype.hasOwnProperty;
8var __commonJS = (cb, mod) => function __require() {
9  try {
10    return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11  } catch (e) {
12    throw mod = 0, e;
13  }
14};
15var __copyProps = (to, from, except, desc) => {
16  if (from && typeof from === "object" || typeof from === "function") {
17    for (let key of __getOwnPropNames(from))
18      if (!__hasOwnProp.call(to, key) && key !== except)
19        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20  }
21  return to;
22};
23var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24  // If the importer is in node compatibility mode or this is not an ESM
25  // file that has been converted to a CommonJS file using a Babel-
26  // compatible transform (i.e. "__esModule" has not been set), then set
27  // "default" to the CommonJS "module.exports" for node compatibility.
28  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29  mod
30));
31
32// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/is-buffer/index.js
33var require_is_buffer = __commonJS({
34  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/is-buffer/index.js"(exports, module) {
35    module.exports = function isBuffer(obj) {
36      return obj != null && obj.constructor != null && typeof obj.constructor.isBuffer === "function" && obj.constructor.isBuffer(obj);
37    };
38  }
39});
40
41// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/rule-codes.js
42var require_rule_codes = __commonJS({
43  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/rule-codes.js"(exports, module) {
44    "use strict";
45    module.exports = ruleCodes;
46    var NO_CODES = [];
47    function ruleCodes(flags, value) {
48      var index = 0;
49      var result;
50      if (!value) return NO_CODES;
51      if (flags.FLAG === "long") {
52        result = new Array(Math.ceil(value.length / 2));
53        while (index < value.length) {
54          result[index / 2] = value.slice(index, index + 2);
55          index += 2;
56        }
57        return result;
58      }
59      return value.split(flags.FLAG === "num" ? "," : "");
60    }
61  }
62});
63
64// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/affix.js
65var require_affix = __commonJS({
66  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/affix.js"(exports, module) {
67    "use strict";
68    var parse = require_rule_codes();
69    module.exports = affix;
70    var push = [].push;
71    var alphabet = "etaoinshrdlcumwfgypbvkjxqz".split("");
72    var whiteSpaceExpression = /\s+/;
73    var defaultKeyboardLayout = [
74      "qwertzuop",
75      "yxcvbnm",
76      "qaw",
77      "say",
78      "wse",
79      "dsx",
80      "sy",
81      "edr",
82      "fdc",
83      "dx",
84      "rft",
85      "gfv",
86      "fc",
87      "tgz",
88      "hgb",
89      "gv",
90      "zhu",
91      "jhn",
92      "hb",
93      "uji",
94      "kjm",
95      "jn",
96      "iko",
97      "lkm"
98    ];
99    function affix(doc) {
100      var rules = /* @__PURE__ */ Object.create(null);
101      var compoundRuleCodes = /* @__PURE__ */ Object.create(null);
102      var flags = /* @__PURE__ */ Object.create(null);
103      var replacementTable = [];
104      var conversion = { in: [], out: [] };
105      var compoundRules = [];
106      var aff = doc.toString("utf8");
107      var lines = [];
108      var last = 0;
109      var index = aff.indexOf("\n");
110      var parts;
111      var line;
112      var ruleType;
113      var count;
114      var remove;
115      var add;
116      var source;
117      var entry;
118      var position;
119      var rule;
120      var value;
121      var offset;
122      var character;
123      flags.KEY = [];
124      while (index > -1) {
125        pushLine(aff.slice(last, index));
126        last = index + 1;
127        index = aff.indexOf("\n", last);
128      }
129      pushLine(aff.slice(last));
130      index = -1;
131      while (++index < lines.length) {
132        line = lines[index];
133        parts = line.split(whiteSpaceExpression);
134        ruleType = parts[0];
135        if (ruleType === "REP") {
136          count = index + parseInt(parts[1], 10);
137          while (++index <= count) {
138            parts = lines[index].split(whiteSpaceExpression);
139            replacementTable.push([parts[1], parts[2]]);
140          }
141          index--;
142        } else if (ruleType === "ICONV" || ruleType === "OCONV") {
143          count = index + parseInt(parts[1], 10);
144          entry = conversion[ruleType === "ICONV" ? "in" : "out"];
145          while (++index <= count) {
146            parts = lines[index].split(whiteSpaceExpression);
147            entry.push([new RegExp(parts[1], "g"), parts[2]]);
148          }
149          index--;
150        } else if (ruleType === "COMPOUNDRULE") {
151          count = index + parseInt(parts[1], 10);
152          while (++index <= count) {
153            rule = lines[index].split(whiteSpaceExpression)[1];
154            position = -1;
155            compoundRules.push(rule);
156            while (++position < rule.length) {
157              compoundRuleCodes[rule.charAt(position)] = [];
158            }
159          }
160          index--;
161        } else if (ruleType === "PFX" || ruleType === "SFX") {
162          count = index + parseInt(parts[3], 10);
163          rule = {
164            type: ruleType,
165            combineable: parts[2] === "Y",
166            entries: []
167          };
168          rules[parts[1]] = rule;
169          while (++index <= count) {
170            parts = lines[index].split(whiteSpaceExpression);
171            remove = parts[2];
172            add = parts[3].split("/");
173            source = parts[4];
174            entry = {
175              add: "",
176              remove: "",
177              match: "",
178              continuation: parse(flags, add[1])
179            };
180            if (add && add[0] !== "0") {
181              entry.add = add[0];
182            }
183            try {
184              if (remove !== "0") {
185                entry.remove = ruleType === "SFX" ? end(remove) : remove;
186              }
187              if (source && source !== ".") {
188                entry.match = ruleType === "SFX" ? end(source) : start(source);
189              }
190            } catch (_) {
191              entry = null;
192            }
193            if (entry) {
194              rule.entries.push(entry);
195            }
196          }
197          index--;
198        } else if (ruleType === "TRY") {
199          source = parts[1];
200          offset = -1;
201          value = [];
202          while (++offset < source.length) {
203            character = source.charAt(offset);
204            if (character.toLowerCase() === character) {
205              value.push(character);
206            }
207          }
208          offset = -1;
209          while (++offset < alphabet.length) {
210            if (source.indexOf(alphabet[offset]) < 0) {
211              value.push(alphabet[offset]);
212            }
213          }
214          flags[ruleType] = value;
215        } else if (ruleType === "KEY") {
216          push.apply(flags[ruleType], parts[1].split("|"));
217        } else if (ruleType === "COMPOUNDMIN") {
218          flags[ruleType] = Number(parts[1]);
219        } else if (ruleType === "ONLYINCOMPOUND") {
220          flags[ruleType] = parts[1];
221          compoundRuleCodes[parts[1]] = [];
222        } else if (ruleType === "FLAG" || ruleType === "KEEPCASE" || ruleType === "NOSUGGEST" || ruleType === "WORDCHARS") {
223          flags[ruleType] = parts[1];
224        } else {
225          flags[ruleType] = parts[1];
226        }
227      }
228      if (isNaN(flags.COMPOUNDMIN)) {
229        flags.COMPOUNDMIN = 3;
230      }
231      if (!flags.KEY.length) {
232        flags.KEY = defaultKeyboardLayout;
233      }
234      if (!flags.TRY) {
235        flags.TRY = alphabet.concat();
236      }
237      if (!flags.KEEPCASE) {
238        flags.KEEPCASE = false;
239      }
240      return {
241        compoundRuleCodes,
242        replacementTable,
243        conversion,
244        compoundRules,
245        rules,
246        flags
247      };
248      function pushLine(line2) {
249        line2 = line2.trim();
250        if (line2 && line2.charCodeAt(0) !== 35) {
251          lines.push(line2);
252        }
253      }
254    }
255    function end(source) {
256      return new RegExp(source + "$");
257    }
258    function start(source) {
259      return new RegExp("^" + source);
260    }
261  }
262});
263
264// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/normalize.js
265var require_normalize = __commonJS({
266  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/normalize.js"(exports, module) {
267    "use strict";
268    module.exports = normalize;
269    function normalize(value, patterns) {
270      var index = -1;
271      while (++index < patterns.length) {
272        value = value.replace(patterns[index][0], patterns[index][1]);
273      }
274      return value;
275    }
276  }
277});
278
279// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/flag.js
280var require_flag = __commonJS({
281  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/flag.js"(exports, module) {
282    "use strict";
283    module.exports = flag;
284    function flag(values, value, flags) {
285      return flags && value in values && flags.indexOf(values[value]) > -1;
286    }
287  }
288});
289
290// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/exact.js
291var require_exact = __commonJS({
292  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/exact.js"(exports, module) {
293    "use strict";
294    var flag = require_flag();
295    module.exports = exact;
296    function exact(context, value) {
297      var index = -1;
298      if (context.data[value]) {
299        return !flag(context.flags, "ONLYINCOMPOUND", context.data[value]);
300      }
301      if (value.length >= context.flags.COMPOUNDMIN) {
302        while (++index < context.compoundRules.length) {
303          if (context.compoundRules[index].test(value)) {
304            return true;
305          }
306        }
307      }
308      return false;
309    }
310  }
311});
312
313// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/form.js
314var require_form = __commonJS({
315  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/form.js"(exports, module) {
316    "use strict";
317    var normalize = require_normalize();
318    var exact = require_exact();
319    var flag = require_flag();
320    module.exports = form;
321    function form(context, value, all) {
322      var normal = value.trim();
323      var alternative;
324      if (!normal) {
325        return null;
326      }
327      normal = normalize(normal, context.conversion.in);
328      if (exact(context, normal)) {
329        if (!all && flag(context.flags, "FORBIDDENWORD", context.data[normal])) {
330          return null;
331        }
332        return normal;
333      }
334      if (normal.toUpperCase() === normal) {
335        alternative = normal.charAt(0) + normal.slice(1).toLowerCase();
336        if (ignore(context.flags, context.data[alternative], all)) {
337          return null;
338        }
339        if (exact(context, alternative)) {
340          return alternative;
341        }
342      }
343      alternative = normal.toLowerCase();
344      if (alternative !== normal) {
345        if (ignore(context.flags, context.data[alternative], all)) {
346          return null;
347        }
348        if (exact(context, alternative)) {
349          return alternative;
350        }
351      }
352      return null;
353    }
354    function ignore(flags, dict, all) {
355      return flag(flags, "KEEPCASE", dict) || all || flag(flags, "FORBIDDENWORD", dict);
356    }
357  }
358});
359
360// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/correct.js
361var require_correct = __commonJS({
362  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/correct.js"(exports, module) {
363    "use strict";
364    var form = require_form();
365    module.exports = correct;
366    function correct(value) {
367      return Boolean(form(this, value));
368    }
369  }
370});
371
372// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/casing.js
373var require_casing = __commonJS({
374  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/casing.js"(exports, module) {
375    "use strict";
376    module.exports = casing;
377    function casing(value) {
378      var head = exact(value.charAt(0));
379      var rest = value.slice(1);
380      if (!rest) {
381        return head;
382      }
383      rest = exact(rest);
384      if (head === rest) {
385        return head;
386      }
387      if (head === "u" && rest === "l") {
388        return "s";
389      }
390      return null;
391    }
392    function exact(value) {
393      return value === value.toLowerCase() ? "l" : value === value.toUpperCase() ? "u" : null;
394    }
395  }
396});
397
398// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/suggest.js
399var require_suggest = __commonJS({
400  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/suggest.js"(exports, module) {
401    "use strict";
402    var casing = require_casing();
403    var normalize = require_normalize();
404    var flag = require_flag();
405    var form = require_form();
406    module.exports = suggest;
407    var push = [].push;
408    function suggest(value) {
409      var self = this;
410      var charAdded = {};
411      var suggestions = [];
412      var weighted = {};
413      var memory;
414      var replacement;
415      var edits = [];
416      var values;
417      var index;
418      var offset;
419      var position;
420      var count;
421      var otherOffset;
422      var otherCharacter;
423      var character;
424      var group;
425      var before;
426      var after;
427      var upper;
428      var insensitive;
429      var firstLevel;
430      var previous;
431      var next;
432      var nextCharacter;
433      var max;
434      var distance;
435      var size;
436      var normalized;
437      var suggestion;
438      var currentCase;
439      value = normalize(value.trim(), self.conversion.in);
440      if (!value || self.correct(value)) {
441        return [];
442      }
443      currentCase = casing(value);
444      index = -1;
445      while (++index < self.replacementTable.length) {
446        replacement = self.replacementTable[index];
447        offset = value.indexOf(replacement[0]);
448        while (offset > -1) {
449          edits.push(value.replace(replacement[0], replacement[1]));
450          offset = value.indexOf(replacement[0], offset + 1);
451        }
452      }
453      index = -1;
454      while (++index < value.length) {
455        character = value.charAt(index);
456        before = value.slice(0, index);
457        after = value.slice(index + 1);
458        insensitive = character.toLowerCase();
459        upper = insensitive !== character;
460        charAdded = {};
461        offset = -1;
462        while (++offset < self.flags.KEY.length) {
463          group = self.flags.KEY[offset];
464          position = group.indexOf(insensitive);
465          if (position < 0) {
466            continue;
467          }
468          otherOffset = -1;
469          while (++otherOffset < group.length) {
470            if (otherOffset !== position) {
471              otherCharacter = group.charAt(otherOffset);
472              if (charAdded[otherCharacter]) {
473                continue;
474              }
475              charAdded[otherCharacter] = true;
476              if (upper) {
477                otherCharacter = otherCharacter.toUpperCase();
478              }
479              edits.push(before + otherCharacter + after);
480            }
481          }
482        }
483      }
484      index = -1;
485      nextCharacter = value.charAt(0);
486      values = [""];
487      max = 1;
488      distance = 0;
489      while (++index < value.length) {
490        character = nextCharacter;
491        nextCharacter = value.charAt(index + 1);
492        before = value.slice(0, index);
493        replacement = character === nextCharacter ? "" : character + character;
494        offset = -1;
495        count = values.length;
496        while (++offset < count) {
497          if (offset <= max) {
498            values.push(values[offset] + replacement);
499          }
500          values[offset] += character;
501        }
502        if (++distance < 3) {
503          max = values.length;
504        }
505      }
506      push.apply(edits, values);
507      values = [value];
508      replacement = value.toLowerCase();
509      if (value === replacement || currentCase === null) {
510        values.push(value.charAt(0).toUpperCase() + replacement.slice(1));
511      }
512      replacement = value.toUpperCase();
513      if (value !== replacement) {
514        values.push(replacement);
515      }
516      memory = {
517        state: {},
518        weighted,
519        suggestions
520      };
521      firstLevel = generate(self, memory, values, edits);
522      previous = 0;
523      max = Math.min(firstLevel.length, Math.pow(Math.max(15 - value.length, 3), 3));
524      size = Math.max(Math.pow(10 - value.length, 3), 1);
525      while (!suggestions.length && previous < max) {
526        next = previous + size;
527        generate(self, memory, firstLevel.slice(previous, next));
528        previous = next;
529      }
530      suggestions.sort(sort);
531      values = [];
532      normalized = [];
533      index = -1;
534      while (++index < suggestions.length) {
535        suggestion = normalize(suggestions[index], self.conversion.out);
536        replacement = suggestion.toLowerCase();
537        if (normalized.indexOf(replacement) < 0) {
538          values.push(suggestion);
539          normalized.push(replacement);
540        }
541      }
542      return values;
543      function sort(a, b) {
544        return sortWeight(a, b) || sortCasing(a, b) || sortAlpha(a, b);
545      }
546      function sortWeight(a, b) {
547        return weighted[a] === weighted[b] ? 0 : weighted[a] > weighted[b] ? -1 : 1;
548      }
549      function sortCasing(a, b) {
550        var leftCasing = casing(a);
551        var rightCasing = casing(b);
552        return leftCasing === rightCasing ? 0 : leftCasing === currentCase ? -1 : rightCasing === currentCase ? 1 : void 0;
553      }
554      function sortAlpha(a, b) {
555        return a.localeCompare(b);
556      }
557    }
558    function generate(context, memory, words, edits) {
559      var characters = context.flags.TRY;
560      var data = context.data;
561      var flags = context.flags;
562      var result = [];
563      var index = -1;
564      var word;
565      var before;
566      var character;
567      var nextCharacter;
568      var nextAfter;
569      var nextNextAfter;
570      var nextUpper;
571      var currentCase;
572      var position;
573      var after;
574      var upper;
575      var inject;
576      var offset;
577      if (edits) {
578        while (++index < edits.length) {
579          check(edits[index], true);
580        }
581      }
582      index = -1;
583      while (++index < words.length) {
584        word = words[index];
585        before = "";
586        character = "";
587        nextCharacter = word.charAt(0);
588        nextAfter = word;
589        nextNextAfter = word.slice(1);
590        nextUpper = nextCharacter.toLowerCase() !== nextCharacter;
591        currentCase = casing(word);
592        position = -1;
593        while (++position <= word.length) {
594          before += character;
595          after = nextAfter;
596          nextAfter = nextNextAfter;
597          nextNextAfter = nextAfter.slice(1);
598          character = nextCharacter;
599          nextCharacter = word.charAt(position + 1);
600          upper = nextUpper;
601          if (nextCharacter) {
602            nextUpper = nextCharacter.toLowerCase() !== nextCharacter;
603          }
604          if (nextAfter && upper !== nextUpper) {
605            check(before + switchCase(nextAfter));
606            check(
607              before + switchCase(nextCharacter) + switchCase(character) + nextNextAfter
608            );
609          }
610          check(before + nextAfter);
611          if (nextAfter) {
612            check(before + nextCharacter + character + nextNextAfter);
613          }
614          offset = -1;
615          while (++offset < characters.length) {
616            inject = characters[offset];
617            if (upper && inject !== inject.toUpperCase()) {
618              if (currentCase !== "s") {
619                check(before + inject + after);
620                check(before + inject + nextAfter);
621              }
622              inject = inject.toUpperCase();
623              check(before + inject + after);
624              check(before + inject + nextAfter);
625            } else {
626              check(before + inject + after);
627              check(before + inject + nextAfter);
628            }
629          }
630        }
631      }
632      return result;
633      function check(value, double) {
634        var state = memory.state[value];
635        var corrected;
636        if (state !== Boolean(state)) {
637          result.push(value);
638          corrected = form(context, value);
639          state = corrected && !flag(flags, "NOSUGGEST", data[corrected]);
640          memory.state[value] = state;
641          if (state) {
642            memory.weighted[value] = double ? 10 : 0;
643            memory.suggestions.push(value);
644          }
645        }
646        if (state) {
647          memory.weighted[value]++;
648        }
649      }
650      function switchCase(fragment) {
651        var first = fragment.charAt(0);
652        return (first.toLowerCase() === first ? first.toUpperCase() : first.toLowerCase()) + fragment.slice(1);
653      }
654    }
655  }
656});
657
658// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/spell.js
659var require_spell = __commonJS({
660  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/spell.js"(exports, module) {
661    "use strict";
662    var form = require_form();
663    var flag = require_flag();
664    module.exports = spell;
665    function spell(word) {
666      var self = this;
667      var value = form(self, word, true);
668      return {
669        correct: self.correct(word),
670        forbidden: Boolean(
671          value && flag(self.flags, "FORBIDDENWORD", self.data[value])
672        ),
673        warn: Boolean(value && flag(self.flags, "WARN", self.data[value]))
674      };
675    }
676  }
677});
678
679// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/apply.js
680var require_apply = __commonJS({
681  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/apply.js"(exports, module) {
682    "use strict";
683    module.exports = apply;
684    function apply(value, rule, rules, words) {
685      var index = -1;
686      var entry;
687      var next;
688      var continuationRule;
689      var continuation;
690      var position;
691      while (++index < rule.entries.length) {
692        entry = rule.entries[index];
693        continuation = entry.continuation;
694        position = -1;
695        if (!entry.match || entry.match.test(value)) {
696          next = entry.remove ? value.replace(entry.remove, "") : value;
697          next = rule.type === "SFX" ? next + entry.add : entry.add + next;
698          words.push(next);
699          if (continuation && continuation.length) {
700            while (++position < continuation.length) {
701              continuationRule = rules[continuation[position]];
702              if (continuationRule) {
703                apply(next, continuationRule, rules, words);
704              }
705            }
706          }
707        }
708      }
709      return words;
710    }
711  }
712});
713
714// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/add.js
715var require_add = __commonJS({
716  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/add.js"(exports, module) {
717    "use strict";
718    var apply = require_apply();
719    module.exports = add;
720    var push = [].push;
721    var NO_RULES = [];
722    function addRules(dict, word, rules) {
723      var curr = dict[word];
724      if (word in dict) {
725        if (curr === NO_RULES) {
726          dict[word] = rules.concat();
727        } else {
728          push.apply(curr, rules);
729        }
730      } else {
731        dict[word] = rules.concat();
732      }
733    }
734    function add(dict, word, codes, options) {
735      var position = -1;
736      var rule;
737      var offset;
738      var subposition;
739      var suboffset;
740      var combined;
741      var newWords;
742      var otherNewWords;
743      if (!("NEEDAFFIX" in options.flags) || codes.indexOf(options.flags.NEEDAFFIX) < 0) {
744        addRules(dict, word, codes);
745      }
746      while (++position < codes.length) {
747        rule = options.rules[codes[position]];
748        if (codes[position] in options.compoundRuleCodes) {
749          options.compoundRuleCodes[codes[position]].push(word);
750        }
751        if (rule) {
752          newWords = apply(word, rule, options.rules, []);
753          offset = -1;
754          while (++offset < newWords.length) {
755            if (!(newWords[offset] in dict)) {
756              dict[newWords[offset]] = NO_RULES;
757            }
758            if (rule.combineable) {
759              subposition = position;
760              while (++subposition < codes.length) {
761                combined = options.rules[codes[subposition]];
762                if (combined && combined.combineable && rule.type !== combined.type) {
763                  otherNewWords = apply(
764                    newWords[offset],
765                    combined,
766                    options.rules,
767                    []
768                  );
769                  suboffset = -1;
770                  while (++suboffset < otherNewWords.length) {
771                    if (!(otherNewWords[suboffset] in dict)) {
772                      dict[otherNewWords[suboffset]] = NO_RULES;
773                    }
774                  }
775                }
776              }
777            }
778          }
779        }
780      }
781    }
782  }
783});
784
785// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/add.js
786var require_add2 = __commonJS({
787  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/add.js"(exports, module) {
788    "use strict";
789    var push = require_add();
790    module.exports = add;
791    var NO_CODES = [];
792    function add(value, model) {
793      var self = this;
794      push(self.data, value, self.data[model] || NO_CODES, self);
795      return self;
796    }
797  }
798});
799
800// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/remove.js
801var require_remove = __commonJS({
802  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/remove.js"(exports, module) {
803    "use strict";
804    module.exports = remove;
805    function remove(value) {
806      var self = this;
807      delete self.data[value];
808      return self;
809    }
810  }
811});
812
813// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/word-characters.js
814var require_word_characters = __commonJS({
815  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/word-characters.js"(exports, module) {
816    "use strict";
817    module.exports = wordCharacters;
818    function wordCharacters() {
819      return this.flags.WORDCHARS || null;
820    }
821  }
822});
823
824// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/dictionary.js
825var require_dictionary = __commonJS({
826  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/util/dictionary.js"(exports, module) {
827    "use strict";
828    var parseCodes = require_rule_codes();
829    var add = require_add();
830    module.exports = parse;
831    var whiteSpaceExpression = /\s/g;
832    function parse(buf, options, dict) {
833      var value = buf.toString("utf8");
834      var last = value.indexOf("\n") + 1;
835      var index = value.indexOf("\n", last);
836      while (index > -1) {
837        if (value.charCodeAt(last) !== 9) {
838          parseLine(value.slice(last, index), options, dict);
839        }
840        last = index + 1;
841        index = value.indexOf("\n", last);
842      }
843      parseLine(value.slice(last), options, dict);
844    }
845    function parseLine(line, options, dict) {
846      var slashOffset = line.indexOf("/");
847      var hashOffset = line.indexOf("#");
848      var codes = "";
849      var word;
850      var result;
851      while (slashOffset > -1 && line.charCodeAt(slashOffset - 1) === 92) {
852        line = line.slice(0, slashOffset - 1) + line.slice(slashOffset);
853        slashOffset = line.indexOf("/", slashOffset);
854      }
855      if (hashOffset > -1) {
856        if (slashOffset > -1 && slashOffset < hashOffset) {
857          word = line.slice(0, slashOffset);
858          whiteSpaceExpression.lastIndex = slashOffset + 1;
859          result = whiteSpaceExpression.exec(line);
860          codes = line.slice(slashOffset + 1, result ? result.index : void 0);
861        } else {
862          word = line.slice(0, hashOffset);
863        }
864      } else if (slashOffset > -1) {
865        word = line.slice(0, slashOffset);
866        codes = line.slice(slashOffset + 1);
867      } else {
868        word = line;
869      }
870      word = word.trim();
871      if (word) {
872        add(dict, word, parseCodes(options.flags, codes.trim()), options);
873      }
874    }
875  }
876});
877
878// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/dictionary.js
879var require_dictionary2 = __commonJS({
880  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/dictionary.js"(exports, module) {
881    "use strict";
882    var parse = require_dictionary();
883    module.exports = add;
884    function add(buf) {
885      var self = this;
886      var index = -1;
887      var rule;
888      var source;
889      var character;
890      var offset;
891      parse(buf, self, self.data);
892      while (++index < self.compoundRules.length) {
893        rule = self.compoundRules[index];
894        source = "";
895        offset = -1;
896        while (++offset < rule.length) {
897          character = rule.charAt(offset);
898          source += self.compoundRuleCodes[character].length ? "(?:" + self.compoundRuleCodes[character].join("|") + ")" : character;
899        }
900        self.compoundRules[index] = new RegExp(source, "i");
901      }
902      return self;
903    }
904  }
905});
906
907// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/personal.js
908var require_personal = __commonJS({
909  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/personal.js"(exports, module) {
910    "use strict";
911    module.exports = add;
912    function add(buf) {
913      var self = this;
914      var lines = buf.toString("utf8").split("\n");
915      var index = -1;
916      var line;
917      var forbidden;
918      var word;
919      var flag;
920      if (self.flags.FORBIDDENWORD === void 0) self.flags.FORBIDDENWORD = false;
921      flag = self.flags.FORBIDDENWORD;
922      while (++index < lines.length) {
923        line = lines[index].trim();
924        if (!line) {
925          continue;
926        }
927        line = line.split("/");
928        word = line[0];
929        forbidden = word.charAt(0) === "*";
930        if (forbidden) {
931          word = word.slice(1);
932        }
933        self.add(word, line[1]);
934        if (forbidden) {
935          self.data[word].push(flag);
936        }
937      }
938      return self;
939    }
940  }
941});
942
943// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/index.js
944var require_lib = __commonJS({
945  "../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/node_modules/nspell/lib/index.js"(exports, module) {
946    "use strict";
947    var buffer = require_is_buffer();
948    var affix = require_affix();
949    module.exports = NSpell;
950    var proto = NSpell.prototype;
951    proto.correct = require_correct();
952    proto.suggest = require_suggest();
953    proto.spell = require_spell();
954    proto.add = require_add2();
955    proto.remove = require_remove();
956    proto.wordCharacters = require_word_characters();
957    proto.dictionary = require_dictionary2();
958    proto.personal = require_personal();
959    function NSpell(aff, dic) {
960      var index = -1;
961      var dictionaries;
962      if (!(this instanceof NSpell)) {
963        return new NSpell(aff, dic);
964      }
965      if (typeof aff === "string" || buffer(aff)) {
966        if (typeof dic === "string" || buffer(dic)) {
967          dictionaries = [{ dic }];
968        }
969      } else if (aff) {
970        if ("length" in aff) {
971          dictionaries = aff;
972          aff = aff[0] && aff[0].aff;
973        } else {
974          if (aff.dic) {
975            dictionaries = [aff];
976          }
977          aff = aff.aff;
978        }
979      }
980      if (!aff) {
981        throw new Error("Missing `aff` in dictionary");
982      }
983      aff = affix(aff);
984      this.data = /* @__PURE__ */ Object.create(null);
985      this.compoundRuleCodes = aff.compoundRuleCodes;
986      this.replacementTable = aff.replacementTable;
987      this.conversion = aff.conversion;
988      this.compoundRules = aff.compoundRules;
989      this.rules = aff.rules;
990      this.flags = aff.flags;
991      if (dictionaries) {
992        while (++index < dictionaries.length) {
993          if (dictionaries[index].dic) {
994            this.dictionary(dictionaries[index].dic);
995          }
996        }
997      }
998    }
999  }
1000});
1001
1002// ../../../../../../private/var/folders/jv/0bssmjcs5yjcjhnptd_623p80000gn/T/typing-speed-vendor-w3plFi/entry.js
1003var import_nspell = __toESM(require_lib());
1004var export_default = import_nspell.default;
1005export {
1006  export_default as default
1007};
1008
hooks/ui.ts 48 lines
1export type Tier = { icon: string; label: string; color: string; from: number }
2
3const TIERS: readonly Tier[] = [
4  { icon: '⚡', label: 'Lightning', color: '#bb9af7', from: 110 },
5  { icon: '🔥', label: 'On fire', color: '#f7768e', from: 85 },
6  { icon: '🚀', label: 'Rocket', color: '#ff9e64', from: 65 },
7  { icon: '🏃', label: 'Quick', color: '#9ece6a', from: 45 },
8  { icon: '🚶', label: 'Steady', color: '#7dcfff', from: 25 },
9  { icon: '🐢', label: 'Warming up', color: '#7aa2f7', from: 0 },
10]
11
12export const tierOf = (wpm: number): Tier => TIERS.find(t => wpm >= t.from) ?? TIERS[TIERS.length - 1]!
13
14export const RAINBOW = ['#f7768e', '#ff9e64', '#e0af68', '#9ece6a', '#7dcfff', '#bb9af7'] as const
15export const SPINNER = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const
16export const SPARKLES = ['✦', '✧', '⋆', '✶', '✷', '✸'] as const
17const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
18
19export const GAUGE_MAX_WPM = 120
20const BURST_WINDOW_MS = 3_000
21
22export const pick = <T>(list: readonly T[], i: number): T => list[((i % list.length) + list.length) % list.length]!
23
24export function gauge(wpm: number, cells: number): { filled: string; empty: string } {
25  const n = Math.max(0, Math.min(cells, Math.round((wpm / GAUGE_MAX_WPM) * cells)))
26  return { filled: '▰'.repeat(n), empty: '▱'.repeat(cells - n) }
27}
28
29export function sparkline(values: readonly number[], max = Math.max(1, ...values)): string {
30  return values.map(v => BARS[Math.max(0, Math.min(BARS.length - 1, Math.round((v / max) * (BARS.length - 1))))]).join('')
31}
32
33export function burstWpm(keyTimes: readonly number[], now: number): number {
34  const recent = keyTimes.filter(t => now - t <= BURST_WINDOW_MS)
35  if (recent.length < 2) return 0
36  return recent.length / 5 / (BURST_WINDOW_MS / 60_000)
37}
38
39export const easeOut = (t: number) => 1 - Math.pow(1 - Math.max(0, Math.min(1, t)), 3)
40
41const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
42
43export const weekdayOf = (dayKey: string) => WEEKDAYS[new Date(`${dayKey}T12:00:00`).getDay()]!
44
45export function lastDays(dayKeyOf: (ms: number) => string, now: number, count: number): string[] {
46  return Array.from({ length: count }, (_, i) => dayKeyOf(now - (count - 1 - i) * 86_400_000))
47}
48
types/index.d.ts 27 lines
1export type Totals = {
2  prompts: number
3  typedChars: number
4  pastedChars: number
5  deletedChars: number
6  words: number
7  activeMs: number
8  bestWpm: number
9}
10
11export type PromptStats = {
12  typedChars: number
13  pastedChars: number
14  deletedChars: number
15  words: number
16  activeMs: number
17  wpm: number
18  cpm: number
19  accuracy: number
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'typing-speed': { session: Totals; last: PromptStats | null }
25  }
26}
27