@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.
- package/README.md +92 -0
- package/bin/monkcli.mjs +59 -0
- package/dist/monkcli.js +1451 -0
- package/engine-data/frontend/static/languages/english.json +207 -0
- package/engine-data/frontend/static/languages/english_10k.json +1 -0
- package/engine-data/frontend/static/languages/english_1k.json +1007 -0
- package/engine-data/frontend/static/languages/english_25k.json +24181 -0
- package/engine-data/frontend/static/languages/english_5k.json +4964 -0
- package/engine-data/frontend/static/quotes/english.json +39206 -0
- package/package.json +38 -0
package/dist/monkcli.js
ADDED
|
@@ -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, {}));
|