@siddhaartha_bs/monkcli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1451 @@
1
+ // apps/cli/src/index.tsx
2
+ import { render } from "ink";
3
+
4
+ // apps/cli/src/App.tsx
5
+ import { Box as Box3, useApp, useInput, useStdout } from "ink";
6
+
7
+ // apps/cli/src/hooks/useTypingController.ts
8
+ import { useCallback, useEffect, useMemo, useState } from "react";
9
+
10
+ // packages/engine/src/generator.ts
11
+ function randomInt(min, max) {
12
+ return Math.floor(Math.random() * (max - min + 1)) + min;
13
+ }
14
+ function randomWord(words) {
15
+ return words[randomInt(0, words.length - 1)];
16
+ }
17
+ function randomDigits(length) {
18
+ let out = "";
19
+ for (let i = 0; i < length; i++) {
20
+ out += randomInt(0, 9).toString();
21
+ }
22
+ return out;
23
+ }
24
+ function capitalize(word) {
25
+ if (word.length === 0) return word;
26
+ return word[0].toUpperCase() + word.slice(1);
27
+ }
28
+ function addPunctuation(word, index, total, sentenceStart) {
29
+ let nextWord = sentenceStart ? capitalize(word) : word;
30
+ const isLast = index === total - 1;
31
+ const roll = Math.random();
32
+ if (isLast || roll < 0.1) {
33
+ nextWord += ".";
34
+ return { word: nextWord, sentenceStart: true };
35
+ }
36
+ if (roll < 0.3) {
37
+ nextWord += ",";
38
+ return { word: nextWord, sentenceStart: false };
39
+ }
40
+ return { word: nextWord, sentenceStart: false };
41
+ }
42
+ function generateWordSequence(dictionary, options) {
43
+ if (dictionary.length === 0) {
44
+ throw new Error("Dictionary is empty");
45
+ }
46
+ const count = Math.max(1, options.count);
47
+ const punctuation = options.punctuation ?? false;
48
+ const numbers = options.numbers ?? false;
49
+ const avoidConsecutiveRepeats = options.avoidConsecutiveRepeats ?? true;
50
+ const out = [];
51
+ let sentenceStart = true;
52
+ for (let i = 0; i < count; i++) {
53
+ let word = randomWord(dictionary).trim();
54
+ if (avoidConsecutiveRepeats && out.length > 0) {
55
+ let retries = 0;
56
+ while (retries < 24 && word === out[out.length - 1]?.replace(/[.,!?]$/, "")) {
57
+ retries++;
58
+ word = randomWord(dictionary).trim();
59
+ }
60
+ }
61
+ if (numbers && Math.random() < 0.1) {
62
+ word = randomDigits(4);
63
+ }
64
+ if (punctuation) {
65
+ const punctuated = addPunctuation(word, i, count, sentenceStart);
66
+ word = punctuated.word;
67
+ sentenceStart = punctuated.sentenceStart;
68
+ }
69
+ out.push(word);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // packages/engine/src/stats.ts
75
+ function splitInputWords(inputText) {
76
+ if (inputText.length === 0) return [];
77
+ const words = inputText.split(" ");
78
+ if (words[words.length - 1] === "") words.pop();
79
+ return words;
80
+ }
81
+ function round2(value) {
82
+ return Math.round(value * 100) / 100;
83
+ }
84
+ function countChars(targetWords, inputWords, includePartialLastWordInWpm) {
85
+ let correctWordChars = 0;
86
+ let allCorrectChars = 0;
87
+ let incorrectChars = 0;
88
+ let extraChars = 0;
89
+ let missedChars = 0;
90
+ let spaces = 0;
91
+ let correctSpaces = 0;
92
+ for (let i = 0; i < inputWords.length; i++) {
93
+ const inputWord = inputWords[i] ?? "";
94
+ const targetWord = targetWords[i] ?? "";
95
+ if (inputWord === targetWord) {
96
+ correctWordChars += targetWord.length;
97
+ allCorrectChars += targetWord.length;
98
+ if (i < inputWords.length - 1) {
99
+ correctSpaces++;
100
+ }
101
+ } else if (inputWord.length >= targetWord.length) {
102
+ for (let c = 0; c < inputWord.length; c++) {
103
+ if (c < targetWord.length) {
104
+ if (inputWord[c] === targetWord[c]) {
105
+ allCorrectChars++;
106
+ } else {
107
+ incorrectChars++;
108
+ }
109
+ } else {
110
+ extraChars++;
111
+ }
112
+ }
113
+ } else {
114
+ const toAdd = { correct: 0, incorrect: 0, missed: 0 };
115
+ for (let c = 0; c < targetWord.length; c++) {
116
+ if (c < inputWord.length) {
117
+ if (inputWord[c] === targetWord[c]) {
118
+ toAdd.correct++;
119
+ } else {
120
+ toAdd.incorrect++;
121
+ }
122
+ } else {
123
+ toAdd.missed++;
124
+ }
125
+ }
126
+ allCorrectChars += toAdd.correct;
127
+ incorrectChars += toAdd.incorrect;
128
+ const isLastInputWord = i === inputWords.length - 1;
129
+ if (isLastInputWord && includePartialLastWordInWpm) {
130
+ if (toAdd.incorrect === 0) {
131
+ correctWordChars += toAdd.correct;
132
+ }
133
+ } else {
134
+ missedChars += toAdd.missed;
135
+ }
136
+ }
137
+ if (i < inputWords.length - 1) {
138
+ spaces++;
139
+ }
140
+ }
141
+ return {
142
+ spaces,
143
+ correctWordChars,
144
+ allCorrectChars,
145
+ incorrectChars,
146
+ extraChars,
147
+ missedChars,
148
+ correctSpaces
149
+ };
150
+ }
151
+ function calculateStats(input) {
152
+ const elapsedSeconds = Math.max(input.elapsedSeconds, 1e-3);
153
+ const inputWords = splitInputWords(input.inputText);
154
+ const chars = countChars(
155
+ input.targetWords,
156
+ inputWords,
157
+ input.includePartialLastWordInWpm
158
+ );
159
+ const wpm = round2(
160
+ (chars.correctWordChars + chars.correctSpaces) * (60 / elapsedSeconds) / 5
161
+ );
162
+ const rawWpm = round2(
163
+ (chars.allCorrectChars + chars.spaces + chars.incorrectChars + chars.extraChars) * (60 / elapsedSeconds) / 5
164
+ );
165
+ const accuracyFromKeystrokes = input.accuracyCounts.correct / (input.accuracyCounts.correct + input.accuracyCounts.incorrect || 1) * 100;
166
+ const accuracy = round2(
167
+ Number.isFinite(accuracyFromKeystrokes) ? accuracyFromKeystrokes : 100
168
+ );
169
+ return {
170
+ wpm: Number.isFinite(wpm) ? wpm : 0,
171
+ rawWpm: Number.isFinite(rawWpm) ? rawWpm : 0,
172
+ accuracy,
173
+ elapsedSeconds: round2(elapsedSeconds),
174
+ chars,
175
+ accuracyCounts: input.accuracyCounts
176
+ };
177
+ }
178
+
179
+ // packages/engine/src/session.ts
180
+ var TypingSession = class {
181
+ mode;
182
+ durationSeconds;
183
+ targetWords;
184
+ targetText;
185
+ createdAt;
186
+ startedAt;
187
+ endedAt;
188
+ inputText = "";
189
+ accuracy = { correct: 0, incorrect: 0 };
190
+ constructor(init) {
191
+ this.mode = init.mode;
192
+ this.durationSeconds = Math.max(1, init.durationSeconds);
193
+ this.targetWords = init.targetWords;
194
+ this.targetText = this.targetWords.join(" ");
195
+ this.createdAt = init.startedAt ?? Date.now();
196
+ }
197
+ get input() {
198
+ return this.inputText;
199
+ }
200
+ get isFinished() {
201
+ return this.endedAt !== void 0;
202
+ }
203
+ ensureStarted(now) {
204
+ if (this.startedAt === void 0) {
205
+ this.startedAt = now;
206
+ }
207
+ }
208
+ applyCharacter(char, now = Date.now()) {
209
+ if (!char || this.isFinished) return;
210
+ this.ensureStarted(now);
211
+ const expected = this.targetText[this.inputText.length] ?? "";
212
+ if (char === expected) {
213
+ this.accuracy.correct++;
214
+ } else {
215
+ this.accuracy.incorrect++;
216
+ }
217
+ this.inputText += char;
218
+ }
219
+ backspace() {
220
+ if (this.isFinished) return;
221
+ if (this.inputText.length === 0) return;
222
+ this.inputText = this.inputText.slice(0, -1);
223
+ }
224
+ getElapsedSeconds(now = Date.now()) {
225
+ const start = this.startedAt ?? this.createdAt;
226
+ const end = this.endedAt ?? now;
227
+ return Math.max(0, (end - start) / 1e3);
228
+ }
229
+ getRemainingSeconds(now = Date.now()) {
230
+ if (this.mode !== "time") return 0;
231
+ const remaining = this.durationSeconds - this.getElapsedSeconds(now);
232
+ return Math.max(0, remaining);
233
+ }
234
+ hasCompletedWordTarget() {
235
+ if (this.mode !== "words") return false;
236
+ const typedWords = this.inputText.trim().length > 0 ? this.inputText.trim().split(/\s+/).length : 0;
237
+ const submitted = this.inputText.endsWith(" ") || this.inputText === this.targetText;
238
+ return submitted && typedWords >= this.targetWords.length;
239
+ }
240
+ hasTypedFinalWordCorrectly() {
241
+ if (this.mode !== "words") return false;
242
+ const typedWords = this.inputText.trim().split(/\s+/).filter((word) => word.length > 0);
243
+ if (typedWords.length !== this.targetWords.length) return false;
244
+ const typedLastWord = typedWords[typedWords.length - 1];
245
+ const targetLastWord = this.targetWords[this.targetWords.length - 1];
246
+ return typedLastWord === targetLastWord;
247
+ }
248
+ hasCompletedQuoteTarget() {
249
+ if (this.mode !== "quote") return false;
250
+ return this.inputText === this.targetText;
251
+ }
252
+ shouldAutoFinish(now = Date.now()) {
253
+ if (this.isFinished) return false;
254
+ if (this.mode === "time") {
255
+ if (this.startedAt === void 0) return false;
256
+ return this.getElapsedSeconds(now) >= this.durationSeconds;
257
+ }
258
+ if (this.mode === "words") {
259
+ return this.hasCompletedWordTarget() || this.hasTypedFinalWordCorrectly();
260
+ }
261
+ return this.hasCompletedQuoteTarget();
262
+ }
263
+ finish(now = Date.now()) {
264
+ if (this.isFinished) return;
265
+ this.ensureStarted(now);
266
+ this.endedAt = now;
267
+ }
268
+ getProgress(now = Date.now()) {
269
+ return calculateStats({
270
+ targetWords: this.targetWords,
271
+ inputText: this.inputText,
272
+ elapsedSeconds: this.getElapsedSeconds(now),
273
+ accuracyCounts: this.accuracy,
274
+ includePartialLastWordInWpm: this.mode !== "words"
275
+ });
276
+ }
277
+ buildResult(now = Date.now()) {
278
+ this.finish(now);
279
+ const progress = this.getProgress(now);
280
+ return {
281
+ ...progress,
282
+ mode: this.mode,
283
+ targetWords: this.targetWords,
284
+ targetText: this.targetText,
285
+ inputText: this.inputText,
286
+ completedAt: new Date(now).toISOString()
287
+ };
288
+ }
289
+ };
290
+
291
+ // packages/engine/src/word-source.ts
292
+ import { existsSync, promises as fs } from "node:fs";
293
+ import path from "node:path";
294
+ import { fileURLToPath } from "node:url";
295
+ function normalizeLanguageDataset(raw, fallbackName) {
296
+ if (Array.isArray(raw)) {
297
+ const words = raw.filter((entry) => typeof entry === "string");
298
+ return {
299
+ name: fallbackName,
300
+ words,
301
+ orderedByFrequency: false
302
+ };
303
+ }
304
+ if (raw && typeof raw === "object") {
305
+ const obj = raw;
306
+ const words = Array.isArray(obj.words) ? obj.words.filter((entry) => typeof entry === "string") : [];
307
+ return {
308
+ name: typeof obj.name === "string" ? obj.name : fallbackName,
309
+ words,
310
+ orderedByFrequency: typeof obj.orderedByFrequency === "boolean" ? obj.orderedByFrequency : void 0
311
+ };
312
+ }
313
+ return {
314
+ name: fallbackName,
315
+ words: []
316
+ };
317
+ }
318
+ function resolveEngineDataDir(engineDataDir) {
319
+ const envDir = process.env.MONKCLI_ENGINE_DATA_DIR;
320
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
321
+ const moduleRelative = path.resolve(moduleDir, "../../../engine-data");
322
+ const candidates = [
323
+ engineDataDir,
324
+ envDir,
325
+ path.resolve(process.cwd(), "engine-data"),
326
+ path.resolve(process.cwd(), "../engine-data"),
327
+ path.resolve(process.cwd(), "../../engine-data"),
328
+ moduleRelative
329
+ ].filter((candidate) => !!candidate);
330
+ for (const candidate of candidates) {
331
+ const probe = path.join(candidate, "frontend", "static", "languages");
332
+ if (existsSync(probe)) {
333
+ return candidate;
334
+ }
335
+ }
336
+ throw new Error(
337
+ "Unable to resolve engine-data directory. Set MONKCLI_ENGINE_DATA_DIR or run from monkcli root."
338
+ );
339
+ }
340
+ async function loadLanguageDataset(language, options = {}) {
341
+ const engineDataDir = resolveEngineDataDir(options.engineDataDir);
342
+ const filePath = path.join(
343
+ engineDataDir,
344
+ "frontend",
345
+ "static",
346
+ "languages",
347
+ `${language}.json`
348
+ );
349
+ const raw = await fs.readFile(filePath, "utf8");
350
+ const parsed = normalizeLanguageDataset(JSON.parse(raw), language);
351
+ if (!Array.isArray(parsed.words) || parsed.words.length === 0) {
352
+ throw new Error(`Language dataset ${language} has no words`);
353
+ }
354
+ return parsed;
355
+ }
356
+ async function loadLanguageWords(language, options = {}) {
357
+ const dataset = await loadLanguageDataset(language, options);
358
+ return dataset.words;
359
+ }
360
+ async function listAvailableLanguages(options = {}) {
361
+ const engineDataDir = resolveEngineDataDir(options.engineDataDir);
362
+ const languagesDir = path.join(engineDataDir, "frontend", "static", "languages");
363
+ const entries = await fs.readdir(languagesDir, { withFileTypes: true });
364
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name.replace(/\.json$/, "")).sort();
365
+ }
366
+ async function loadQuoteDataset(language, options = {}) {
367
+ const engineDataDir = resolveEngineDataDir(options.engineDataDir);
368
+ const filePath = path.join(
369
+ engineDataDir,
370
+ "frontend",
371
+ "static",
372
+ "quotes",
373
+ `${language}.json`
374
+ );
375
+ const raw = await fs.readFile(filePath, "utf8");
376
+ const parsed = JSON.parse(raw);
377
+ if (!Array.isArray(parsed.quotes) || parsed.quotes.length === 0) {
378
+ throw new Error(`Quote dataset ${language} has no quotes`);
379
+ }
380
+ return parsed;
381
+ }
382
+ async function loadQuoteTexts(language, options = {}) {
383
+ const dataset = await loadQuoteDataset(language, options);
384
+ return dataset.quotes.map((quote) => quote.text).filter((text) => typeof text === "string" && text.trim().length > 0);
385
+ }
386
+
387
+ // packages/storage-local/src/index.ts
388
+ import { existsSync as existsSync2, promises as fs2 } from "node:fs";
389
+ import os from "node:os";
390
+ import path2 from "node:path";
391
+ var CURRENT_VERSION = 1;
392
+ var APP_NAME = "monkcli";
393
+ var EMPTY_STATS = {
394
+ totalTests: 0,
395
+ sumWpm: 0,
396
+ sumAccuracy: 0,
397
+ averageWpm: 0,
398
+ averageAccuracy: 0
399
+ };
400
+ function resolveConfigDir() {
401
+ if (process.env.MONKCLI_CONFIG_DIR) {
402
+ return process.env.MONKCLI_CONFIG_DIR;
403
+ }
404
+ if (process.platform === "win32") {
405
+ const appData = process.env.APPDATA ?? path2.join(os.homedir(), "AppData", "Roaming");
406
+ return path2.join(appData, APP_NAME);
407
+ }
408
+ if (process.platform === "darwin") {
409
+ return path2.join(os.homedir(), "Library", "Application Support", APP_NAME);
410
+ }
411
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path2.join(os.homedir(), ".config");
412
+ return path2.join(xdgConfig, APP_NAME);
413
+ }
414
+ function resolveDataDir() {
415
+ if (process.env.MONKCLI_DATA_DIR) {
416
+ return process.env.MONKCLI_DATA_DIR;
417
+ }
418
+ if (process.platform === "win32") {
419
+ const localAppData = process.env.LOCALAPPDATA ?? path2.join(os.homedir(), "AppData", "Local");
420
+ return path2.join(localAppData, APP_NAME);
421
+ }
422
+ if (process.platform === "darwin") {
423
+ return path2.join(os.homedir(), "Library", "Application Support", APP_NAME);
424
+ }
425
+ const xdgState = process.env.XDG_STATE_HOME ?? path2.join(os.homedir(), ".local", "state");
426
+ return path2.join(xdgState, APP_NAME);
427
+ }
428
+ function resolveLegacyDir() {
429
+ return path2.join(os.homedir(), ".monkcli");
430
+ }
431
+ function resolveResultsFilePath(customPath) {
432
+ if (customPath) return customPath;
433
+ return path2.join(resolveDataDir(), "results.json");
434
+ }
435
+ function resolveStatsFilePath(customPath) {
436
+ if (customPath) return customPath;
437
+ return path2.join(resolveDataDir(), "stats.json");
438
+ }
439
+ function resolveSettingsFilePath(customPath) {
440
+ if (customPath) return customPath;
441
+ return path2.join(resolveConfigDir(), "settings.json");
442
+ }
443
+ function resolveLegacyResultsFilePath() {
444
+ return path2.join(resolveLegacyDir(), "results.json");
445
+ }
446
+ function resolveLegacySettingsFilePath() {
447
+ return path2.join(resolveLegacyDir(), "settings.json");
448
+ }
449
+ async function ensureParent(filePath) {
450
+ await fs2.mkdir(path2.dirname(filePath), { recursive: true });
451
+ }
452
+ function mergeSettings(defaultSettings, stored) {
453
+ if (!stored || typeof stored !== "object") return defaultSettings;
454
+ const maybeSettings = stored.settings;
455
+ if (!maybeSettings || typeof maybeSettings !== "object") return defaultSettings;
456
+ return {
457
+ language: maybeSettings.language ?? defaultSettings.language,
458
+ mode: maybeSettings.mode ?? defaultSettings.mode,
459
+ wordTarget: maybeSettings.wordTarget ?? defaultSettings.wordTarget,
460
+ timeTargetSeconds: maybeSettings.timeTargetSeconds ?? defaultSettings.timeTargetSeconds
461
+ };
462
+ }
463
+ function normalizeStats(raw) {
464
+ if (!raw || typeof raw !== "object") return EMPTY_STATS;
465
+ const candidate = raw;
466
+ const totalTests = Number.isFinite(candidate.totalTests) ? Math.max(0, Number(candidate.totalTests)) : 0;
467
+ const sumWpm = Number.isFinite(candidate.sumWpm) ? Math.max(0, Number(candidate.sumWpm)) : 0;
468
+ const sumAccuracy = Number.isFinite(candidate.sumAccuracy) ? Math.max(0, Number(candidate.sumAccuracy)) : 0;
469
+ if (totalTests === 0) return EMPTY_STATS;
470
+ const averageWpm = sumWpm / totalTests;
471
+ const averageAccuracy = sumAccuracy / totalTests;
472
+ return {
473
+ totalTests,
474
+ sumWpm,
475
+ sumAccuracy,
476
+ averageWpm,
477
+ averageAccuracy
478
+ };
479
+ }
480
+ function calculateStatsFromHistory(history) {
481
+ if (history.length === 0) return EMPTY_STATS;
482
+ let sumWpm = 0;
483
+ let sumAccuracy = 0;
484
+ for (const result of history) {
485
+ sumWpm += result.wpm;
486
+ sumAccuracy += result.accuracy;
487
+ }
488
+ const totalTests = history.length;
489
+ return {
490
+ totalTests,
491
+ sumWpm,
492
+ sumAccuracy,
493
+ averageWpm: sumWpm / totalTests,
494
+ averageAccuracy: sumAccuracy / totalTests
495
+ };
496
+ }
497
+ async function writeStats(stats, customPath) {
498
+ const filePath = resolveStatsFilePath(customPath);
499
+ const payload = {
500
+ version: CURRENT_VERSION,
501
+ stats
502
+ };
503
+ await ensureParent(filePath);
504
+ await fs2.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
505
+ }
506
+ async function readStoredResults(customPath) {
507
+ const filePath = resolveResultsFilePath(customPath);
508
+ try {
509
+ const raw = await fs2.readFile(filePath, "utf8");
510
+ const parsed = JSON.parse(raw);
511
+ if (!Array.isArray(parsed.results)) return [];
512
+ return parsed.results;
513
+ } catch (error) {
514
+ const err = error;
515
+ if (err.code === "ENOENT" && !customPath) {
516
+ const legacyPath = resolveLegacyResultsFilePath();
517
+ if (!existsSync2(legacyPath)) return [];
518
+ const rawLegacy = await fs2.readFile(legacyPath, "utf8");
519
+ const parsedLegacy = JSON.parse(rawLegacy);
520
+ return Array.isArray(parsedLegacy.results) ? parsedLegacy.results : [];
521
+ }
522
+ if (err.code === "ENOENT") return [];
523
+ throw error;
524
+ }
525
+ }
526
+ async function readStoredStats(customPath) {
527
+ const filePath = resolveStatsFilePath(customPath);
528
+ try {
529
+ const raw = await fs2.readFile(filePath, "utf8");
530
+ const parsed = JSON.parse(raw);
531
+ return normalizeStats(parsed.stats);
532
+ } catch (error) {
533
+ const err = error;
534
+ if (err.code !== "ENOENT") {
535
+ throw error;
536
+ }
537
+ const history = await readStoredResults();
538
+ const derived = calculateStatsFromHistory(history);
539
+ if (derived.totalTests > 0) {
540
+ await writeStats(derived, customPath);
541
+ }
542
+ return derived;
543
+ }
544
+ }
545
+ async function saveResultAndUpdateStats(result, customResultsPath, customStatsPath) {
546
+ const filePath = resolveResultsFilePath(customResultsPath);
547
+ const existing = await readStoredResults(filePath);
548
+ const payload = {
549
+ version: CURRENT_VERSION,
550
+ results: [result, ...existing]
551
+ };
552
+ await ensureParent(filePath);
553
+ await fs2.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
554
+ const currentStats = await readStoredStats(customStatsPath);
555
+ const totalTests = currentStats.totalTests + 1;
556
+ const sumWpm = currentStats.sumWpm + result.wpm;
557
+ const sumAccuracy = currentStats.sumAccuracy + result.accuracy;
558
+ const nextStats = {
559
+ totalTests,
560
+ sumWpm,
561
+ sumAccuracy,
562
+ averageWpm: sumWpm / totalTests,
563
+ averageAccuracy: sumAccuracy / totalTests
564
+ };
565
+ await writeStats(nextStats, customStatsPath);
566
+ return nextStats;
567
+ }
568
+ async function readStoredSettings(defaultSettings, customPath) {
569
+ const filePath = resolveSettingsFilePath(customPath);
570
+ try {
571
+ const raw = await fs2.readFile(filePath, "utf8");
572
+ const parsed = JSON.parse(raw);
573
+ return mergeSettings(defaultSettings, parsed);
574
+ } catch (error) {
575
+ const err = error;
576
+ if (err.code === "ENOENT" && !customPath) {
577
+ const legacyPath = resolveLegacySettingsFilePath();
578
+ if (!existsSync2(legacyPath)) return defaultSettings;
579
+ const rawLegacy = await fs2.readFile(legacyPath, "utf8");
580
+ const parsedLegacy = JSON.parse(rawLegacy);
581
+ return mergeSettings(defaultSettings, parsedLegacy);
582
+ }
583
+ if (err.code === "ENOENT") {
584
+ return defaultSettings;
585
+ }
586
+ throw error;
587
+ }
588
+ }
589
+ async function saveSettings(settings, customPath) {
590
+ const filePath = resolveSettingsFilePath(customPath);
591
+ const payload = {
592
+ version: CURRENT_VERSION,
593
+ settings
594
+ };
595
+ await ensureParent(filePath);
596
+ await fs2.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
597
+ }
598
+
599
+ // apps/cli/src/config.ts
600
+ import path3 from "node:path";
601
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
602
+ var DEFAULT_LANGUAGE = "english";
603
+ var WORD_TARGET_OPTIONS = [10, 20, 50, 100];
604
+ var TIME_TARGET_OPTIONS = [15, 30, 60, 120];
605
+ var TIME_MODE_WORD_POOL = 120;
606
+ var DEFAULT_SETTINGS = {
607
+ language: DEFAULT_LANGUAGE,
608
+ mode: "time",
609
+ wordTarget: 10,
610
+ timeTargetSeconds: 30
611
+ };
612
+ var APP_DIR = path3.dirname(fileURLToPath2(import.meta.url));
613
+ var ENGINE_DATA_DIR = path3.resolve(APP_DIR, "../../../engine-data");
614
+
615
+ // apps/cli/src/hooks/useTypingController.ts
616
+ var MODE_OPTIONS = ["time", "words", "quote"];
617
+ function wrapIndex(index, total) {
618
+ if (total <= 0) return 0;
619
+ return (index % total + total) % total;
620
+ }
621
+ function cycleValue(values, current, delta) {
622
+ if (values.length === 0) return current;
623
+ const currentIndex = values.indexOf(current);
624
+ const baseIndex = currentIndex >= 0 ? currentIndex : 0;
625
+ return values[wrapIndex(baseIndex + delta, values.length)];
626
+ }
627
+ function languageRank(language) {
628
+ if (language === "english") return 0;
629
+ const match = language.match(/^english_(\d+)k$/);
630
+ if (!match) return Number.MAX_SAFE_INTEGER;
631
+ return Number(match[1]);
632
+ }
633
+ function getEnglishLanguageOptions(languages) {
634
+ const englishOnly = languages.filter(
635
+ (language) => language === "english" || /^english_\d+k$/.test(language)
636
+ );
637
+ const source = englishOnly.length > 0 ? englishOnly : languages;
638
+ return [...source].sort((a, b) => {
639
+ const rankDiff = languageRank(a) - languageRank(b);
640
+ if (Number.isFinite(rankDiff) && rankDiff !== 0) return rankDiff;
641
+ return a.localeCompare(b);
642
+ });
643
+ }
644
+ function normalizeSettings(stored, availableLanguages) {
645
+ return {
646
+ language: availableLanguages.includes(stored.language) ? stored.language : availableLanguages[0] ?? DEFAULT_SETTINGS.language,
647
+ mode: MODE_OPTIONS.includes(stored.mode) ? stored.mode : DEFAULT_SETTINGS.mode,
648
+ wordTarget: WORD_TARGET_OPTIONS.some((option) => option === stored.wordTarget) ? stored.wordTarget : DEFAULT_SETTINGS.wordTarget,
649
+ timeTargetSeconds: TIME_TARGET_OPTIONS.some((option) => option === stored.timeTargetSeconds) ? stored.timeTargetSeconds : DEFAULT_SETTINGS.timeTargetSeconds
650
+ };
651
+ }
652
+ function useTypingController() {
653
+ const [phase, setPhase] = useState("loading");
654
+ const [mode, setMode] = useState(DEFAULT_SETTINGS.mode);
655
+ const [menuCursor, setMenuCursor] = useState(0);
656
+ const [language, setLanguage] = useState(DEFAULT_SETTINGS.language);
657
+ const [availableLanguages, setAvailableLanguages] = useState([]);
658
+ const [dictionaries, setDictionaries] = useState({});
659
+ const [quotes, setQuotes] = useState([]);
660
+ const [wordTarget, setWordTarget] = useState(DEFAULT_SETTINGS.wordTarget);
661
+ const [timeTargetSeconds, setTimeTargetSeconds] = useState(
662
+ DEFAULT_SETTINGS.timeTargetSeconds
663
+ );
664
+ const [session, setSession] = useState(null);
665
+ const [now, setNow] = useState(Date.now());
666
+ const [inputVersion, setInputVersion] = useState(0);
667
+ const [lastResult, setLastResult] = useState(null);
668
+ const [historyCount, setHistoryCount] = useState(0);
669
+ const [averageWpm, setAverageWpm] = useState(0);
670
+ const [averageAccuracy, setAverageAccuracy] = useState(0);
671
+ const [errorMessage, setErrorMessage] = useState("");
672
+ const [isBootstrapped, setIsBootstrapped] = useState(false);
673
+ const [isFinalizing, setIsFinalizing] = useState(false);
674
+ const resultsFilePath = useMemo(() => resolveResultsFilePath(), []);
675
+ const settingsFilePath = useMemo(() => resolveSettingsFilePath(), []);
676
+ const statsFilePath = useMemo(() => resolveStatsFilePath(), []);
677
+ const menuItemCount = mode === "quote" ? 2 : 3;
678
+ useEffect(() => {
679
+ let cancelled = false;
680
+ const bootstrap = async () => {
681
+ try {
682
+ const [languages, stats, storedSettings, loadedQuotes] = await Promise.all([
683
+ listAvailableLanguages({ engineDataDir: ENGINE_DATA_DIR }),
684
+ readStoredStats(),
685
+ readStoredSettings(DEFAULT_SETTINGS),
686
+ loadQuoteTexts("english", { engineDataDir: ENGINE_DATA_DIR }).catch(() => [])
687
+ ]);
688
+ if (cancelled) return;
689
+ const languageOptions = getEnglishLanguageOptions(languages);
690
+ if (languageOptions.length === 0) {
691
+ throw new Error("No language datasets found in engine-data");
692
+ }
693
+ const dictionarySettled = await Promise.allSettled(
694
+ languageOptions.map(async (languageName) => {
695
+ const words = await loadLanguageWords(languageName, {
696
+ engineDataDir: ENGINE_DATA_DIR
697
+ });
698
+ return [languageName, words.map((word) => word.toLowerCase())];
699
+ })
700
+ );
701
+ if (cancelled) return;
702
+ const successfulEntries = [];
703
+ for (const entry of dictionarySettled) {
704
+ if (entry.status === "fulfilled") {
705
+ successfulEntries.push(entry.value);
706
+ }
707
+ }
708
+ const dictionaryMap = Object.fromEntries(successfulEntries);
709
+ const validLanguages = languageOptions.filter(
710
+ (languageName) => (dictionaryMap[languageName]?.length ?? 0) > 0
711
+ );
712
+ if (validLanguages.length === 0) {
713
+ throw new Error("No valid language datasets found in engine-data");
714
+ }
715
+ const normalized = normalizeSettings(storedSettings, validLanguages);
716
+ setAvailableLanguages(validLanguages);
717
+ setDictionaries(dictionaryMap);
718
+ setQuotes(loadedQuotes);
719
+ setLanguage(normalized.language);
720
+ setMode(normalized.mode);
721
+ setWordTarget(normalized.wordTarget);
722
+ setTimeTargetSeconds(normalized.timeTargetSeconds);
723
+ setHistoryCount(stats.totalTests);
724
+ setAverageWpm(stats.averageWpm);
725
+ setAverageAccuracy(stats.averageAccuracy);
726
+ setPhase("menu");
727
+ setIsBootstrapped(true);
728
+ } catch (error) {
729
+ if (cancelled) return;
730
+ setErrorMessage(error instanceof Error ? error.message : String(error));
731
+ setPhase("error");
732
+ }
733
+ };
734
+ void bootstrap();
735
+ return () => {
736
+ cancelled = true;
737
+ };
738
+ }, []);
739
+ useEffect(() => {
740
+ if (!isBootstrapped) return;
741
+ const settings = {
742
+ language,
743
+ mode,
744
+ wordTarget,
745
+ timeTargetSeconds
746
+ };
747
+ void saveSettings(settings).catch(() => void 0);
748
+ }, [isBootstrapped, language, mode, wordTarget, timeTargetSeconds]);
749
+ useEffect(() => {
750
+ setMenuCursor((prev) => Math.min(prev, menuItemCount - 1));
751
+ }, [menuItemCount]);
752
+ const moveMenuCursor = useCallback(
753
+ (delta) => {
754
+ setMenuCursor((prev) => wrapIndex(prev + delta, menuItemCount));
755
+ },
756
+ [menuItemCount]
757
+ );
758
+ const adjustFocusedSetting = useCallback(
759
+ (delta) => {
760
+ if (menuCursor === 0) {
761
+ setLanguage((prev) => cycleValue(availableLanguages, prev, delta));
762
+ return;
763
+ }
764
+ if (menuCursor === 1) {
765
+ setMode((prev) => cycleValue(MODE_OPTIONS, prev, delta));
766
+ return;
767
+ }
768
+ if (menuCursor === 2 && mode === "time") {
769
+ setTimeTargetSeconds((prev) => cycleValue(TIME_TARGET_OPTIONS, prev, delta));
770
+ }
771
+ if (menuCursor === 2 && mode === "words") {
772
+ setWordTarget((prev) => cycleValue(WORD_TARGET_OPTIONS, prev, delta));
773
+ }
774
+ },
775
+ [availableLanguages, menuCursor, mode]
776
+ );
777
+ const startSession = useCallback(() => {
778
+ if (mode === "quote") {
779
+ if (quotes.length === 0) {
780
+ setErrorMessage("No quotes available for quote mode");
781
+ setPhase("error");
782
+ return;
783
+ }
784
+ const quote = quotes[Math.floor(Math.random() * quotes.length)];
785
+ const targetWords = quote.trim().split(/\s+/).filter((word) => word.length > 0);
786
+ if (targetWords.length === 0) {
787
+ setErrorMessage("Selected quote was empty");
788
+ setPhase("error");
789
+ return;
790
+ }
791
+ const next2 = new TypingSession({
792
+ mode,
793
+ durationSeconds: timeTargetSeconds,
794
+ targetWords
795
+ });
796
+ setSession(next2);
797
+ setNow(Date.now());
798
+ setPhase("running");
799
+ return;
800
+ }
801
+ const dictionary = dictionaries[language] ?? [];
802
+ if (dictionary.length === 0) {
803
+ setErrorMessage(`No words loaded for ${language}. Check engine-data path: ${ENGINE_DATA_DIR}`);
804
+ setPhase("error");
805
+ return;
806
+ }
807
+ const generated = generateWordSequence(dictionary, {
808
+ count: mode === "time" ? TIME_MODE_WORD_POOL : wordTarget,
809
+ punctuation: false,
810
+ numbers: false,
811
+ avoidConsecutiveRepeats: true
812
+ });
813
+ const next = new TypingSession({
814
+ mode,
815
+ durationSeconds: timeTargetSeconds,
816
+ targetWords: mode === "words" ? generated.slice(0, wordTarget) : generated
817
+ });
818
+ setSession(next);
819
+ setNow(Date.now());
820
+ setPhase("running");
821
+ }, [dictionaries, language, mode, quotes, timeTargetSeconds, wordTarget]);
822
+ const finalizeSession = useCallback(async () => {
823
+ if (!session || isFinalizing) return;
824
+ setIsFinalizing(true);
825
+ setPhase("saving");
826
+ try {
827
+ const result = session.buildResult(Date.now());
828
+ const nextStats = await saveResultAndUpdateStats(result);
829
+ setHistoryCount(nextStats.totalTests);
830
+ setAverageWpm(nextStats.averageWpm);
831
+ setAverageAccuracy(nextStats.averageAccuracy);
832
+ setLastResult(result);
833
+ setPhase("result");
834
+ } catch (error) {
835
+ setErrorMessage(error instanceof Error ? error.message : String(error));
836
+ setPhase("error");
837
+ } finally {
838
+ setIsFinalizing(false);
839
+ }
840
+ }, [isFinalizing, session]);
841
+ useEffect(() => {
842
+ if (phase !== "running" || !session) return;
843
+ const interval = setInterval(() => {
844
+ const tickNow = Date.now();
845
+ setNow(tickNow);
846
+ if (session.shouldAutoFinish(tickNow)) {
847
+ void finalizeSession();
848
+ }
849
+ }, 1e3);
850
+ return () => clearInterval(interval);
851
+ }, [finalizeSession, phase, session]);
852
+ const applyBackspace = useCallback(() => {
853
+ if (phase !== "running" || !session) return;
854
+ session.backspace();
855
+ setInputVersion((v) => v + 1);
856
+ }, [phase, session]);
857
+ const applyInputText = useCallback(
858
+ (input) => {
859
+ if (phase !== "running" || !session) return;
860
+ if (!input) return;
861
+ const chars = [...input];
862
+ const ts = Date.now();
863
+ for (const char of chars) {
864
+ session.applyCharacter(char, ts);
865
+ }
866
+ setInputVersion((v) => v + 1);
867
+ if (session.shouldAutoFinish(ts)) {
868
+ void finalizeSession();
869
+ }
870
+ },
871
+ [finalizeSession, phase, session]
872
+ );
873
+ const progress = useMemo(() => {
874
+ if (!session) return null;
875
+ return session.getProgress(now);
876
+ }, [now, session]);
877
+ const targetText = useMemo(() => session?.targetText ?? "", [session]);
878
+ const inputText = useMemo(() => {
879
+ void inputVersion;
880
+ return session?.input ?? "";
881
+ }, [inputVersion, session]);
882
+ const extraTyped = session && session.input.length > session.targetText.length ? session.input.slice(session.targetText.length) : "";
883
+ const remainingSeconds = session?.getRemainingSeconds(now) ?? 0;
884
+ const dictionarySize = dictionaries[language]?.length ?? 0;
885
+ return {
886
+ phase,
887
+ mode,
888
+ menuCursor,
889
+ menuItemCount,
890
+ language,
891
+ availableLanguages,
892
+ wordTarget,
893
+ wordTargetOptions: WORD_TARGET_OPTIONS,
894
+ timeTargetSeconds,
895
+ timeTargetOptions: TIME_TARGET_OPTIONS,
896
+ quoteCount: quotes.length,
897
+ dictionarySize,
898
+ historyCount,
899
+ averageWpm,
900
+ averageAccuracy,
901
+ errorMessage,
902
+ lastResult,
903
+ progress,
904
+ targetText,
905
+ inputText,
906
+ extraTyped,
907
+ remainingSeconds,
908
+ hasRunningSession: phase === "running" && session !== null,
909
+ engineDataDir: ENGINE_DATA_DIR,
910
+ resultsFilePath,
911
+ settingsFilePath,
912
+ statsFilePath,
913
+ moveMenuCursor,
914
+ adjustFocusedSetting,
915
+ startSession,
916
+ goToMenu: () => {
917
+ setSession(null);
918
+ setPhase("menu");
919
+ },
920
+ applyBackspace,
921
+ applyInputText
922
+ };
923
+ }
924
+
925
+ // apps/cli/src/components/screens.tsx
926
+ import os2 from "node:os";
927
+ import { Box as Box2, Text as Text2 } from "ink";
928
+
929
+ // apps/cli/src/theme.ts
930
+ function parseThemeMode(value) {
931
+ if (!value) return "auto";
932
+ const normalized = value.trim().toLowerCase();
933
+ if (normalized === "high-contrast") return "high-contrast";
934
+ if (normalized === "mono" || normalized === "no-color") return "mono";
935
+ return "auto";
936
+ }
937
+ var THEME_MODE = parseThemeMode(process.env.MONKCLI_THEME);
938
+ var noColorEnv = process.env.NO_COLOR !== void 0 || process.env.MONKCLI_NO_COLOR === "1" || process.env.MONKCLI_NO_COLOR === "true";
939
+ var colorDepth = typeof process.stdout.getColorDepth === "function" ? process.stdout.getColorDepth() : 1;
940
+ var ttySupportsColor = Boolean(process.stdout.isTTY) && colorDepth >= 4;
941
+ var COLORS_ENABLED = THEME_MODE !== "mono" && ttySupportsColor && !noColorEnv;
942
+ var HIGH_CONTRAST = THEME_MODE === "high-contrast" || !COLORS_ENABLED;
943
+
944
+ // apps/cli/src/components/TargetTextView.tsx
945
+ import { Box, Text } from "ink";
946
+ import { jsx, jsxs } from "react/jsx-runtime";
947
+ var LEFT_WINDOW = 28;
948
+ var RIGHT_WINDOW = 52;
949
+ function styleCorrect(char) {
950
+ return {
951
+ char,
952
+ bold: true
953
+ };
954
+ }
955
+ function styleIncorrect(char) {
956
+ return {
957
+ char,
958
+ color: COLORS_ENABLED ? "red" : void 0,
959
+ bold: true,
960
+ inverse: HIGH_CONTRAST,
961
+ underline: true
962
+ };
963
+ }
964
+ function styleUpcoming(char) {
965
+ return {
966
+ char,
967
+ color: COLORS_ENABLED ? "gray" : void 0,
968
+ dimColor: !HIGH_CONTRAST,
969
+ bold: false
970
+ };
971
+ }
972
+ function styleCurrent(char) {
973
+ return {
974
+ char,
975
+ color: COLORS_ENABLED ? "cyan" : void 0,
976
+ bold: true,
977
+ underline: HIGH_CONTRAST
978
+ };
979
+ }
980
+ function buildPastChars(targetText, inputText, pointer) {
981
+ const start = Math.max(0, pointer - LEFT_WINDOW);
982
+ const typedSlice = inputText.slice(start, pointer);
983
+ const visible = [];
984
+ for (let i = 0; i < typedSlice.length; i++) {
985
+ const char = typedSlice[i];
986
+ const absoluteIndex = start + i;
987
+ if (absoluteIndex < targetText.length) {
988
+ const isCorrect = char === targetText[absoluteIndex];
989
+ visible.push(isCorrect ? styleCorrect(char) : styleIncorrect(char));
990
+ } else {
991
+ visible.push(styleIncorrect(char));
992
+ }
993
+ }
994
+ if (start > 0 && visible.length > 0) {
995
+ visible[0] = {
996
+ char: "\u2026",
997
+ color: COLORS_ENABLED ? "gray" : void 0,
998
+ dimColor: !HIGH_CONTRAST,
999
+ bold: HIGH_CONTRAST
1000
+ };
1001
+ }
1002
+ const padding = Array.from({ length: LEFT_WINDOW - visible.length }, () => ({
1003
+ char: " "
1004
+ }));
1005
+ return [...padding, ...visible];
1006
+ }
1007
+ function buildFutureChars(targetText, pointer) {
1008
+ const end = Math.min(targetText.length, pointer + RIGHT_WINDOW);
1009
+ const visible = [];
1010
+ for (let i = pointer; i < end; i++) {
1011
+ const char = targetText[i];
1012
+ visible.push(i === pointer ? styleCurrent(char) : styleUpcoming(char));
1013
+ }
1014
+ if (end < targetText.length && visible.length > 0) {
1015
+ visible[visible.length - 1] = {
1016
+ char: "\u2026",
1017
+ color: COLORS_ENABLED ? "gray" : void 0,
1018
+ dimColor: !HIGH_CONTRAST,
1019
+ bold: HIGH_CONTRAST
1020
+ };
1021
+ }
1022
+ const padding = Array.from({ length: RIGHT_WINDOW - visible.length }, () => ({
1023
+ char: " "
1024
+ }));
1025
+ return [...visible, ...padding];
1026
+ }
1027
+ function renderChars(chars, keyPrefix) {
1028
+ return chars.map((entry, index) => /* @__PURE__ */ jsx(
1029
+ Text,
1030
+ {
1031
+ color: entry.color,
1032
+ dimColor: entry.dimColor,
1033
+ bold: entry.bold,
1034
+ inverse: entry.inverse,
1035
+ underline: entry.underline,
1036
+ children: entry.char
1037
+ },
1038
+ `${keyPrefix}-${index}`
1039
+ ));
1040
+ }
1041
+ function TargetTextView(props) {
1042
+ const { targetText, inputText } = props;
1043
+ if (!targetText || targetText.trim().length === 0) {
1044
+ return /* @__PURE__ */ jsx(Box, { width: "100%", justifyContent: "center", children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "No target words" }) });
1045
+ }
1046
+ const pointer = Math.max(0, inputText.length);
1047
+ const pastChars = buildPastChars(targetText, inputText, pointer);
1048
+ const futureChars = buildFutureChars(targetText, pointer);
1049
+ return /* @__PURE__ */ jsxs(Box, { width: "100%", justifyContent: "center", children: [
1050
+ /* @__PURE__ */ jsx(Text, { children: renderChars(pastChars, "past") }),
1051
+ /* @__PURE__ */ jsx(Text, { color: COLORS_ENABLED ? "yellow" : void 0, inverse: true, bold: true, children: "|" }),
1052
+ /* @__PURE__ */ jsx(Text, { children: renderChars(futureChars, "future") })
1053
+ ] });
1054
+ }
1055
+
1056
+ // apps/cli/src/components/screens.tsx
1057
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1058
+ function compactPath(filePath) {
1059
+ const home = os2.homedir();
1060
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
1061
+ }
1062
+ function formatLanguageLabel(language) {
1063
+ if (language === "english") return "english";
1064
+ const match = language.match(/^english_(\d+)k$/);
1065
+ if (match) {
1066
+ return `english${match[1]}k`;
1067
+ }
1068
+ return language;
1069
+ }
1070
+ function RainbowTitle(props) {
1071
+ const gradientStops = ["#00d4ff", "#4d7cff", "#a855f7", "#ff3d81"];
1072
+ const hexToRgb = (hex) => {
1073
+ const clean = hex.replace("#", "");
1074
+ const r = Number.parseInt(clean.slice(0, 2), 16);
1075
+ const g = Number.parseInt(clean.slice(2, 4), 16);
1076
+ const b = Number.parseInt(clean.slice(4, 6), 16);
1077
+ return [r, g, b];
1078
+ };
1079
+ const rgbToHex = (r, g, b) => `#${[r, g, b].map((value) => Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0")).join("")}`;
1080
+ const interpolate = (a, b, t) => a + (b - a) * t;
1081
+ const colorAt = (t) => {
1082
+ if (gradientStops.length === 1) return gradientStops[0];
1083
+ const segments = gradientStops.length - 1;
1084
+ const scaled = t * segments;
1085
+ const index = Math.min(segments - 1, Math.max(0, Math.floor(scaled)));
1086
+ const localT = scaled - index;
1087
+ const from = hexToRgb(gradientStops[index]);
1088
+ const to = hexToRgb(gradientStops[index + 1]);
1089
+ return rgbToHex(
1090
+ interpolate(from[0], to[0], localT),
1091
+ interpolate(from[1], to[1], localT),
1092
+ interpolate(from[2], to[2], localT)
1093
+ );
1094
+ };
1095
+ if (!COLORS_ENABLED) {
1096
+ return /* @__PURE__ */ jsx2(Text2, { bold: true, children: props.text });
1097
+ }
1098
+ const chars = [...props.text];
1099
+ return /* @__PURE__ */ jsx2(Text2, { bold: true, children: chars.map((char, index) => /* @__PURE__ */ jsx2(
1100
+ Text2,
1101
+ {
1102
+ color: colorAt(chars.length <= 1 ? 0 : index / (chars.length - 1)),
1103
+ children: char
1104
+ },
1105
+ `${char}-${index}`
1106
+ )) });
1107
+ }
1108
+ function KeyTag(props) {
1109
+ const tone = props.tone ?? "neutral";
1110
+ const color = tone === "success" ? "green" : tone === "danger" ? "red" : tone === "primary" ? "cyan" : "yellow";
1111
+ return /* @__PURE__ */ jsx2(
1112
+ Text2,
1113
+ {
1114
+ color: COLORS_ENABLED ? color : void 0,
1115
+ bold: true,
1116
+ inverse: HIGH_CONTRAST && tone !== "neutral",
1117
+ underline: HIGH_CONTRAST && tone === "primary",
1118
+ children: props.label
1119
+ }
1120
+ );
1121
+ }
1122
+ function MenuControls() {
1123
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
1124
+ /* @__PURE__ */ jsx2(KeyTag, { label: "\u2191/\u2193", tone: "primary" }),
1125
+ " move row | ",
1126
+ /* @__PURE__ */ jsx2(KeyTag, { label: "\u2190/\u2192", tone: "primary" }),
1127
+ " change value | ",
1128
+ /* @__PURE__ */ jsx2(KeyTag, { label: "Enter", tone: "success" }),
1129
+ " start | ",
1130
+ /* @__PURE__ */ jsx2(KeyTag, { label: "q", tone: "danger" }),
1131
+ " quit"
1132
+ ] });
1133
+ }
1134
+ function RunningControls() {
1135
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
1136
+ /* @__PURE__ */ jsx2(KeyTag, { label: "Tab", tone: "primary" }),
1137
+ " new test | ",
1138
+ /* @__PURE__ */ jsx2(KeyTag, { label: "Enter", tone: "success" }),
1139
+ " menu | ",
1140
+ /* @__PURE__ */ jsx2(KeyTag, { label: "q", tone: "danger" }),
1141
+ " quit"
1142
+ ] });
1143
+ }
1144
+ function LoadingScreen() {
1145
+ return /* @__PURE__ */ jsx2(Text2, { children: "Loading engine data..." });
1146
+ }
1147
+ function SavingScreen() {
1148
+ return /* @__PURE__ */ jsx2(Text2, { children: "Saving result..." });
1149
+ }
1150
+ function ErrorScreen(props) {
1151
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1152
+ /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
1153
+ "Error: ",
1154
+ props.errorMessage
1155
+ ] }),
1156
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1157
+ /* @__PURE__ */ jsx2(KeyTag, { label: "Enter", tone: "success" }),
1158
+ " menu | ",
1159
+ /* @__PURE__ */ jsx2(KeyTag, { label: "q", tone: "danger" }),
1160
+ " quit"
1161
+ ] })
1162
+ ] });
1163
+ }
1164
+ function MenuScreen(props) {
1165
+ const {
1166
+ language,
1167
+ availableLanguages,
1168
+ mode,
1169
+ menuCursor,
1170
+ menuItemCount,
1171
+ timeTargetSeconds,
1172
+ timeTargetOptions,
1173
+ wordTarget,
1174
+ wordTargetOptions,
1175
+ quoteCount,
1176
+ dictionarySize,
1177
+ historyCount,
1178
+ averageWpm,
1179
+ averageAccuracy,
1180
+ resultsFilePath,
1181
+ settingsFilePath,
1182
+ statsFilePath,
1183
+ engineDataDir
1184
+ } = props;
1185
+ const modeDetail = mode === "time" ? `seconds: ${timeTargetSeconds} [${timeTargetOptions.join(", ")}]` : `words: ${wordTarget} [${wordTargetOptions.join(", ")}]`;
1186
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1187
+ /* @__PURE__ */ jsx2(RainbowTitle, { text: "monkcli" }),
1188
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1189
+ "history: ",
1190
+ historyCount,
1191
+ " | avg: ",
1192
+ Math.round(averageWpm),
1193
+ " wpm,",
1194
+ " ",
1195
+ Math.round(averageAccuracy),
1196
+ "% acc | dictionary words: ",
1197
+ dictionarySize,
1198
+ " | quotes:",
1199
+ " ",
1200
+ quoteCount
1201
+ ] }),
1202
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1203
+ "results: ",
1204
+ compactPath(resultsFilePath),
1205
+ " | stats: ",
1206
+ compactPath(statsFilePath),
1207
+ " | settings:",
1208
+ " ",
1209
+ compactPath(settingsFilePath)
1210
+ ] }),
1211
+ /* @__PURE__ */ jsx2(Text2, { children: " " }),
1212
+ /* @__PURE__ */ jsxs2(Text2, { color: menuCursor === 0 ? "green" : "white", children: [
1213
+ menuCursor === 0 ? ">" : " ",
1214
+ " language: ",
1215
+ formatLanguageLabel(language),
1216
+ " (",
1217
+ availableLanguages.length,
1218
+ " options)"
1219
+ ] }),
1220
+ /* @__PURE__ */ jsxs2(Text2, { color: menuCursor === 1 ? "green" : "white", children: [
1221
+ menuCursor === 1 ? ">" : " ",
1222
+ " mode: ",
1223
+ mode
1224
+ ] }),
1225
+ mode !== "quote" ? /* @__PURE__ */ jsxs2(Text2, { color: menuCursor === 2 ? "green" : "white", children: [
1226
+ menuCursor === 2 ? ">" : " ",
1227
+ " ",
1228
+ modeDetail
1229
+ ] }) : /* @__PURE__ */ jsx2(Text2, { color: "gray", dimColor: true, children: "quote source: english" }),
1230
+ /* @__PURE__ */ jsx2(Text2, { children: " " }),
1231
+ /* @__PURE__ */ jsx2(MenuControls, {}),
1232
+ /* @__PURE__ */ jsxs2(Text2, { color: "gray", dimColor: true, children: [
1233
+ "active rows: ",
1234
+ menuItemCount
1235
+ ] }),
1236
+ dictionarySize === 0 && mode !== "quote" ? /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
1237
+ "Cannot start: dictionary is empty. engine-data path: ",
1238
+ engineDataDir
1239
+ ] }) : null,
1240
+ quoteCount === 0 && mode === "quote" ? /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
1241
+ "Cannot start: quote list is empty. engine-data path: ",
1242
+ engineDataDir
1243
+ ] }) : null
1244
+ ] });
1245
+ }
1246
+ function ResultScreen(props) {
1247
+ const { result, historyCount, averageWpm, averageAccuracy, resultsFilePath } = props;
1248
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1249
+ /* @__PURE__ */ jsx2(Text2, { color: "green", bold: true, children: "Test complete" }),
1250
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1251
+ Math.round(result.wpm),
1252
+ " wpm | ",
1253
+ Math.round(result.rawWpm),
1254
+ " raw | ",
1255
+ Math.round(result.accuracy),
1256
+ "% acc"
1257
+ ] }),
1258
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1259
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "time" }),
1260
+ " ",
1261
+ Math.round(result.elapsedSeconds),
1262
+ "s |",
1263
+ " ",
1264
+ /* @__PURE__ */ jsx2(Text2, { color: "green", children: "correct keys" }),
1265
+ " ",
1266
+ /* @__PURE__ */ jsx2(Text2, { color: "green", bold: true, children: result.accuracyCounts.correct }),
1267
+ " ",
1268
+ "| ",
1269
+ /* @__PURE__ */ jsx2(Text2, { color: "red", children: "incorrect keys" }),
1270
+ " ",
1271
+ /* @__PURE__ */ jsx2(Text2, { color: "red", bold: true, children: result.accuracyCounts.incorrect })
1272
+ ] }),
1273
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1274
+ "saved results: ",
1275
+ historyCount,
1276
+ " (",
1277
+ compactPath(resultsFilePath),
1278
+ ")"
1279
+ ] }),
1280
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1281
+ "average: ",
1282
+ Math.round(averageWpm),
1283
+ " wpm | ",
1284
+ Math.round(averageAccuracy),
1285
+ "% acc"
1286
+ ] }),
1287
+ /* @__PURE__ */ jsx2(RunningControls, {})
1288
+ ] });
1289
+ }
1290
+ function RunningScreen(props) {
1291
+ const { mode, remainingSeconds, progress, targetText, inputText, extraTyped } = props;
1292
+ const modeLabel = mode === "time" ? `time | ${Math.max(0, Math.ceil(remainingSeconds))}s left` : mode;
1293
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1294
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", bold: true, children: modeLabel }),
1295
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1296
+ Math.round(progress?.wpm ?? 0),
1297
+ " wpm | ",
1298
+ Math.round(progress?.accuracy ?? 100),
1299
+ "% acc"
1300
+ ] }),
1301
+ /* @__PURE__ */ jsx2(Text2, { children: " " }),
1302
+ /* @__PURE__ */ jsx2(TargetTextView, { targetText, inputText }),
1303
+ extraTyped.length > 0 ? /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
1304
+ "extra: ",
1305
+ extraTyped
1306
+ ] }) : null,
1307
+ /* @__PURE__ */ jsx2(Text2, { children: " " }),
1308
+ /* @__PURE__ */ jsx2(RunningControls, {})
1309
+ ] });
1310
+ }
1311
+
1312
+ // apps/cli/src/App.tsx
1313
+ import { jsx as jsx3 } from "react/jsx-runtime";
1314
+ function App() {
1315
+ const { exit } = useApp();
1316
+ const { stdout } = useStdout();
1317
+ const terminalWidth = stdout.columns ?? 120;
1318
+ const terminalHeight = stdout.rows ?? 30;
1319
+ const controller = useTypingController();
1320
+ const safeWidth = Math.max(40, terminalWidth);
1321
+ const safeHeight = Math.max(12, terminalHeight);
1322
+ const panelWidth = Math.max(36, Math.min(safeWidth - 2, 120));
1323
+ const renderCentered = (content) => /* @__PURE__ */ jsx3(Box3, { width: safeWidth, height: safeHeight, justifyContent: "center", alignItems: "center", children: /* @__PURE__ */ jsx3(Box3, { width: panelWidth, flexDirection: "column", children: content }) });
1324
+ useInput((input, key) => {
1325
+ if (key.ctrl && input === "c" || input === "q") {
1326
+ exit();
1327
+ return;
1328
+ }
1329
+ if (controller.phase === "menu") {
1330
+ if (key.upArrow) {
1331
+ controller.moveMenuCursor(-1);
1332
+ return;
1333
+ }
1334
+ if (key.downArrow) {
1335
+ controller.moveMenuCursor(1);
1336
+ return;
1337
+ }
1338
+ if (key.leftArrow) {
1339
+ controller.adjustFocusedSetting(-1);
1340
+ return;
1341
+ }
1342
+ if (key.rightArrow) {
1343
+ controller.adjustFocusedSetting(1);
1344
+ return;
1345
+ }
1346
+ if (key.return) {
1347
+ controller.startSession();
1348
+ }
1349
+ return;
1350
+ }
1351
+ if (controller.phase === "result") {
1352
+ if (key.tab) {
1353
+ controller.startSession();
1354
+ return;
1355
+ }
1356
+ if (key.return) {
1357
+ controller.goToMenu();
1358
+ }
1359
+ return;
1360
+ }
1361
+ if (controller.phase === "error") {
1362
+ if (key.return) {
1363
+ controller.goToMenu();
1364
+ }
1365
+ return;
1366
+ }
1367
+ if (!controller.hasRunningSession) return;
1368
+ if (key.tab) {
1369
+ controller.startSession();
1370
+ return;
1371
+ }
1372
+ if (key.return) {
1373
+ controller.goToMenu();
1374
+ return;
1375
+ }
1376
+ if (key.backspace || key.delete) {
1377
+ controller.applyBackspace();
1378
+ return;
1379
+ }
1380
+ if (input && !key.ctrl && !key.meta) {
1381
+ controller.applyInputText(input);
1382
+ }
1383
+ });
1384
+ if (controller.phase === "loading") {
1385
+ return renderCentered(/* @__PURE__ */ jsx3(LoadingScreen, {}));
1386
+ }
1387
+ if (controller.phase === "error") {
1388
+ return renderCentered(/* @__PURE__ */ jsx3(ErrorScreen, { errorMessage: controller.errorMessage }));
1389
+ }
1390
+ if (controller.phase === "menu") {
1391
+ return renderCentered(
1392
+ /* @__PURE__ */ jsx3(
1393
+ MenuScreen,
1394
+ {
1395
+ language: controller.language,
1396
+ availableLanguages: controller.availableLanguages,
1397
+ mode: controller.mode,
1398
+ menuCursor: controller.menuCursor,
1399
+ menuItemCount: controller.menuItemCount,
1400
+ timeTargetSeconds: controller.timeTargetSeconds,
1401
+ timeTargetOptions: controller.timeTargetOptions,
1402
+ wordTarget: controller.wordTarget,
1403
+ wordTargetOptions: controller.wordTargetOptions,
1404
+ quoteCount: controller.quoteCount,
1405
+ dictionarySize: controller.dictionarySize,
1406
+ historyCount: controller.historyCount,
1407
+ averageWpm: controller.averageWpm,
1408
+ averageAccuracy: controller.averageAccuracy,
1409
+ resultsFilePath: controller.resultsFilePath,
1410
+ settingsFilePath: controller.settingsFilePath,
1411
+ statsFilePath: controller.statsFilePath,
1412
+ engineDataDir: controller.engineDataDir
1413
+ }
1414
+ )
1415
+ );
1416
+ }
1417
+ if (controller.phase === "saving") {
1418
+ return renderCentered(/* @__PURE__ */ jsx3(SavingScreen, {}));
1419
+ }
1420
+ if (controller.phase === "result" && controller.lastResult) {
1421
+ return renderCentered(
1422
+ /* @__PURE__ */ jsx3(
1423
+ ResultScreen,
1424
+ {
1425
+ result: controller.lastResult,
1426
+ historyCount: controller.historyCount,
1427
+ averageWpm: controller.averageWpm,
1428
+ averageAccuracy: controller.averageAccuracy,
1429
+ resultsFilePath: controller.resultsFilePath
1430
+ }
1431
+ )
1432
+ );
1433
+ }
1434
+ return renderCentered(
1435
+ /* @__PURE__ */ jsx3(
1436
+ RunningScreen,
1437
+ {
1438
+ mode: controller.mode,
1439
+ remainingSeconds: controller.remainingSeconds,
1440
+ progress: controller.progress,
1441
+ targetText: controller.targetText,
1442
+ inputText: controller.inputText,
1443
+ extraTyped: controller.extraTyped
1444
+ }
1445
+ )
1446
+ );
1447
+ }
1448
+
1449
+ // apps/cli/src/index.tsx
1450
+ import { jsx as jsx4 } from "react/jsx-runtime";
1451
+ render(/* @__PURE__ */ jsx4(App, {}));