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…

A Claude Code mod that measures how fast you type your prompts.
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.
/btw why is this slow? only the arguments are measured; a bare command such as /clear is not measured at all.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.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.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.
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.
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
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).
hooks/register.tsx 575 lines1import { 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}
575hooks/stats.ts 144 lines1import 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}
144hooks/dictionaries.ts 5 lines1// 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
5hooks/spell.ts 160 lines1import { 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}
160hooks/vendor/nspell.js 1008 lines1// 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};
1008hooks/ui.ts 48 lines1export 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}
48types/index.d.ts 27 lines1export 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