qwerty-cli 0.0.1-alpha.0 → 0.0.1-alpha.5

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.
Files changed (3) hide show
  1. package/dist/cli.js +1626 -717
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -1,6 +1,67 @@
1
1
  // src/cli.ts
2
2
  import { Command as Command6 } from "commander";
3
3
 
4
+ // package.json
5
+ var package_default = {
6
+ name: "qwerty-cli",
7
+ version: "0.0.1-alpha.5",
8
+ description: "Terminal clone of qwerty-learner: typing practice for English vocabulary, with chapters, dictation, mistake book, and audio.",
9
+ type: "module",
10
+ bin: {
11
+ qwerty: "bin/qwerty.mjs"
12
+ },
13
+ files: [
14
+ "dist",
15
+ "bin",
16
+ "assets"
17
+ ],
18
+ scripts: {
19
+ build: "tsup",
20
+ dev: "tsup --watch",
21
+ typecheck: "tsc --noEmit",
22
+ test: "vitest run",
23
+ "test:watch": "vitest",
24
+ "sync-registry": "tsx scripts/sync-registry.ts",
25
+ prepublishOnly: "pnpm build"
26
+ },
27
+ engines: {
28
+ node: ">=20"
29
+ },
30
+ keywords: [
31
+ "qwerty",
32
+ "typing",
33
+ "cli",
34
+ "vocabulary",
35
+ "ink",
36
+ "terminal"
37
+ ],
38
+ license: "MIT",
39
+ dependencies: {
40
+ chalk: "^5.3.0",
41
+ commander: "^12.1.0",
42
+ ink: "^5.0.1",
43
+ "ink-big-text": "^2.0.0",
44
+ "p-queue": "^8.0.1",
45
+ react: "^18.3.1",
46
+ undici: "^6.19.8",
47
+ zod: "^3.23.8"
48
+ },
49
+ devDependencies: {
50
+ "@types/node": "^20.14.10",
51
+ "@types/react": "^18.3.3",
52
+ "ts-morph": "^23.0.0",
53
+ tsup: "^8.2.4",
54
+ tsx: "^4.19.0",
55
+ typescript: "^5.5.4",
56
+ vitest: "^2.0.5"
57
+ },
58
+ pnpm: {
59
+ onlyBuiltDependencies: [
60
+ "esbuild"
61
+ ]
62
+ }
63
+ };
64
+
4
65
  // src/commands/config.ts
5
66
  import { Command } from "commander";
6
67
  import chalk from "chalk";
@@ -449,6 +510,396 @@ import chalk3 from "chalk";
449
510
  import { render } from "ink";
450
511
  import { createElement } from "react";
451
512
 
513
+ // src/ui/App.tsx
514
+ import { useApp as useApp4, useInput as useInput8 } from "ink";
515
+
516
+ // src/ui/nav.tsx
517
+ import { createContext, useContext, useState, useCallback } from "react";
518
+ import { jsx } from "react/jsx-runtime";
519
+ var NavContext = createContext(null);
520
+ function NavProvider({ initial, children }) {
521
+ const [stack, setStack] = useState([initial]);
522
+ const navigate = useCallback((frame) => {
523
+ setStack((s) => [...s, frame]);
524
+ }, []);
525
+ const replace = useCallback((frame) => {
526
+ setStack((s) => s.length === 0 ? [frame] : [...s.slice(0, -1), frame]);
527
+ }, []);
528
+ const back = useCallback(() => {
529
+ setStack((s) => s.length > 1 ? s.slice(0, -1) : s);
530
+ }, []);
531
+ const reset = useCallback((frame) => {
532
+ setStack([frame]);
533
+ }, []);
534
+ const current = stack[stack.length - 1];
535
+ return /* @__PURE__ */ jsx(NavContext.Provider, { value: { current, stack, navigate, replace, back, reset }, children });
536
+ }
537
+ function useNav() {
538
+ const ctx = useContext(NavContext);
539
+ if (!ctx) throw new Error("useNav must be used inside NavProvider");
540
+ return ctx;
541
+ }
542
+
543
+ // src/ui/Fullscreen.tsx
544
+ import { useEffect } from "react";
545
+ import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
546
+ var ENTER = "\x1B[?1049h\x1B[?25l";
547
+ var LEAVE = "\x1B[?25h\x1B[?1049l";
548
+ function shouldUse() {
549
+ return Boolean(process.stdout.isTTY) && process.env.QWERTY_NO_ALTSCREEN !== "1";
550
+ }
551
+ function Fullscreen({ children }) {
552
+ useEffect(() => {
553
+ if (!shouldUse()) return;
554
+ process.stdout.write(ENTER);
555
+ const leave = () => {
556
+ try {
557
+ process.stdout.write(LEAVE);
558
+ } catch {
559
+ }
560
+ };
561
+ const onSignal = () => {
562
+ leave();
563
+ process.exit(130);
564
+ };
565
+ process.once("SIGINT", onSignal);
566
+ process.once("SIGTERM", onSignal);
567
+ process.once("exit", leave);
568
+ return () => {
569
+ process.off("SIGINT", onSignal);
570
+ process.off("SIGTERM", onSignal);
571
+ process.off("exit", leave);
572
+ leave();
573
+ };
574
+ }, []);
575
+ return /* @__PURE__ */ jsx2(Fragment, { children });
576
+ }
577
+
578
+ // src/ui/audio-context.tsx
579
+ import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useState as useState2 } from "react";
580
+
581
+ // src/infra/audio.ts
582
+ import { spawn } from "child_process";
583
+ import { mkdir as mkdir3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
584
+ import { join as join3, dirname as dirname2 } from "path";
585
+ import { request as request2 } from "undici";
586
+ import PQueue from "p-queue";
587
+ var CANDIDATES = [
588
+ { kind: "afplay", cmd: "afplay", args: (f) => [f], supports: "both" },
589
+ { kind: "ffplay", cmd: "ffplay", args: (f) => ["-nodisp", "-autoexit", "-loglevel", "quiet", f], supports: "both" },
590
+ { kind: "mpg123", cmd: "mpg123", args: (f) => ["-q", f], supports: "mp3" },
591
+ { kind: "paplay", cmd: "paplay", args: (f) => [f], supports: "wav" },
592
+ { kind: "aplay", cmd: "aplay", args: (f) => ["-q", f], supports: "wav" },
593
+ {
594
+ kind: "powershell",
595
+ cmd: "powershell",
596
+ args: (f) => ["-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${f}').PlaySync();`],
597
+ supports: "wav"
598
+ }
599
+ ];
600
+ var PRON_API = "https://dict.youdao.com/dictvoice?audio=";
601
+ var runtime = null;
602
+ async function isExecutable(cmd) {
603
+ return new Promise((resolve2) => {
604
+ const probe = spawn(cmd, ["--version"], { stdio: "ignore" });
605
+ probe.on("error", () => resolve2(false));
606
+ probe.on("exit", () => resolve2(true));
607
+ setTimeout(() => {
608
+ probe.kill();
609
+ resolve2(false);
610
+ }, 500);
611
+ });
612
+ }
613
+ async function detect() {
614
+ let wav = null;
615
+ let mp3 = null;
616
+ for (const p of CANDIDATES) {
617
+ if (!await isExecutable(p.cmd)) continue;
618
+ if (!wav && (p.supports === "wav" || p.supports === "both")) wav = p;
619
+ if (!mp3 && (p.supports === "mp3" || p.supports === "both")) mp3 = p;
620
+ if (wav && mp3) break;
621
+ }
622
+ return { wav, mp3 };
623
+ }
624
+ async function initAudio(disabledByConfig) {
625
+ if (runtime) return runtime;
626
+ if (disabledByConfig) {
627
+ runtime = {
628
+ disabled: true,
629
+ wavPlayer: null,
630
+ mp3Player: null,
631
+ warning: null,
632
+ keyQueue: new PQueue({ concurrency: 1 }),
633
+ feedbackQueue: new PQueue({ concurrency: 1 }),
634
+ pronQueue: new PQueue({ concurrency: 1 })
635
+ };
636
+ return runtime;
637
+ }
638
+ const { wav, mp3 } = await detect();
639
+ let warning = null;
640
+ if (!wav && !mp3) {
641
+ warning = "No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled.";
642
+ } else if (!mp3) {
643
+ warning = "No MP3 player found; word pronunciations will be skipped.";
644
+ }
645
+ runtime = {
646
+ disabled: !wav && !mp3,
647
+ wavPlayer: wav,
648
+ mp3Player: mp3,
649
+ warning,
650
+ keyQueue: new PQueue({ concurrency: 2 }),
651
+ feedbackQueue: new PQueue({ concurrency: 1 }),
652
+ pronQueue: new PQueue({ concurrency: 1 })
653
+ };
654
+ return runtime;
655
+ }
656
+ function spawnPlay(player, file) {
657
+ try {
658
+ const child = spawn(player.cmd, player.args(file), {
659
+ detached: true,
660
+ stdio: "ignore"
661
+ });
662
+ child.on("error", () => {
663
+ });
664
+ child.unref();
665
+ } catch {
666
+ }
667
+ }
668
+ function playFile(file, kind) {
669
+ if (!runtime || runtime.disabled) return;
670
+ const player = kind === "wav" ? runtime.wavPlayer : runtime.mp3Player;
671
+ if (!player) return;
672
+ spawnPlay(player, file);
673
+ }
674
+ function playKeystroke() {
675
+ if (!runtime || runtime.disabled) return;
676
+ if (runtime.keyQueue.size >= 2) return;
677
+ void runtime.keyQueue.add(async () => {
678
+ playFile(join3(packageAssetsDir(), "sounds", "key-default.wav"), "wav");
679
+ await new Promise((r) => setTimeout(r, 30));
680
+ });
681
+ }
682
+ function playCorrect() {
683
+ if (!runtime || runtime.disabled) return;
684
+ void runtime.feedbackQueue.add(async () => {
685
+ playFile(join3(packageAssetsDir(), "sounds", "correct.wav"), "wav");
686
+ await new Promise((r) => setTimeout(r, 50));
687
+ });
688
+ }
689
+ function playWrong() {
690
+ if (!runtime || runtime.disabled) return;
691
+ void runtime.feedbackQueue.add(async () => {
692
+ playFile(join3(packageAssetsDir(), "sounds", "wrong.wav"), "wav");
693
+ await new Promise((r) => setTimeout(r, 50));
694
+ });
695
+ }
696
+ async function downloadPronunciation(word, accent) {
697
+ const cacheFile = paths.audioCache(word, accent);
698
+ if (await exists(cacheFile)) return cacheFile;
699
+ await mkdir3(dirname2(cacheFile), { recursive: true });
700
+ const type = accent === "us" ? 2 : 1;
701
+ const url = `${PRON_API}${encodeURIComponent(word)}&type=${type}`;
702
+ try {
703
+ const res = await request2(url, { headersTimeout: 8e3, bodyTimeout: 2e4 });
704
+ if (res.statusCode >= 400) return null;
705
+ const buf = Buffer.from(await res.body.arrayBuffer());
706
+ if (buf.length < 1024) return null;
707
+ const tmp = `${cacheFile}.tmp`;
708
+ await writeFile2(tmp, buf);
709
+ await rename2(tmp, cacheFile);
710
+ return cacheFile;
711
+ } catch {
712
+ return null;
713
+ }
714
+ }
715
+ async function playPronunciation(word, accent) {
716
+ if (!runtime || runtime.disabled || !runtime.mp3Player) return;
717
+ await ensureDirs();
718
+ await runtime.pronQueue.add(async () => {
719
+ const file = await downloadPronunciation(word, accent);
720
+ if (file) playFile(file, "mp3");
721
+ });
722
+ }
723
+ async function prefetchPronunciation(word, accent) {
724
+ if (!runtime || runtime.disabled || !runtime.mp3Player) return;
725
+ await ensureDirs();
726
+ void runtime.pronQueue.add(async () => {
727
+ await downloadPronunciation(word, accent);
728
+ });
729
+ }
730
+ function audioWarning() {
731
+ return runtime?.warning ?? null;
732
+ }
733
+
734
+ // src/ui/audio-context.tsx
735
+ import { jsx as jsx3 } from "react/jsx-runtime";
736
+ var AudioStatusContext = createContext2({ warning: null, ready: false });
737
+ function AudioStatusProvider({
738
+ disabled,
739
+ children
740
+ }) {
741
+ const [status, setStatus] = useState2({ warning: null, ready: false });
742
+ useEffect2(() => {
743
+ let cancelled = false;
744
+ initAudio(disabled).then(() => {
745
+ if (cancelled) return;
746
+ setStatus({ warning: audioWarning(), ready: true });
747
+ }).catch(() => {
748
+ if (cancelled) return;
749
+ setStatus({ warning: null, ready: true });
750
+ });
751
+ return () => {
752
+ cancelled = true;
753
+ };
754
+ }, [disabled]);
755
+ return /* @__PURE__ */ jsx3(AudioStatusContext.Provider, { value: status, children });
756
+ }
757
+ function useAudioStatus() {
758
+ return useContext2(AudioStatusContext);
759
+ }
760
+
761
+ // src/ui/app-state.tsx
762
+ import { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useState as useState3 } from "react";
763
+ import { jsx as jsx4 } from "react/jsx-runtime";
764
+ var AppStateContext = createContext3(null);
765
+ function AppStateProvider({
766
+ initialCfg,
767
+ children
768
+ }) {
769
+ const [cfg, setCfgState] = useState3(initialCfg);
770
+ const setCfg = useCallback2(async (next) => {
771
+ setCfgState(next);
772
+ await saveConfig(next);
773
+ }, []);
774
+ return /* @__PURE__ */ jsx4(AppStateContext.Provider, { value: { cfg, setCfg }, children });
775
+ }
776
+ function useAppState() {
777
+ const ctx = useContext3(AppStateContext);
778
+ if (!ctx) throw new Error("useAppState must be used inside AppStateProvider");
779
+ return ctx;
780
+ }
781
+
782
+ // src/ui/screens/MainMenu.tsx
783
+ import { useState as useState4 } from "react";
784
+ import { Box as Box2, Text as Text2, useApp, useInput } from "ink";
785
+
786
+ // src/ui/components/BigWord.tsx
787
+ import { Box, Text, useStdout } from "ink";
788
+ import BigText from "ink-big-text";
789
+ import { jsx as jsx5, jsxs } from "react/jsx-runtime";
790
+ var PALETTE = {
791
+ accent: "#5eead4",
792
+ muted: "#6b7280",
793
+ text: "#e5e7eb",
794
+ primary: "#7dcfff",
795
+ success: "#86efac",
796
+ warning: "#fbbf24",
797
+ error: "#f87171"
798
+ };
799
+ function BigWord({ target, typed, error = false, hideTarget = false }) {
800
+ const { stdout } = useStdout();
801
+ const cols = stdout?.columns ?? 80;
802
+ const chars = [...target];
803
+ const typedChars = [...typed];
804
+ const useBig = cols >= 60 && process.env.QWERTY_NO_BIGTEXT !== "1";
805
+ if (!useBig) {
806
+ return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", children: chars.map((ch, i) => {
807
+ const isTyped = i < typedChars.length;
808
+ const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
809
+ const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
810
+ return /* @__PURE__ */ jsxs(Text, { bold: isTyped, color, underline: !isTyped && error, children: [
811
+ display,
812
+ " "
813
+ ] }, i);
814
+ }) });
815
+ }
816
+ return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", flexDirection: "row", children: chars.map((ch, i) => {
817
+ const isTyped = i < typedChars.length;
818
+ const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
819
+ const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
820
+ return /* @__PURE__ */ jsx5(BigText, { text: display, font: "tiny", colors: [color], space: false }, i);
821
+ }) });
822
+ }
823
+
824
+ // src/ui/screens/MainMenu.tsx
825
+ import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
826
+ function MainMenu({ cfg }) {
827
+ const [selected, setSelected] = useState4(0);
828
+ const { exit } = useApp();
829
+ const nav = useNav();
830
+ const audio = useAudioStatus();
831
+ const items = [
832
+ {
833
+ key: "p",
834
+ label: "Practice",
835
+ hint: cfg.defaultDict ? `start ${cfg.defaultDict}` : "pick a dictionary",
836
+ run: () => {
837
+ if (cfg.defaultDict) {
838
+ nav.navigate({
839
+ name: "practice",
840
+ params: { dictId: cfg.defaultDict, chapterIndex: 0, mode: cfg.defaultMode }
841
+ });
842
+ } else {
843
+ nav.navigate({ name: "dict", params: { pickerMode: "choose-then-practice" } });
844
+ }
845
+ }
846
+ },
847
+ { key: "d", label: "Dictionaries", hint: "browse, pull, set default", run: () => nav.navigate({ name: "dict" }) },
848
+ { key: "w", label: "Word lookup", hint: "search local dicts", run: () => nav.navigate({ name: "word" }) },
849
+ { key: "s", label: "Stats", hint: "history & trends", run: () => nav.navigate({ name: "stats" }) },
850
+ { key: "c", label: "Config", hint: "edit preferences", run: () => nav.navigate({ name: "config" }) },
851
+ { key: "q", label: "Quit", hint: "Ctrl+C also exits", run: () => exit() }
852
+ ];
853
+ useInput((input, key) => {
854
+ if (key.upArrow) setSelected((i) => (i - 1 + items.length) % items.length);
855
+ if (key.downArrow) setSelected((i) => (i + 1) % items.length);
856
+ if (key.return) {
857
+ items[selected].run();
858
+ return;
859
+ }
860
+ for (const it of items) {
861
+ if (input === it.key) {
862
+ it.run();
863
+ return;
864
+ }
865
+ }
866
+ });
867
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
868
+ /* @__PURE__ */ jsxs2(Box2, { children: [
869
+ /* @__PURE__ */ jsx6(Text2, { bold: true, color: PALETTE.accent, children: "qwerty" }),
870
+ /* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: " \xB7 typing practice for the terminal" })
871
+ ] }),
872
+ /* @__PURE__ */ jsx6(Box2, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
873
+ const active = i === selected;
874
+ return /* @__PURE__ */ jsxs2(Box2, { children: [
875
+ /* @__PURE__ */ jsx6(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
876
+ /* @__PURE__ */ jsxs2(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
877
+ "[",
878
+ it.key,
879
+ "]"
880
+ ] }),
881
+ /* @__PURE__ */ jsx6(Text2, { children: " " }),
882
+ /* @__PURE__ */ jsx6(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: it.label.padEnd(14) }),
883
+ /* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: it.hint })
884
+ ] }, it.key);
885
+ }) }),
886
+ /* @__PURE__ */ jsx6(Box2, { marginTop: 2, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
887
+ "default dict: ",
888
+ cfg.defaultDict ?? "(none \u2014 pick one in Dictionaries)"
889
+ ] }) }),
890
+ /* @__PURE__ */ jsx6(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump" }) }),
891
+ audio.warning && /* @__PURE__ */ jsx6(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.warning, children: [
892
+ "! ",
893
+ audio.warning
894
+ ] }) })
895
+ ] });
896
+ }
897
+
898
+ // src/ui/screens/PracticeScreen.tsx
899
+ import { useState as useState6, useEffect as useEffect5, useRef as useRef3 } from "react";
900
+ import { Box as Box3, Text as Text3, useApp as useApp3, useInput as useInput3 } from "ink";
901
+ import BigText2 from "ink-big-text";
902
+
452
903
  // src/util/shuffle.ts
453
904
  function shuffle(arr, rng = Math.random) {
454
905
  const out = [...arr];
@@ -479,9 +930,6 @@ function chunkChapters(words, chapterSize) {
479
930
  }
480
931
  return chunks;
481
932
  }
482
- function chapterCount(totalWords, chapterSize) {
483
- return Math.ceil(totalWords / chapterSize);
484
- }
485
933
  function buildPlaylist(chapter, mode, seed) {
486
934
  if (mode === "random") {
487
935
  const rng = seed === void 0 ? Math.random : mulberry32(seed);
@@ -490,160 +938,33 @@ function buildPlaylist(chapter, mode, seed) {
490
938
  return chapter;
491
939
  }
492
940
 
493
- // src/domain/stats.ts
494
- import { z as z3 } from "zod";
495
- var SessionRecordSchema = z3.object({
496
- ts: z3.string(),
497
- dictId: z3.string(),
498
- chapter: z3.number().int().nonnegative(),
499
- mode: z3.string(),
500
- wordCount: z3.number().int().nonnegative(),
501
- errors: z3.number().int().nonnegative(),
502
- durationMs: z3.number().int().nonnegative(),
503
- perWordErrors: z3.record(z3.string(), z3.number().int().nonnegative()).default({})
504
- });
505
- async function appendSession(record) {
506
- await appendJsonl(paths.stats, record);
507
- }
508
- async function loadSessions() {
509
- const rows = await readJsonl(paths.stats);
510
- return rows.map((r) => SessionRecordSchema.safeParse(r)).filter((r) => r.success).map((r) => r.data);
511
- }
512
- function computeWPM(record) {
513
- if (record.durationMs === 0) return 0;
514
- const minutes = record.durationMs / 6e4;
515
- return Math.round(record.wordCount / minutes * 10) / 10;
941
+ // src/domain/input-buffer.ts
942
+ function initialState(target) {
943
+ return { target, typed: "", errorsThisWord: 0 };
516
944
  }
517
- function accuracy(record) {
518
- if (record.wordCount === 0) return 1;
519
- const wordsWithErrors = Object.keys(record.perWordErrors).length;
520
- return Math.max(0, Math.min(1, (record.wordCount - wordsWithErrors) / record.wordCount));
521
- }
522
- function dailyStreak(sessions, now = /* @__PURE__ */ new Date()) {
523
- if (sessions.length === 0) return 0;
524
- const days = /* @__PURE__ */ new Set();
525
- for (const s of sessions) days.add(s.ts.slice(0, 10));
526
- let streak = 0;
527
- const cur = new Date(now);
528
- while (true) {
529
- const key = cur.toISOString().slice(0, 10);
530
- if (!days.has(key)) break;
531
- streak++;
532
- cur.setUTCDate(cur.getUTCDate() - 1);
533
- }
534
- return streak;
535
- }
536
- var SPARK = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
537
- function sparkline(values) {
538
- if (values.length === 0) return "";
539
- const max = Math.max(...values, 1);
540
- const min = Math.min(...values, 0);
541
- const range = Math.max(1, max - min);
542
- return values.map((v) => {
543
- const idx = Math.floor((v - min) / range * (SPARK.length - 1));
544
- return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
545
- }).join("");
546
- }
547
- function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
548
- const out = [];
549
- const byDay = /* @__PURE__ */ new Map();
550
- for (const s of sessions) {
551
- const key = s.ts.slice(0, 10);
552
- const arr = byDay.get(key) ?? [];
553
- arr.push(s);
554
- byDay.set(key, arr);
555
- }
556
- const cur = new Date(now);
557
- cur.setUTCDate(cur.getUTCDate() - (days - 1));
558
- for (let i = 0; i < days; i++) {
559
- const key = cur.toISOString().slice(0, 10);
560
- const todays = byDay.get(key) ?? [];
561
- if (todays.length === 0) {
562
- out.push({ date: key, wpm: 0, accuracy: 0, sessions: 0 });
563
- } else {
564
- const wpm = todays.reduce((a, s) => a + computeWPM(s), 0) / todays.length;
565
- const acc = todays.reduce((a, s) => a + accuracy(s), 0) / todays.length;
566
- out.push({ date: key, wpm, accuracy: acc, sessions: todays.length });
567
- }
568
- cur.setUTCDate(cur.getUTCDate() + 1);
569
- }
570
- return out;
571
- }
572
-
573
- // src/domain/mistakes.ts
574
- import { z as z4 } from "zod";
575
- var MistakeBookSchema = z4.record(
576
- z4.string(),
577
- z4.object({
578
- count: z4.number().int().nonnegative(),
579
- lastSeen: z4.string(),
580
- dictIds: z4.array(z4.string()).default([])
581
- })
582
- );
583
- async function loadMistakes() {
584
- const raw = await readJson(paths.mistakes);
585
- if (!raw) return {};
586
- const parsed = MistakeBookSchema.safeParse(raw);
587
- if (!parsed.success) {
588
- console.warn("Mistake book is corrupt; starting fresh");
589
- return {};
590
- }
591
- return parsed.data;
592
- }
593
- async function saveMistakes(book) {
594
- await writeJsonAtomic(paths.mistakes, book);
595
- }
596
- function bump(book, word, dictId, delta = 1) {
597
- const prev = book[word] ?? { count: 0, lastSeen: (/* @__PURE__ */ new Date(0)).toISOString(), dictIds: [] };
598
- const dictIds = prev.dictIds.includes(dictId) ? prev.dictIds : [...prev.dictIds, dictId];
599
- return {
600
- ...book,
601
- [word]: {
602
- count: prev.count + delta,
603
- lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
604
- dictIds
605
- }
606
- };
607
- }
608
- function topN(book, n) {
609
- return Object.entries(book).sort((a, b) => b[1].count - a[1].count).slice(0, n);
610
- }
611
-
612
- // src/ui/screens/PracticeScreen.tsx
613
- import { useState as useState2, useEffect as useEffect3, useRef as useRef3 } from "react";
614
- import { Box as Box4, Text as Text5, useApp as useApp2, useInput as useInput2 } from "ink";
615
-
616
- // src/ui/hooks/useWordLoop.ts
617
- import { useEffect, useReducer, useRef, useState } from "react";
618
- import { useInput, useApp } from "ink";
619
-
620
- // src/domain/input-buffer.ts
621
- function initialState(target) {
622
- return { target, typed: "", errorsThisWord: 0 };
623
- }
624
- function reduce(state, ev) {
625
- switch (ev.type) {
626
- case "reset":
627
- return { state: { ...state, typed: "" }, effect: "none" };
628
- case "backspace": {
629
- if (state.typed.length === 0) return { state, effect: "none" };
630
- return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
631
- }
632
- case "char": {
633
- const candidate = state.typed + ev.ch;
634
- const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
635
- if (candidate === targetUpToCandidate) {
636
- if (candidate.length === state.target.length) {
637
- return { state: { ...state, typed: candidate }, effect: "correct" };
638
- }
639
- return { state: { ...state, typed: candidate }, effect: "progress" };
640
- }
641
- return {
642
- state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
643
- effect: "wrong"
644
- };
645
- }
646
- }
945
+ function reduce(state, ev) {
946
+ switch (ev.type) {
947
+ case "reset":
948
+ return { state: { ...state, typed: "" }, effect: "none" };
949
+ case "backspace": {
950
+ if (state.typed.length === 0) return { state, effect: "none" };
951
+ return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
952
+ }
953
+ case "char": {
954
+ const candidate = state.typed + ev.ch;
955
+ const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
956
+ if (candidate === targetUpToCandidate) {
957
+ if (candidate.length === state.target.length) {
958
+ return { state: { ...state, typed: candidate }, effect: "correct" };
959
+ }
960
+ return { state: { ...state, typed: candidate }, effect: "progress" };
961
+ }
962
+ return {
963
+ state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
964
+ effect: "wrong"
965
+ };
966
+ }
967
+ }
647
968
  }
648
969
 
649
970
  // src/domain/session.ts
@@ -697,6 +1018,35 @@ function feedSession(session, ev, now = Date.now()) {
697
1018
  effect
698
1019
  };
699
1020
  }
1021
+ function skipSession(session, now = Date.now()) {
1022
+ if (!session.current) return { session, effect: "none" };
1023
+ const result = {
1024
+ word: session.current.input.target,
1025
+ errors: 0,
1026
+ durationMs: now - session.current.wordStartedAt,
1027
+ skipped: true
1028
+ };
1029
+ const nextIndex = session.current.wordIndex + 1;
1030
+ const results = [...session.results, result];
1031
+ if (nextIndex >= session.playlist.length) {
1032
+ return {
1033
+ session: { ...session, results, current: null, finishedAt: now },
1034
+ effect: "skipped"
1035
+ };
1036
+ }
1037
+ return {
1038
+ session: {
1039
+ ...session,
1040
+ results,
1041
+ current: {
1042
+ wordIndex: nextIndex,
1043
+ wordStartedAt: now,
1044
+ input: initialState(session.playlist[nextIndex].name)
1045
+ }
1046
+ },
1047
+ effect: "skipped"
1048
+ };
1049
+ }
700
1050
  function sessionSummary(session) {
701
1051
  const errors = session.results.reduce((a, r) => a + r.errors, 0);
702
1052
  const durationMs = (session.finishedAt ?? Date.now()) - session.startedAt;
@@ -707,11 +1057,56 @@ function sessionSummary(session) {
707
1057
  return { wordCount: session.results.length, errors, durationMs, perWordErrors };
708
1058
  }
709
1059
 
1060
+ // src/domain/mistakes.ts
1061
+ import { z as z3 } from "zod";
1062
+ var MistakeBookSchema = z3.record(
1063
+ z3.string(),
1064
+ z3.object({
1065
+ count: z3.number().int().nonnegative(),
1066
+ lastSeen: z3.string(),
1067
+ dictIds: z3.array(z3.string()).default([])
1068
+ })
1069
+ );
1070
+ async function loadMistakes() {
1071
+ const raw = await readJson(paths.mistakes);
1072
+ if (!raw) return {};
1073
+ const parsed = MistakeBookSchema.safeParse(raw);
1074
+ if (!parsed.success) {
1075
+ console.warn("Mistake book is corrupt; starting fresh");
1076
+ return {};
1077
+ }
1078
+ return parsed.data;
1079
+ }
1080
+ async function saveMistakes(book) {
1081
+ await writeJsonAtomic(paths.mistakes, book);
1082
+ }
1083
+ function bump(book, word, dictId, delta = 1) {
1084
+ const prev = book[word] ?? { count: 0, lastSeen: (/* @__PURE__ */ new Date(0)).toISOString(), dictIds: [] };
1085
+ const dictIds = prev.dictIds.includes(dictId) ? prev.dictIds : [...prev.dictIds, dictId];
1086
+ return {
1087
+ ...book,
1088
+ [word]: {
1089
+ count: prev.count + delta,
1090
+ lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
1091
+ dictIds
1092
+ }
1093
+ };
1094
+ }
1095
+ function topN(book, n) {
1096
+ return Object.entries(book).sort((a, b) => b[1].count - a[1].count).slice(0, n);
1097
+ }
1098
+
710
1099
  // src/ui/hooks/useWordLoop.ts
1100
+ import { useEffect as useEffect3, useReducer, useRef, useState as useState5 } from "react";
1101
+ import { useInput as useInput2, useApp as useApp2 } from "ink";
711
1102
  function reducer(state, action) {
712
1103
  if (action.type === "start") {
713
1104
  return { session: startSession(action.playlist, action.now), lastEffect: null };
714
1105
  }
1106
+ if (action.type === "skip") {
1107
+ const r = skipSession(state.session, action.now);
1108
+ return { session: r.session, lastEffect: r.effect };
1109
+ }
715
1110
  if (action.type === "event") {
716
1111
  if (action.key.backspace || action.key.delete) {
717
1112
  const r = feedSession(state.session, { type: "backspace" }, action.now);
@@ -730,20 +1125,25 @@ function reducer(state, action) {
730
1125
  }
731
1126
  return state;
732
1127
  }
733
- function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true }) {
1128
+ function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled = true }) {
734
1129
  const [state, dispatch] = useReducer(reducer, void 0, () => ({
735
1130
  session: startSession(playlist, Date.now()),
736
1131
  lastEffect: null
737
1132
  }));
738
1133
  const completedRef = useRef(false);
739
- const [tick, setTick] = useState(0);
740
- const { exit } = useApp();
741
- useInput(
1134
+ const [tick, setTick] = useState5(0);
1135
+ const { exit } = useApp2();
1136
+ useInput2(
742
1137
  (input, key) => {
743
1138
  if (key.ctrl && input === "c") {
744
1139
  exit();
745
1140
  return;
746
1141
  }
1142
+ if (key.ctrl && input === "n") {
1143
+ onSkip?.();
1144
+ dispatch({ type: "skip", now: Date.now() });
1145
+ return;
1146
+ }
747
1147
  if (key.escape) {
748
1148
  onEscape?.();
749
1149
  return;
@@ -763,13 +1163,13 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true })
763
1163
  },
764
1164
  { isActive: enabled }
765
1165
  );
766
- useEffect(() => {
1166
+ useEffect3(() => {
767
1167
  if (state.session.finishedAt !== null && !completedRef.current) {
768
1168
  completedRef.current = true;
769
1169
  onComplete(state.session);
770
1170
  }
771
1171
  }, [state.session, onComplete]);
772
- useEffect(() => {
1172
+ useEffect3(() => {
773
1173
  if (state.session.finishedAt !== null) return;
774
1174
  const id = setInterval(() => setTick((t) => t + 1), 1e3);
775
1175
  return () => clearInterval(id);
@@ -778,172 +1178,14 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true })
778
1178
  }
779
1179
 
780
1180
  // src/ui/hooks/useAudio.ts
781
- import { useEffect as useEffect2, useRef as useRef2 } from "react";
782
-
783
- // src/infra/audio.ts
784
- import { spawn } from "child_process";
785
- import { mkdir as mkdir3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
786
- import { join as join3, dirname as dirname2 } from "path";
787
- import { request as request2 } from "undici";
788
- import PQueue from "p-queue";
789
- var CANDIDATES = [
790
- { kind: "afplay", cmd: "afplay", args: (f) => [f], supports: "both" },
791
- { kind: "ffplay", cmd: "ffplay", args: (f) => ["-nodisp", "-autoexit", "-loglevel", "quiet", f], supports: "both" },
792
- { kind: "mpg123", cmd: "mpg123", args: (f) => ["-q", f], supports: "mp3" },
793
- { kind: "paplay", cmd: "paplay", args: (f) => [f], supports: "wav" },
794
- { kind: "aplay", cmd: "aplay", args: (f) => ["-q", f], supports: "wav" },
795
- {
796
- kind: "powershell",
797
- cmd: "powershell",
798
- args: (f) => ["-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${f}').PlaySync();`],
799
- supports: "wav"
800
- }
801
- ];
802
- var PRON_API = "https://dict.youdao.com/dictvoice?audio=";
803
- var runtime = null;
804
- async function isExecutable(cmd) {
805
- return new Promise((resolve2) => {
806
- const probe = spawn(cmd, ["--version"], { stdio: "ignore" });
807
- probe.on("error", () => resolve2(false));
808
- probe.on("exit", () => resolve2(true));
809
- setTimeout(() => {
810
- probe.kill();
811
- resolve2(false);
812
- }, 500);
813
- });
814
- }
815
- async function detect() {
816
- let wav = null;
817
- let mp3 = null;
818
- for (const p of CANDIDATES) {
819
- if (!await isExecutable(p.cmd)) continue;
820
- if (!wav && (p.supports === "wav" || p.supports === "both")) wav = p;
821
- if (!mp3 && (p.supports === "mp3" || p.supports === "both")) mp3 = p;
822
- if (wav && mp3) break;
823
- }
824
- return { wav, mp3 };
825
- }
826
- async function initAudio(disabledByConfig) {
827
- if (runtime) return runtime;
828
- if (disabledByConfig) {
829
- runtime = {
830
- disabled: true,
831
- wavPlayer: null,
832
- mp3Player: null,
833
- warning: null,
834
- keyQueue: new PQueue({ concurrency: 1 }),
835
- feedbackQueue: new PQueue({ concurrency: 1 }),
836
- pronQueue: new PQueue({ concurrency: 1 })
837
- };
838
- return runtime;
839
- }
840
- const { wav, mp3 } = await detect();
841
- let warning = null;
842
- if (!wav && !mp3) {
843
- warning = "No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled.";
844
- } else if (!mp3) {
845
- warning = "No MP3 player found; word pronunciations will be skipped.";
846
- }
847
- runtime = {
848
- disabled: !wav && !mp3,
849
- wavPlayer: wav,
850
- mp3Player: mp3,
851
- warning,
852
- keyQueue: new PQueue({ concurrency: 2 }),
853
- feedbackQueue: new PQueue({ concurrency: 1 }),
854
- pronQueue: new PQueue({ concurrency: 1 })
855
- };
856
- return runtime;
857
- }
858
- function spawnPlay(player, file) {
859
- try {
860
- const child = spawn(player.cmd, player.args(file), {
861
- detached: true,
862
- stdio: "ignore"
863
- });
864
- child.on("error", () => {
865
- });
866
- child.unref();
867
- } catch {
868
- }
869
- }
870
- function playFile(file, kind) {
871
- if (!runtime || runtime.disabled) return;
872
- const player = kind === "wav" ? runtime.wavPlayer : runtime.mp3Player;
873
- if (!player) return;
874
- spawnPlay(player, file);
875
- }
876
- function playKeystroke() {
877
- if (!runtime || runtime.disabled) return;
878
- if (runtime.keyQueue.size >= 2) return;
879
- void runtime.keyQueue.add(async () => {
880
- playFile(join3(packageAssetsDir(), "sounds", "key-default.wav"), "wav");
881
- await new Promise((r) => setTimeout(r, 30));
882
- });
883
- }
884
- function playCorrect() {
885
- if (!runtime || runtime.disabled) return;
886
- void runtime.feedbackQueue.add(async () => {
887
- playFile(join3(packageAssetsDir(), "sounds", "correct.wav"), "wav");
888
- await new Promise((r) => setTimeout(r, 50));
889
- });
890
- }
891
- function playWrong() {
892
- if (!runtime || runtime.disabled) return;
893
- void runtime.feedbackQueue.add(async () => {
894
- playFile(join3(packageAssetsDir(), "sounds", "wrong.wav"), "wav");
895
- await new Promise((r) => setTimeout(r, 50));
896
- });
897
- }
898
- async function downloadPronunciation(word, accent) {
899
- const cacheFile = paths.audioCache(word, accent);
900
- if (await exists(cacheFile)) return cacheFile;
901
- await mkdir3(dirname2(cacheFile), { recursive: true });
902
- const type = accent === "us" ? 2 : 1;
903
- const url = `${PRON_API}${encodeURIComponent(word)}&type=${type}`;
904
- try {
905
- const res = await request2(url, { headersTimeout: 8e3, bodyTimeout: 2e4 });
906
- if (res.statusCode >= 400) return null;
907
- const buf = Buffer.from(await res.body.arrayBuffer());
908
- if (buf.length < 1024) return null;
909
- const tmp = `${cacheFile}.tmp`;
910
- await writeFile2(tmp, buf);
911
- await rename2(tmp, cacheFile);
912
- return cacheFile;
913
- } catch {
914
- return null;
915
- }
916
- }
917
- async function playPronunciation(word, accent) {
918
- if (!runtime || runtime.disabled || !runtime.mp3Player) return;
919
- await ensureDirs();
920
- await runtime.pronQueue.add(async () => {
921
- const file = await downloadPronunciation(word, accent);
922
- if (file) playFile(file, "mp3");
923
- });
924
- }
925
- async function prefetchPronunciation(word, accent) {
926
- if (!runtime || runtime.disabled || !runtime.mp3Player) return;
927
- await ensureDirs();
928
- void runtime.pronQueue.add(async () => {
929
- await downloadPronunciation(word, accent);
930
- });
931
- }
932
- function audioWarning() {
933
- return runtime?.warning ?? null;
934
- }
935
-
936
- // src/ui/hooks/useAudio.ts
1181
+ import { useEffect as useEffect4, useRef as useRef2 } from "react";
937
1182
  function useAudio(opts) {
938
1183
  const initedRef = useRef2(false);
939
- useEffect2(() => {
1184
+ useEffect4(() => {
940
1185
  if (initedRef.current) return;
941
1186
  initedRef.current = true;
942
- initAudio(!opts.enabled).then(() => {
943
- const w = audioWarning();
944
- if (w) opts.onWarning?.(w);
945
- }).catch(() => void 0);
946
- }, [opts]);
1187
+ initAudio(!opts.enabled).catch(() => void 0);
1188
+ }, [opts.enabled]);
947
1189
  return {
948
1190
  keystroke: () => opts.enabled && playKeystroke(),
949
1191
  correct: () => opts.enabled && playCorrect(),
@@ -959,267 +1201,1082 @@ function useAudio(opts) {
959
1201
  };
960
1202
  }
961
1203
 
962
- // src/ui/components/WordInput.tsx
963
- import { Box, Text } from "ink";
964
- import { jsx } from "react/jsx-runtime";
965
- function WordInput({ state, hideTarget = false, flashError = false }) {
966
- const target = [...state.target];
967
- const typed = [...state.typed];
968
- return /* @__PURE__ */ jsx(Box, { children: target.map((ch, i) => {
969
- const t = typed[i];
970
- if (t !== void 0) {
971
- return /* @__PURE__ */ jsx(Text, { bold: true, color: "white", children: t }, i);
1204
+ // src/ui/hooks/useSessionPersistence.ts
1205
+ import { useCallback as useCallback3 } from "react";
1206
+
1207
+ // src/domain/stats.ts
1208
+ import { z as z4 } from "zod";
1209
+ var SessionRecordSchema = z4.object({
1210
+ ts: z4.string(),
1211
+ dictId: z4.string(),
1212
+ chapter: z4.number().int().nonnegative(),
1213
+ mode: z4.string(),
1214
+ wordCount: z4.number().int().nonnegative(),
1215
+ errors: z4.number().int().nonnegative(),
1216
+ durationMs: z4.number().int().nonnegative(),
1217
+ perWordErrors: z4.record(z4.string(), z4.number().int().nonnegative()).default({})
1218
+ });
1219
+ async function appendSession(record) {
1220
+ await appendJsonl(paths.stats, record);
1221
+ }
1222
+ async function loadSessions() {
1223
+ const rows = await readJsonl(paths.stats);
1224
+ return rows.map((r) => SessionRecordSchema.safeParse(r)).filter((r) => r.success).map((r) => r.data);
1225
+ }
1226
+ function computeWPM(record) {
1227
+ if (record.durationMs === 0) return 0;
1228
+ const minutes = record.durationMs / 6e4;
1229
+ return Math.round(record.wordCount / minutes * 10) / 10;
1230
+ }
1231
+ function accuracy(record) {
1232
+ if (record.wordCount === 0) return 1;
1233
+ const wordsWithErrors = Object.keys(record.perWordErrors).length;
1234
+ return Math.max(0, Math.min(1, (record.wordCount - wordsWithErrors) / record.wordCount));
1235
+ }
1236
+ function dailyStreak(sessions, now = /* @__PURE__ */ new Date()) {
1237
+ if (sessions.length === 0) return 0;
1238
+ const days = /* @__PURE__ */ new Set();
1239
+ for (const s of sessions) days.add(s.ts.slice(0, 10));
1240
+ let streak = 0;
1241
+ const cur = new Date(now);
1242
+ while (true) {
1243
+ const key = cur.toISOString().slice(0, 10);
1244
+ if (!days.has(key)) break;
1245
+ streak++;
1246
+ cur.setUTCDate(cur.getUTCDate() - 1);
1247
+ }
1248
+ return streak;
1249
+ }
1250
+ var SPARK = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
1251
+ function sparkline(values) {
1252
+ if (values.length === 0) return "";
1253
+ const max = Math.max(...values, 1);
1254
+ const min = Math.min(...values, 0);
1255
+ const range = Math.max(1, max - min);
1256
+ return values.map((v) => {
1257
+ const idx = Math.floor((v - min) / range * (SPARK.length - 1));
1258
+ return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
1259
+ }).join("");
1260
+ }
1261
+ function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
1262
+ const out = [];
1263
+ const byDay = /* @__PURE__ */ new Map();
1264
+ for (const s of sessions) {
1265
+ const key = s.ts.slice(0, 10);
1266
+ const arr = byDay.get(key) ?? [];
1267
+ arr.push(s);
1268
+ byDay.set(key, arr);
1269
+ }
1270
+ const cur = new Date(now);
1271
+ cur.setUTCDate(cur.getUTCDate() - (days - 1));
1272
+ for (let i = 0; i < days; i++) {
1273
+ const key = cur.toISOString().slice(0, 10);
1274
+ const todays = byDay.get(key) ?? [];
1275
+ if (todays.length === 0) {
1276
+ out.push({ date: key, wpm: 0, accuracy: 0, sessions: 0 });
1277
+ } else {
1278
+ const wpm = todays.reduce((a, s) => a + computeWPM(s), 0) / todays.length;
1279
+ const acc = todays.reduce((a, s) => a + accuracy(s), 0) / todays.length;
1280
+ out.push({ date: key, wpm, accuracy: acc, sessions: todays.length });
1281
+ }
1282
+ cur.setUTCDate(cur.getUTCDate() + 1);
1283
+ }
1284
+ return out;
1285
+ }
1286
+
1287
+ // src/ui/hooks/useSessionPersistence.ts
1288
+ function useSessionPersistence(meta) {
1289
+ return useCallback3(
1290
+ async (summary) => {
1291
+ const rec = {
1292
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1293
+ dictId: meta.dictId,
1294
+ chapter: meta.chapterIndex,
1295
+ mode: meta.mode,
1296
+ wordCount: summary.wordCount,
1297
+ errors: summary.errors,
1298
+ durationMs: summary.durationMs,
1299
+ perWordErrors: summary.perWordErrors
1300
+ };
1301
+ await appendSession(rec);
1302
+ const dirty = Object.entries(summary.perWordErrors).filter(([, n]) => n > 0);
1303
+ if (dirty.length === 0) return;
1304
+ let book = await loadMistakes();
1305
+ for (const [word, n] of dirty) book = bump(book, word, meta.dictId, n);
1306
+ await saveMistakes(book);
1307
+ },
1308
+ [meta.dictId, meta.chapterIndex, meta.mode]
1309
+ );
1310
+ }
1311
+
1312
+ // src/ui/screens/PracticeScreen.tsx
1313
+ import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
1314
+ function PracticeScreen({ params }) {
1315
+ const { dictId, chapterIndex, mode } = params;
1316
+ const { cfg } = useAppState();
1317
+ const nav = useNav();
1318
+ const [phase, setPhase] = useState6("loading");
1319
+ const [loaded, setLoaded] = useState6(null);
1320
+ const [errorMsg, setErrorMsg] = useState6(null);
1321
+ useEffect5(() => {
1322
+ let cancelled = false;
1323
+ setPhase("loading");
1324
+ setLoaded(null);
1325
+ setErrorMsg(null);
1326
+ (async () => {
1327
+ try {
1328
+ const words = await ensureDictionary(dictId);
1329
+ if (cancelled) return;
1330
+ if (mode === "review") {
1331
+ const book = await loadMistakes();
1332
+ if (cancelled) return;
1333
+ const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
1334
+ if (reviewWords.length === 0) {
1335
+ setErrorMsg("No mistakes to review yet. Practice some chapters first.");
1336
+ setPhase("error");
1337
+ return;
1338
+ }
1339
+ setLoaded({ playlist: reviewWords, totalChapters: 1 });
1340
+ setPhase("typing");
1341
+ return;
1342
+ }
1343
+ const chapters = chunkChapters(words, cfg.chapterSize);
1344
+ if (chapters.length === 0) {
1345
+ setErrorMsg(`Dictionary ${dictId} is empty.`);
1346
+ setPhase("error");
1347
+ return;
1348
+ }
1349
+ const idx = Math.max(0, Math.min(chapters.length - 1, chapterIndex));
1350
+ const playlist = buildPlaylist(chapters[idx], mode);
1351
+ setLoaded({ playlist, totalChapters: chapters.length });
1352
+ setPhase("typing");
1353
+ } catch (err) {
1354
+ if (cancelled) return;
1355
+ setErrorMsg(err.message);
1356
+ setPhase("error");
1357
+ }
1358
+ })();
1359
+ return () => {
1360
+ cancelled = true;
1361
+ };
1362
+ }, [dictId, chapterIndex, mode, cfg.chapterSize]);
1363
+ if (phase === "loading") {
1364
+ return /* @__PURE__ */ jsx7(Centered, { text: "loading\u2026", color: PALETTE.muted });
1365
+ }
1366
+ if (phase === "error") {
1367
+ return /* @__PURE__ */ jsx7(ErrorView, { msg: errorMsg ?? "Unknown error" });
1368
+ }
1369
+ if (!loaded) return null;
1370
+ return /* @__PURE__ */ jsx7(
1371
+ PracticeRunner,
1372
+ {
1373
+ params,
1374
+ loaded,
1375
+ phase,
1376
+ setPhase
1377
+ },
1378
+ `${dictId}-${chapterIndex}-${mode}`
1379
+ );
1380
+ }
1381
+ function PracticeRunner({
1382
+ params,
1383
+ loaded,
1384
+ phase,
1385
+ setPhase
1386
+ }) {
1387
+ const { dictId, chapterIndex, mode } = params;
1388
+ const { cfg } = useAppState();
1389
+ const nav = useNav();
1390
+ const { exit } = useApp3();
1391
+ const goBack = () => nav.stack.length > 1 ? nav.back() : exit();
1392
+ const persist = useSessionPersistence({ dictId, chapterIndex, mode });
1393
+ const audio = useAudio({
1394
+ enabled: cfg.sounds.master,
1395
+ accent: cfg.accent,
1396
+ autoplayPronunciation: cfg.autoplayPronunciation
1397
+ });
1398
+ const finishedRef = useRef3(false);
1399
+ const lastEffectRef = useRef3(null);
1400
+ const lastIndexRef = useRef3(-1);
1401
+ const { session, lastEffect, tick } = useWordLoop({
1402
+ playlist: loaded.playlist,
1403
+ enabled: phase === "typing",
1404
+ onComplete: (s) => {
1405
+ if (finishedRef.current) return;
1406
+ finishedRef.current = true;
1407
+ setPhase("summary");
1408
+ Promise.resolve(persist(sessionSummary(s))).catch((err) => {
1409
+ console.error("Failed to persist session:", err);
1410
+ });
1411
+ },
1412
+ onEscape: () => setPhase(phase === "paused" ? "typing" : "paused"),
1413
+ onTab: () => {
1414
+ const cur = session.current ? loaded.playlist[session.current.wordIndex] : void 0;
1415
+ if (cur) void audio.pronounce(cur.name);
972
1416
  }
973
- if (hideTarget) {
974
- return /* @__PURE__ */ jsx(Text, { dimColor: true, children: "_" }, i);
1417
+ });
1418
+ useEffect5(() => {
1419
+ if (lastEffect === null) return;
1420
+ if (lastEffect === lastEffectRef.current) return;
1421
+ lastEffectRef.current = lastEffect;
1422
+ if (lastEffect === "wrong" && cfg.sounds.feedback) audio.wrong();
1423
+ if (lastEffect === "progress" && cfg.sounds.keystroke) audio.keystroke();
1424
+ if (lastEffect === "correct") {
1425
+ if (cfg.sounds.feedback) audio.correct();
1426
+ if (cfg.sounds.keystroke) audio.keystroke();
975
1427
  }
976
- return /* @__PURE__ */ jsx(Text, { dimColor: true, underline: true, color: flashError ? "red" : void 0, children: ch }, i);
977
- }) });
1428
+ }, [lastEffect, audio, cfg.sounds.feedback, cfg.sounds.keystroke]);
1429
+ useEffect5(() => {
1430
+ const idx = session.current?.wordIndex ?? -1;
1431
+ if (idx === -1) return;
1432
+ if (idx === lastIndexRef.current) return;
1433
+ lastIndexRef.current = idx;
1434
+ const cur = loaded.playlist[idx];
1435
+ const next = loaded.playlist[idx + 1];
1436
+ if (cur && cfg.autoplayPronunciation) audio.pronounce(cur.name);
1437
+ if (next) audio.prefetch(next.name);
1438
+ }, [session.current?.wordIndex, audio, cfg.autoplayPronunciation, loaded.playlist]);
1439
+ void tick;
1440
+ useInput3(
1441
+ (input) => {
1442
+ if (input === "r") setPhase("typing");
1443
+ if (input === "q") goBack();
1444
+ },
1445
+ { isActive: phase === "paused" }
1446
+ );
1447
+ useInput3(
1448
+ (input) => {
1449
+ if (input === "q") {
1450
+ goBack();
1451
+ return;
1452
+ }
1453
+ if (input === "n") {
1454
+ const nextIdx = chapterIndex + 1;
1455
+ if (mode === "loop") {
1456
+ nav.replace({ name: "practice", params: { dictId, chapterIndex, mode } });
1457
+ } else if (mode === "review" || nextIdx >= loaded.totalChapters) {
1458
+ goBack();
1459
+ } else {
1460
+ nav.replace({ name: "practice", params: { dictId, chapterIndex: nextIdx, mode } });
1461
+ }
1462
+ return;
1463
+ }
1464
+ if (input === "m") {
1465
+ nav.replace({ name: "practice", params: { dictId, chapterIndex: 0, mode: "review" } });
1466
+ return;
1467
+ }
1468
+ },
1469
+ { isActive: phase === "summary" }
1470
+ );
1471
+ if (phase === "paused") return /* @__PURE__ */ jsx7(PausedView, {});
1472
+ if (phase === "summary") {
1473
+ return /* @__PURE__ */ jsx7(
1474
+ SummaryView,
1475
+ {
1476
+ dictId,
1477
+ chapterIndex,
1478
+ totalChapters: loaded.totalChapters,
1479
+ mode,
1480
+ summary: sessionSummary(session)
1481
+ }
1482
+ );
1483
+ }
1484
+ const currentWord = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
1485
+ const inputState = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
1486
+ const elapsedMs = Date.now() - session.startedAt;
1487
+ const completed = session.results.length;
1488
+ const errors = session.results.reduce((a, r) => a + r.errors, 0);
1489
+ const minutes = elapsedMs / 6e4;
1490
+ const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
1491
+ return /* @__PURE__ */ jsx7(
1492
+ TypingLayout,
1493
+ {
1494
+ dictId,
1495
+ chapterIndex,
1496
+ totalChapters: loaded.totalChapters,
1497
+ mode,
1498
+ accent: cfg.accent,
1499
+ completed,
1500
+ total: loaded.playlist.length,
1501
+ errors,
1502
+ wpm,
1503
+ elapsedMs,
1504
+ target: currentWord?.name ?? "",
1505
+ typed: inputState.typed,
1506
+ flashError: lastEffect === "wrong",
1507
+ hideTarget: mode === "dictation",
1508
+ phonetic: pickPhonetic(currentWord, cfg.accent),
1509
+ translation: currentWord?.trans ?? []
1510
+ }
1511
+ );
1512
+ }
1513
+ function pickPhonetic(word, accent) {
1514
+ if (!word) return null;
1515
+ const p = accent === "us" ? word.usphone : word.ukphone;
1516
+ return p ? `/${p}/` : null;
1517
+ }
1518
+ function fmtTime(ms) {
1519
+ const total = Math.floor(ms / 1e3);
1520
+ const m = Math.floor(total / 60);
1521
+ const s = total % 60;
1522
+ return `${m}:${String(s).padStart(2, "0")}`;
1523
+ }
1524
+ function TypingLayout(props) {
1525
+ const progressFrac = props.total === 0 ? 0 : props.completed / props.total;
1526
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1527
+ /* @__PURE__ */ jsx7(
1528
+ StatusBar,
1529
+ {
1530
+ dictId: props.dictId,
1531
+ chapterIndex: props.chapterIndex,
1532
+ totalChapters: props.totalChapters,
1533
+ mode: props.mode,
1534
+ accent: props.accent,
1535
+ completed: props.completed,
1536
+ total: props.total,
1537
+ elapsedMs: props.elapsedMs
1538
+ }
1539
+ ),
1540
+ /* @__PURE__ */ jsxs3(Box3, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
1541
+ /* @__PURE__ */ jsx7(
1542
+ BigWord,
1543
+ {
1544
+ target: props.target,
1545
+ typed: props.typed,
1546
+ error: props.flashError,
1547
+ hideTarget: props.hideTarget
1548
+ }
1549
+ ),
1550
+ props.phonetic && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
1551
+ props.translation.length > 0 && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((t, i) => /* @__PURE__ */ jsx7(Text3, { color: PALETTE.primary, children: t }, i)) })
1552
+ ] }),
1553
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1554
+ /* @__PURE__ */ jsx7(ProgressBar, { frac: progressFrac }),
1555
+ /* @__PURE__ */ jsx7(Box3, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
1556
+ props.completed,
1557
+ "/",
1558
+ props.total,
1559
+ " \xB7 ",
1560
+ fmtTime(props.elapsedMs),
1561
+ " \xB7 ",
1562
+ props.wpm,
1563
+ " wpm \xB7 ",
1564
+ props.errors,
1565
+ " errors"
1566
+ ] }) }),
1567
+ /* @__PURE__ */ jsx7(Box3, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay \xB7 Ctrl+C quit" }) })
1568
+ ] })
1569
+ ] });
1570
+ }
1571
+ function StatusBar(props) {
1572
+ const left = props.mode === "review" ? `${props.dictId} \xB7 review \xB7 ${props.accent}` : `${props.dictId} \xB7 ch ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode} \xB7 ${props.accent}`;
1573
+ const right = `${props.completed}/${props.total} \xB7 ${fmtTime(props.elapsedMs)}`;
1574
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
1575
+ /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: left }),
1576
+ /* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
1577
+ /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: right })
1578
+ ] });
1579
+ }
1580
+ function ProgressBar({ frac }) {
1581
+ const cols = process.stdout.columns ?? 80;
1582
+ const width = Math.max(20, Math.min(72, cols - 16));
1583
+ const filled = Math.round(width * Math.max(0, Math.min(1, frac)));
1584
+ const empty = width - filled;
1585
+ return /* @__PURE__ */ jsxs3(Box3, { justifyContent: "center", children: [
1586
+ /* @__PURE__ */ jsx7(Text3, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
1587
+ /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
1588
+ ] });
978
1589
  }
979
-
980
- // src/ui/components/Phonetic.tsx
981
- import { Text as Text2 } from "ink";
982
- import { jsxs } from "react/jsx-runtime";
983
- function Phonetic({ word, accent }) {
984
- const phon = accent === "us" ? word.usphone : word.ukphone;
985
- if (!phon) return null;
986
- return /* @__PURE__ */ jsxs(Text2, { dimColor: true, children: [
987
- "/",
988
- phon,
989
- "/"
1590
+ function PausedView() {
1591
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
1592
+ /* @__PURE__ */ jsx7(BigText2, { text: "paused", font: "tiny", colors: [PALETTE.warning] }),
1593
+ /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[r] resume \xB7 [q] quit to menu" }) })
990
1594
  ] });
991
1595
  }
992
-
993
- // src/ui/components/Translation.tsx
994
- import { Box as Box2, Text as Text3 } from "ink";
995
- import { jsx as jsx2 } from "react/jsx-runtime";
996
- function Translation({ word }) {
997
- if (!word.trans || word.trans.length === 0) return null;
998
- return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: word.trans.map((t, i) => /* @__PURE__ */ jsx2(Text3, { color: "cyan", children: t }, i)) });
1596
+ function ErrorView({ msg }) {
1597
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
1598
+ /* @__PURE__ */ jsx7(Text3, { color: PALETTE.error, children: msg }),
1599
+ /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[q] back to menu" }) }),
1600
+ /* @__PURE__ */ jsx7(BackKey, {})
1601
+ ] });
999
1602
  }
1000
-
1001
- // src/ui/components/Progress.tsx
1002
- import { Box as Box3, Text as Text4 } from "ink";
1003
- import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1004
- function Progress({ current, total, errors, elapsedMs }) {
1005
- const minutes = elapsedMs / 6e4;
1006
- const wpm = minutes > 0 ? Math.round(current / minutes) : 0;
1007
- const seconds = Math.floor(elapsedMs / 1e3);
1008
- return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsxs2(Text4, { children: [
1009
- /* @__PURE__ */ jsx3(Text4, { dimColor: true, children: "progress " }),
1010
- /* @__PURE__ */ jsx3(Text4, { bold: true, children: current }),
1011
- /* @__PURE__ */ jsxs2(Text4, { dimColor: true, children: [
1012
- "/",
1013
- total
1603
+ function BackKey() {
1604
+ const nav = useNav();
1605
+ useInput3((input, key) => {
1606
+ if (input === "q" || key.escape) nav.back();
1607
+ });
1608
+ return null;
1609
+ }
1610
+ function Centered({ text, color }) {
1611
+ return /* @__PURE__ */ jsx7(Box3, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx7(Text3, { color, children: text }) });
1612
+ }
1613
+ function SummaryView(props) {
1614
+ const { summary } = props;
1615
+ const minutes = summary.durationMs / 6e4;
1616
+ const wpm = minutes > 0 ? Math.round(summary.wordCount / minutes * 10) / 10 : 0;
1617
+ const errorWords = Object.keys(summary.perWordErrors).length;
1618
+ const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
1619
+ const accPct = Math.round(acc * 1e3) / 10;
1620
+ const subtitle = props.mode === "review" ? `${props.dictId} \xB7 review` : `${props.dictId} \xB7 chapter ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode}`;
1621
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
1622
+ /* @__PURE__ */ jsx7(BigText2, { text: "complete", font: "tiny", colors: [PALETTE.success] }),
1623
+ /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: subtitle }) }),
1624
+ /* @__PURE__ */ jsxs3(Box3, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
1625
+ /* @__PURE__ */ jsx7(StatCard, { label: "words", value: String(summary.wordCount), color: PALETTE.text }),
1626
+ /* @__PURE__ */ jsx7(
1627
+ StatCard,
1628
+ {
1629
+ label: "errors",
1630
+ value: String(summary.errors),
1631
+ color: summary.errors > 0 ? PALETTE.error : PALETTE.muted
1632
+ }
1633
+ ),
1634
+ /* @__PURE__ */ jsx7(StatCard, { label: "wpm", value: String(wpm), color: PALETTE.accent }),
1635
+ /* @__PURE__ */ jsx7(StatCard, { label: "accuracy", value: `${accPct}%`, color: PALETTE.accent })
1014
1636
  ] }),
1015
- /* @__PURE__ */ jsx3(Text4, { dimColor: true, children: " errors " }),
1016
- /* @__PURE__ */ jsx3(Text4, { color: errors > 0 ? "red" : "green", children: errors }),
1017
- /* @__PURE__ */ jsx3(Text4, { dimColor: true, children: " wpm " }),
1018
- /* @__PURE__ */ jsx3(Text4, { children: wpm }),
1019
- /* @__PURE__ */ jsx3(Text4, { dimColor: true, children: " time " }),
1020
- /* @__PURE__ */ jsxs2(Text4, { children: [
1021
- Math.floor(seconds / 60),
1022
- ":",
1023
- String(seconds % 60).padStart(2, "0")
1024
- ] })
1025
- ] }) });
1637
+ /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
1638
+ "elapsed ",
1639
+ fmtTime(summary.durationMs)
1640
+ ] }) }),
1641
+ /* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
1642
+ /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[n] next chapter \xB7 [m] review mistakes \xB7 [q] back to menu" }) })
1643
+ ] });
1644
+ }
1645
+ function StatCard({ label, value, color }) {
1646
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", marginX: 2, children: [
1647
+ /* @__PURE__ */ jsx7(BigText2, { text: value, font: "tiny", colors: [color] }),
1648
+ /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: label })
1649
+ ] });
1026
1650
  }
1027
1651
 
1028
- // src/ui/screens/PracticeScreen.tsx
1029
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1030
- function PracticeScreen(props) {
1031
- const [paused, setPaused] = useState2(false);
1032
- const [finished, setFinished] = useState2(null);
1033
- const [audioWarn, setAudioWarn] = useState2(null);
1034
- const { exit } = useApp2();
1035
- const audio = useAudio({
1036
- enabled: props.audio.master,
1037
- accent: props.accent,
1038
- autoplayPronunciation: props.audio.autoplayPronunciation,
1039
- onWarning: setAudioWarn
1040
- });
1041
- const lastEffectRef = useRef3(null);
1042
- const lastIndexRef = useRef3(-1);
1043
- const { session, lastEffect, tick } = useWordLoop({
1044
- playlist: props.playlist,
1045
- enabled: !paused && finished === null,
1046
- onComplete: (s) => {
1047
- setFinished(s);
1048
- Promise.resolve(props.onSessionComplete(sessionSummary(s))).catch((err) => {
1049
- console.error("Failed to persist session:", err);
1652
+ // src/ui/screens/DictBrowser.tsx
1653
+ import { useEffect as useEffect6, useState as useState7 } from "react";
1654
+ import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
1655
+ import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
1656
+ function DictBrowser({ params }) {
1657
+ const nav = useNav();
1658
+ const { cfg, setCfg } = useAppState();
1659
+ const [rows, setRows] = useState7([]);
1660
+ const [loading, setLoading] = useState7(true);
1661
+ const [selected, setSelected] = useState7(0);
1662
+ const [filter, setFilter] = useState7("");
1663
+ const [filterFocus, setFilterFocus] = useState7(false);
1664
+ const [pending, setPending] = useState7(null);
1665
+ const [tick, setTick] = useState7(0);
1666
+ const refresh = async () => {
1667
+ const reg = await loadRegistry();
1668
+ const flagged = await Promise.all(
1669
+ reg.map(async (e) => ({ entry: e, local: await isLocallyAvailable(e.id) }))
1670
+ );
1671
+ setRows(flagged);
1672
+ setLoading(false);
1673
+ };
1674
+ useEffect6(() => {
1675
+ void refresh();
1676
+ }, [tick]);
1677
+ const filtered = filter ? rows.filter((r) => filterRegistry([r.entry], filter).length > 0) : rows;
1678
+ const safeSelected = Math.max(0, Math.min(filtered.length - 1, selected));
1679
+ const current = filtered[safeSelected];
1680
+ useInput4((input, key) => {
1681
+ if (filterFocus) {
1682
+ if (key.escape || key.return) {
1683
+ setFilterFocus(false);
1684
+ return;
1685
+ }
1686
+ if (key.backspace || key.delete) {
1687
+ setFilter((f) => f.slice(0, -1));
1688
+ return;
1689
+ }
1690
+ if (input && !key.ctrl && !key.meta) {
1691
+ setFilter((f) => f + input);
1692
+ }
1693
+ return;
1694
+ }
1695
+ if (key.upArrow) setSelected((i) => Math.max(0, i - 1));
1696
+ if (key.downArrow) setSelected((i) => Math.min(filtered.length - 1, i + 1));
1697
+ if (input === "/") {
1698
+ setFilterFocus(true);
1699
+ return;
1700
+ }
1701
+ if (key.escape || input === "b") {
1702
+ nav.back();
1703
+ return;
1704
+ }
1705
+ if (!current) return;
1706
+ if (key.return) {
1707
+ void (async () => {
1708
+ await setCfg({ ...cfg, defaultDict: current.entry.id });
1709
+ if (params?.pickerMode === "choose-then-practice") {
1710
+ nav.replace({
1711
+ name: "practice",
1712
+ params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
1713
+ });
1714
+ } else {
1715
+ nav.back();
1716
+ }
1717
+ })();
1718
+ return;
1719
+ }
1720
+ if (input === "p") {
1721
+ nav.replace({
1722
+ name: "practice",
1723
+ params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
1050
1724
  });
1051
- },
1052
- onEscape: () => setPaused((p) => !p),
1053
- onTab: () => {
1054
- const cur = session.current ? props.playlist[session.current.wordIndex] : void 0;
1055
- if (cur) void audio.pronounce(cur.name);
1725
+ return;
1726
+ }
1727
+ if (input === "r" && current.local) {
1728
+ setPending({ kind: "removing", id: current.entry.id });
1729
+ void (async () => {
1730
+ try {
1731
+ await removeDictionary(current.entry.id);
1732
+ setPending(null);
1733
+ setTick((t) => t + 1);
1734
+ } catch (err) {
1735
+ setPending({ kind: "error", id: current.entry.id, msg: err.message });
1736
+ }
1737
+ })();
1738
+ return;
1739
+ }
1740
+ if (input === "u") {
1741
+ setPending({ kind: "pulling", id: current.entry.id });
1742
+ void (async () => {
1743
+ try {
1744
+ await pullDictionary(current.entry.id);
1745
+ setPending(null);
1746
+ setTick((t) => t + 1);
1747
+ } catch (err) {
1748
+ setPending({ kind: "error", id: current.entry.id, msg: err.message });
1749
+ }
1750
+ })();
1056
1751
  }
1057
1752
  });
1058
- useEffect3(() => {
1059
- if (lastEffect === null) return;
1060
- if (lastEffect === lastEffectRef.current) return;
1061
- lastEffectRef.current = lastEffect;
1062
- if (lastEffect === "wrong" && props.audio.feedback) audio.wrong();
1063
- if (lastEffect === "progress" && props.audio.keystroke) audio.keystroke();
1064
- if (lastEffect === "correct") {
1065
- if (props.audio.feedback) audio.correct();
1066
- if (props.audio.keystroke) audio.keystroke();
1753
+ if (loading) {
1754
+ return /* @__PURE__ */ jsx8(Box4, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: "loading dictionaries\u2026" }) });
1755
+ }
1756
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1757
+ /* @__PURE__ */ jsxs4(Box4, { children: [
1758
+ /* @__PURE__ */ jsx8(Text4, { bold: true, color: PALETTE.accent, children: "Dictionaries" }),
1759
+ /* @__PURE__ */ jsx8(Box4, { flexGrow: 1 }),
1760
+ /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: filterFocus ? `/ ${filter}_` : filter ? `/ ${filter}` : `${filtered.length} entries` })
1761
+ ] }),
1762
+ /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexGrow: 1, children: [
1763
+ /* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "60%", children: filtered.slice(Math.max(0, safeSelected - 8), safeSelected + 16).map((row, vi) => {
1764
+ const i = Math.max(0, safeSelected - 8) + vi;
1765
+ const active = i === safeSelected;
1766
+ const isDefault = cfg.defaultDict === row.entry.id;
1767
+ return /* @__PURE__ */ jsxs4(Box4, { children: [
1768
+ /* @__PURE__ */ jsx8(Text4, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1769
+ /* @__PURE__ */ jsx8(Text4, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }),
1770
+ /* @__PURE__ */ jsx8(Text4, { children: " " }),
1771
+ /* @__PURE__ */ jsx8(Text4, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }),
1772
+ /* @__PURE__ */ jsx8(Text4, { children: " " }),
1773
+ /* @__PURE__ */ jsx8(Text4, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: row.entry.id.slice(0, 14).padEnd(15) }),
1774
+ /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) })
1775
+ ] }, row.entry.id);
1776
+ }) }),
1777
+ /* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "40%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs4(Fragment2, { children: [
1778
+ /* @__PURE__ */ jsx8(Text4, { bold: true, color: PALETTE.text, children: current.entry.name }),
1779
+ /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: current.entry.id }),
1780
+ /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
1781
+ current.entry.language,
1782
+ " \xB7 ",
1783
+ current.entry.category,
1784
+ " \xB7 ",
1785
+ current.entry.length,
1786
+ " words"
1787
+ ] }) }),
1788
+ /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.primary, children: current.entry.description || "(no description)" }) }),
1789
+ current.entry.tags.length > 0 && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
1790
+ "tags: ",
1791
+ current.entry.tags.join(", ")
1792
+ ] }) }),
1793
+ /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
1794
+ /* @__PURE__ */ jsx8(Text4, { color: current.local ? PALETTE.accent : PALETTE.muted, children: current.local ? "local \u2713" : "not local" }),
1795
+ cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx8(Text4, { color: PALETTE.success, children: " \xB7 default \u2605" })
1796
+ ] })
1797
+ ] }) })
1798
+ ] }),
1799
+ pending && /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
1800
+ pending.kind === "pulling" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.warning, children: [
1801
+ "pulling ",
1802
+ pending.id,
1803
+ "\u2026"
1804
+ ] }),
1805
+ pending.kind === "removing" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.warning, children: [
1806
+ "removing ",
1807
+ pending.id,
1808
+ "\u2026"
1809
+ ] }),
1810
+ pending.kind === "error" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.error, children: [
1811
+ "error on ",
1812
+ pending.id,
1813
+ ": ",
1814
+ pending.msg
1815
+ ] })
1816
+ ] }),
1817
+ /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: "\u2191/\u2193 select \xB7 Enter set default \xB7 p practice \xB7 u pull \xB7 r remove \xB7 / filter \xB7 Esc back" }) })
1818
+ ] });
1819
+ }
1820
+
1821
+ // src/ui/screens/ConfigEditor.tsx
1822
+ import { useState as useState8 } from "react";
1823
+ import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
1824
+ import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
1825
+ var FIELDS = [
1826
+ { kind: "dictRef", path: "defaultDict", label: "default dict" },
1827
+ { kind: "enum", path: "defaultMode", label: "default mode", options: ["order", "dictation", "review", "random", "loop"] },
1828
+ { kind: "enum", path: "accent", label: "accent", options: ["us", "uk"] },
1829
+ { kind: "enum", path: "mirror", label: "mirror", options: ["jsdelivr", "github"] },
1830
+ { kind: "int", path: "chapterSize", label: "chapter size", min: 1, max: 200 },
1831
+ { kind: "bool", path: "autoplayPronunciation", label: "autoplay pronunciation" },
1832
+ { kind: "bool", path: "sounds.master", label: "sounds master" },
1833
+ { kind: "bool", path: "sounds.keystroke", label: "sounds keystroke" },
1834
+ { kind: "bool", path: "sounds.feedback", label: "sounds feedback" },
1835
+ { kind: "string", path: "sounds.keySoundName", label: "sounds key sound" }
1836
+ ];
1837
+ function getByPath2(cfg, path) {
1838
+ return path.split(".").reduce((acc, k) => {
1839
+ if (acc && typeof acc === "object") return acc[k];
1840
+ return void 0;
1841
+ }, cfg);
1842
+ }
1843
+ function ConfigEditor() {
1844
+ const nav = useNav();
1845
+ const { cfg, setCfg } = useAppState();
1846
+ const [selected, setSelected] = useState8(0);
1847
+ const [editing, setEditing] = useState8(false);
1848
+ const [draft, setDraft] = useState8("");
1849
+ const [error, setError] = useState8(null);
1850
+ const field = FIELDS[selected];
1851
+ const currentValue = getByPath2(cfg, field.path);
1852
+ const commit = async (raw) => {
1853
+ try {
1854
+ const next = setByPath(cfg, field.path, raw);
1855
+ await setCfg(next);
1856
+ setEditing(false);
1857
+ setError(null);
1858
+ } catch (err) {
1859
+ setError(err.message);
1067
1860
  }
1068
- }, [lastEffect, audio, props.audio.feedback, props.audio.keystroke]);
1069
- useEffect3(() => {
1070
- const idx = session.current?.wordIndex ?? -1;
1071
- if (idx === -1) return;
1072
- if (idx === lastIndexRef.current) return;
1073
- lastIndexRef.current = idx;
1074
- const cur = props.playlist[idx];
1075
- const next = props.playlist[idx + 1];
1076
- if (cur && props.audio.autoplayPronunciation) audio.pronounce(cur.name);
1077
- if (next) audio.prefetch(next.name);
1078
- }, [session.current?.wordIndex, audio, props.audio.autoplayPronunciation, props.playlist]);
1079
- void tick;
1080
- useInput2(
1081
- (input) => {
1082
- if (input === "q") exit();
1083
- if (input === "r") setPaused(false);
1084
- },
1085
- { isActive: paused && finished === null }
1086
- );
1087
- useInput2(
1088
- (input) => {
1089
- if (input === "q") {
1090
- props.onQuit?.();
1091
- exit();
1861
+ };
1862
+ useInput5((input, key) => {
1863
+ if (editing && field.kind === "string") {
1864
+ if (key.escape) {
1865
+ setEditing(false);
1866
+ setError(null);
1867
+ return;
1092
1868
  }
1093
- if (input === "n") {
1094
- props.onAdvanceChapter?.();
1095
- exit();
1869
+ if (key.return) {
1870
+ void commit(draft);
1871
+ return;
1096
1872
  }
1097
- if (input === "m") {
1098
- props.onReviewMistakes?.();
1099
- exit();
1873
+ if (key.backspace || key.delete) {
1874
+ setDraft((d) => d.slice(0, -1));
1875
+ return;
1100
1876
  }
1101
- },
1102
- { isActive: finished !== null }
1877
+ if (input && !key.ctrl && !key.meta) setDraft((d) => d + input);
1878
+ return;
1879
+ }
1880
+ if (editing && field.kind === "int") {
1881
+ if (key.escape) {
1882
+ setEditing(false);
1883
+ setError(null);
1884
+ return;
1885
+ }
1886
+ if (key.return) {
1887
+ void commit(draft);
1888
+ return;
1889
+ }
1890
+ if (key.backspace || key.delete) {
1891
+ setDraft((d) => d.slice(0, -1));
1892
+ return;
1893
+ }
1894
+ if (/^[0-9]$/.test(input)) setDraft((d) => d + input);
1895
+ return;
1896
+ }
1897
+ if (key.escape || input === "b") {
1898
+ nav.back();
1899
+ return;
1900
+ }
1901
+ if (key.upArrow) {
1902
+ setSelected((i) => (i - 1 + FIELDS.length) % FIELDS.length);
1903
+ setError(null);
1904
+ return;
1905
+ }
1906
+ if (key.downArrow) {
1907
+ setSelected((i) => (i + 1) % FIELDS.length);
1908
+ setError(null);
1909
+ return;
1910
+ }
1911
+ if (field.kind === "bool" && (input === " " || key.return)) {
1912
+ void commit(currentValue ? "false" : "true");
1913
+ return;
1914
+ }
1915
+ if (field.kind === "enum" && (key.leftArrow || key.rightArrow)) {
1916
+ const idx = field.options.indexOf(String(currentValue));
1917
+ const delta = key.rightArrow ? 1 : -1;
1918
+ const next = field.options[(idx + delta + field.options.length) % field.options.length];
1919
+ void commit(next);
1920
+ return;
1921
+ }
1922
+ if (field.kind === "dictRef" && key.return) {
1923
+ nav.navigate({ name: "dict", params: { pickerMode: "set-default" } });
1924
+ return;
1925
+ }
1926
+ if ((field.kind === "string" || field.kind === "int") && key.return) {
1927
+ setDraft(String(currentValue ?? ""));
1928
+ setEditing(true);
1929
+ setError(null);
1930
+ }
1931
+ });
1932
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1933
+ /* @__PURE__ */ jsx9(Text5, { bold: true, color: PALETTE.accent, children: "Config" }),
1934
+ /* @__PURE__ */ jsx9(Box5, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
1935
+ const active = i === selected;
1936
+ const value = getByPath2(cfg, f.path);
1937
+ const display = renderValue(f, value, active && editing ? draft : null);
1938
+ return /* @__PURE__ */ jsxs5(Box5, { children: [
1939
+ /* @__PURE__ */ jsx9(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1940
+ /* @__PURE__ */ jsx9(Text5, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: f.label.padEnd(28) }),
1941
+ /* @__PURE__ */ jsx9(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
1942
+ ] }, f.path);
1943
+ }) }),
1944
+ error && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: PALETTE.error, children: [
1945
+ "! ",
1946
+ error
1947
+ ] }) }),
1948
+ /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text5, { color: PALETTE.muted, children: hintFor(field, editing) }) })
1949
+ ] });
1950
+ }
1951
+ function renderValue(field, value, draft) {
1952
+ if (draft !== null) return `${draft}_`;
1953
+ if (field.kind === "bool") return value ? "\u2713 on" : "\u2717 off";
1954
+ if (field.kind === "dictRef") return String(value ?? "(none)");
1955
+ if (field.kind === "enum") return `< ${value} >`;
1956
+ return String(value ?? "");
1957
+ }
1958
+ function hintFor(field, editing) {
1959
+ if (editing) return "type to edit \xB7 Enter save \xB7 Esc cancel";
1960
+ if (field.kind === "bool") return "space toggle \xB7 \u2191/\u2193 move \xB7 Esc back";
1961
+ if (field.kind === "enum") return "\u2190/\u2192 cycle \xB7 \u2191/\u2193 move \xB7 Esc back";
1962
+ if (field.kind === "dictRef") return "Enter pick dict \xB7 \u2191/\u2193 move \xB7 Esc back";
1963
+ return "Enter edit \xB7 \u2191/\u2193 move \xB7 Esc back";
1964
+ }
1965
+
1966
+ // src/ui/screens/StatsViewer.tsx
1967
+ import { useEffect as useEffect7, useState as useState9 } from "react";
1968
+ import { Box as Box6, Text as Text6, useInput as useInput6 } from "ink";
1969
+ import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
1970
+ var DAY_WINDOWS = [7, 14, 30, 90];
1971
+ function StatsViewer() {
1972
+ const nav = useNav();
1973
+ const [sessions, setSessions] = useState9(null);
1974
+ const [book, setBook] = useState9(null);
1975
+ const [windowIdx, setWindowIdx] = useState9(1);
1976
+ useEffect7(() => {
1977
+ void (async () => {
1978
+ const [s, b] = await Promise.all([loadSessions(), loadMistakes()]);
1979
+ setSessions(s);
1980
+ setBook(b);
1981
+ })();
1982
+ }, []);
1983
+ useInput6((input, key) => {
1984
+ if (key.escape || input === "b" || input === "q") {
1985
+ nav.back();
1986
+ return;
1987
+ }
1988
+ if (input === "n") setWindowIdx((i) => (i + 1) % DAY_WINDOWS.length);
1989
+ if (input === "N") setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
1990
+ });
1991
+ if (!sessions || !book) {
1992
+ return /* @__PURE__ */ jsx10(Box6, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "loading stats\u2026" }) });
1993
+ }
1994
+ if (sessions.length === 0) {
1995
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
1996
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "No practice history yet." }),
1997
+ /* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "Run a practice session first." }) }),
1998
+ /* @__PURE__ */ jsx10(Box6, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "[q] back" }) })
1999
+ ] });
2000
+ }
2001
+ const days = DAY_WINDOWS[windowIdx];
2002
+ const buckets = dailyBuckets(sessions, days);
2003
+ const streak = dailyStreak(sessions);
2004
+ const totalWords = sessions.reduce((a, s) => a + s.wordCount, 0);
2005
+ const totalErrors = sessions.reduce((a, s) => a + s.errors, 0);
2006
+ const totalMs = sessions.reduce((a, s) => a + s.durationMs, 0);
2007
+ const firstTryWords = sessions.reduce(
2008
+ (a, s) => a + (s.wordCount - Object.keys(s.perWordErrors).length),
2009
+ 0
1103
2010
  );
1104
- if (finished) {
1105
- const summary = sessionSummary(finished);
1106
- const minutes = summary.durationMs / 6e4;
1107
- const wpm = minutes > 0 ? Math.round(summary.wordCount / minutes * 10) / 10 : 0;
1108
- const errorWords = Object.keys(summary.perWordErrors).length;
1109
- const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
1110
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 1, paddingY: 1, borderStyle: "round", borderColor: "green", children: [
1111
- /* @__PURE__ */ jsxs3(Text5, { bold: true, color: "green", children: [
1112
- "Chapter complete: ",
1113
- props.dictId,
1114
- " chapter ",
1115
- props.chapterNumber,
1116
- "/",
1117
- props.totalChapters
2011
+ const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
2012
+ const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
2013
+ const recent = sessions.slice(-5).reverse();
2014
+ const top = topN(book, 8);
2015
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2016
+ /* @__PURE__ */ jsx10(Text6, { bold: true, color: PALETTE.accent, children: "Stats" }),
2017
+ /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
2018
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "lifetime" }),
2019
+ /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, children: [
2020
+ /* @__PURE__ */ jsx10(Stat, { label: "sessions", value: String(sessions.length) }),
2021
+ /* @__PURE__ */ jsx10(Stat, { label: "words", value: String(totalWords) }),
2022
+ /* @__PURE__ */ jsx10(Stat, { label: "errors", value: String(totalErrors) }),
2023
+ /* @__PURE__ */ jsx10(Stat, { label: "wpm", value: String(overallWpm), accent: true }),
2024
+ /* @__PURE__ */ jsx10(Stat, { label: "accuracy", value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
2025
+ /* @__PURE__ */ jsx10(Stat, { label: "streak", value: `${streak}d`, accent: true })
2026
+ ] })
2027
+ ] }),
2028
+ /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
2029
+ /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2030
+ "last ",
2031
+ days,
2032
+ " days (n / N to cycle window)"
1118
2033
  ] }),
1119
- /* @__PURE__ */ jsx4(Box4, { marginTop: 1, flexDirection: "column", children: /* @__PURE__ */ jsxs3(Text5, { children: [
1120
- /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: "words " }),
1121
- /* @__PURE__ */ jsx4(Text5, { bold: true, children: summary.wordCount }),
1122
- /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: " errors " }),
1123
- /* @__PURE__ */ jsx4(Text5, { color: summary.errors > 0 ? "red" : "green", children: summary.errors }),
1124
- /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: " wpm " }),
1125
- /* @__PURE__ */ jsx4(Text5, { children: wpm }),
1126
- /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: " accuracy " }),
1127
- /* @__PURE__ */ jsxs3(Text5, { children: [
1128
- Math.round(acc * 1e3) / 10,
2034
+ /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
2035
+ /* @__PURE__ */ jsxs6(Box6, { children: [
2036
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "wpm ".padEnd(10) }),
2037
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.wpm)) }),
2038
+ /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2039
+ " max ",
2040
+ Math.round(Math.max(...buckets.map((b) => b.wpm)))
2041
+ ] })
2042
+ ] }),
2043
+ /* @__PURE__ */ jsxs6(Box6, { children: [
2044
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "accuracy".padEnd(10) }),
2045
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.accuracy * 100)) })
2046
+ ] }),
2047
+ /* @__PURE__ */ jsxs6(Box6, { children: [
2048
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "sessions".padEnd(10) }),
2049
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.sessions)) })
2050
+ ] })
2051
+ ] })
2052
+ ] }),
2053
+ /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
2054
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "recent sessions" }),
2055
+ recent.map((s, i) => /* @__PURE__ */ jsxs6(Box6, { children: [
2056
+ /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2057
+ " ",
2058
+ s.ts.replace("T", " ").slice(0, 16),
2059
+ " "
2060
+ ] }),
2061
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
2062
+ /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2063
+ " ",
2064
+ "ch",
2065
+ String(s.chapter + 1).padStart(3),
2066
+ " ",
2067
+ s.mode.padEnd(9),
2068
+ " ",
2069
+ String(s.wordCount).padStart(3),
2070
+ "w ",
2071
+ s.errors,
2072
+ "err ",
2073
+ computeWPM(s),
2074
+ "wpm ",
2075
+ Math.round(accuracy(s) * 1e3) / 10,
1129
2076
  "%"
1130
2077
  ] })
1131
- ] }) }),
1132
- /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: "[n] next chapter [m] review mistakes [q] quit" }) })
1133
- ] });
2078
+ ] }, i))
2079
+ ] }),
2080
+ top.length > 0 && /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
2081
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "top mistakes" }),
2082
+ top.map(([word, entry]) => /* @__PURE__ */ jsxs6(Box6, { children: [
2083
+ /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.error, children: [
2084
+ " ",
2085
+ String(entry.count).padStart(3),
2086
+ " "
2087
+ ] }),
2088
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: word.padEnd(20) }),
2089
+ /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: entry.dictIds.join(", ") })
2090
+ ] }, word))
2091
+ ] }),
2092
+ /* @__PURE__ */ jsx10(Box6, { flexGrow: 1 }),
2093
+ /* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "n / N cycle window \xB7 q back" }) })
2094
+ ] });
2095
+ }
2096
+ function Stat({ label, value, accent = false }) {
2097
+ return /* @__PURE__ */ jsxs6(Box6, { marginRight: 3, children: [
2098
+ /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2099
+ label,
2100
+ " "
2101
+ ] }),
2102
+ /* @__PURE__ */ jsx10(Text6, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
2103
+ ] });
2104
+ }
2105
+
2106
+ // src/ui/screens/WordLookup.tsx
2107
+ import { useEffect as useEffect8, useState as useState10 } from "react";
2108
+ import { Box as Box7, Text as Text7, useInput as useInput7 } from "ink";
2109
+ import { readdir } from "fs/promises";
2110
+ import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
2111
+ async function listLocalDictIds() {
2112
+ try {
2113
+ const files = await readdir(paths.dictsDir);
2114
+ return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
2115
+ } catch {
2116
+ return [];
2117
+ }
2118
+ }
2119
+ function WordLookup() {
2120
+ const nav = useNav();
2121
+ const [query, setQuery] = useState10("");
2122
+ const [allWords, setAllWords] = useState10([]);
2123
+ const [book, setBook] = useState10({});
2124
+ const [loading, setLoading] = useState10(true);
2125
+ const [selected, setSelected] = useState10(0);
2126
+ useEffect8(() => {
2127
+ void (async () => {
2128
+ const ids = await listLocalDictIds();
2129
+ const collected = [];
2130
+ for (const id of ids) {
2131
+ const words = await loadLocalDictionary(id);
2132
+ if (!words) continue;
2133
+ for (const w of words) collected.push({ dictId: id, word: w });
2134
+ }
2135
+ setAllWords(collected);
2136
+ setBook(await loadMistakes());
2137
+ setLoading(false);
2138
+ })();
2139
+ }, []);
2140
+ const q = query.toLowerCase().trim();
2141
+ const filtered = q ? allWords.filter((h) => h.word.name.toLowerCase().includes(q)).slice(0, 50) : [];
2142
+ useInput7((input, key) => {
2143
+ if (key.escape) {
2144
+ nav.back();
2145
+ return;
2146
+ }
2147
+ if (key.upArrow) {
2148
+ setSelected((i) => Math.max(0, i - 1));
2149
+ return;
2150
+ }
2151
+ if (key.downArrow) {
2152
+ setSelected((i) => Math.min(filtered.length - 1, i + 1));
2153
+ return;
2154
+ }
2155
+ if (key.backspace || key.delete) {
2156
+ setQuery((s) => s.slice(0, -1));
2157
+ setSelected(0);
2158
+ return;
2159
+ }
2160
+ if (input && !key.ctrl && !key.meta && input.trim().length > 0) {
2161
+ setQuery((s) => s + input);
2162
+ setSelected(0);
2163
+ }
2164
+ });
2165
+ if (loading) {
2166
+ return /* @__PURE__ */ jsx11(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "indexing local dictionaries\u2026" }) });
1134
2167
  }
1135
- if (paused) {
1136
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 1, borderStyle: "round", borderColor: "yellow", children: [
1137
- /* @__PURE__ */ jsx4(Text5, { bold: true, color: "yellow", children: "Paused" }),
1138
- /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: "[r] resume [q] quit" }) })
2168
+ if (allWords.length === 0) {
2169
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2170
+ /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "No local dictionaries." }),
2171
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "Pull one in Dictionaries first." }) }),
2172
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 2, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "[Esc] back" }) })
1139
2173
  ] });
1140
2174
  }
1141
- const currentWord = session.current ? props.playlist[session.current.wordIndex] : props.playlist[props.playlist.length - 1];
1142
- const inputState = session.current?.input ?? initialState("");
1143
- const elapsedMs = Date.now() - session.startedAt;
1144
- const completed = session.results.length;
1145
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 1, paddingY: 0, children: [
1146
- /* @__PURE__ */ jsxs3(Box4, { children: [
1147
- /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: props.dictId }),
1148
- /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
1149
- " \xB7 chapter ",
1150
- props.chapterNumber,
1151
- "/",
1152
- props.totalChapters
1153
- ] }),
1154
- /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
1155
- " \xB7 mode ",
1156
- props.mode
1157
- ] }),
1158
- /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
1159
- " \xB7 accent ",
1160
- props.accent
2175
+ const current = filtered[selected];
2176
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2177
+ /* @__PURE__ */ jsxs7(Box7, { children: [
2178
+ /* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.accent, children: "Word lookup" }),
2179
+ /* @__PURE__ */ jsx11(Box7, { flexGrow: 1 }),
2180
+ /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2181
+ allWords.length,
2182
+ " words across local dicts"
1161
2183
  ] })
1162
2184
  ] }),
1163
- /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(WordInput, { state: inputState, hideTarget: props.mode === "dictation", flashError: lastEffect === "wrong" }) }),
1164
- currentWord && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Phonetic, { word: currentWord, accent: props.accent }) }),
1165
- currentWord && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Translation, { word: currentWord }) }),
1166
- /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Progress, { current: completed, total: props.playlist.length, errors: inputState.errorsThisWord, elapsedMs }) }),
1167
- /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: "[Esc] pause [Tab] replay pronunciation [Ctrl+C] quit" }) }),
1168
- audioWarn && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text5, { color: "yellow", children: audioWarn }) })
2185
+ /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
2186
+ /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "> " }),
2187
+ /* @__PURE__ */ jsx11(Text7, { color: PALETTE.text, children: query }),
2188
+ /* @__PURE__ */ jsx11(Text7, { color: PALETTE.accent, children: "_" })
2189
+ ] }),
2190
+ /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexGrow: 1, children: [
2191
+ /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", width: "40%", children: [
2192
+ filtered.map((h, i) => {
2193
+ const active = i === selected;
2194
+ return /* @__PURE__ */ jsxs7(Box7, { children: [
2195
+ /* @__PURE__ */ jsx11(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2196
+ /* @__PURE__ */ jsx11(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: h.word.name.padEnd(20) }),
2197
+ /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: h.dictId })
2198
+ ] }, `${h.dictId}-${h.word.name}-${i}`);
2199
+ }),
2200
+ filtered.length === 0 && q && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2201
+ 'no matches for "',
2202
+ query,
2203
+ '"'
2204
+ ] })
2205
+ ] }),
2206
+ /* @__PURE__ */ jsx11(Box7, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs7(Fragment3, { children: [
2207
+ /* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.text, children: current.word.name }),
2208
+ /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
2209
+ current.word.usphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2210
+ "US /",
2211
+ current.word.usphone,
2212
+ "/ "
2213
+ ] }),
2214
+ current.word.ukphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2215
+ "UK /",
2216
+ current.word.ukphone,
2217
+ "/"
2218
+ ] })
2219
+ ] }),
2220
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 1, flexDirection: "column", children: (current.word.trans ?? []).map((t, i) => /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.primary, children: [
2221
+ "\xB7 ",
2222
+ t
2223
+ ] }, i)) }),
2224
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2225
+ "in: ",
2226
+ current.dictId
2227
+ ] }) }),
2228
+ book[current.word.name] && /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
2229
+ /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.error, children: [
2230
+ "mistakes: ",
2231
+ book[current.word.name].count
2232
+ ] }),
2233
+ /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2234
+ " ",
2235
+ "(last ",
2236
+ book[current.word.name].lastSeen.slice(0, 10),
2237
+ ")"
2238
+ ] })
2239
+ ] })
2240
+ ] }) })
2241
+ ] }),
2242
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back" }) })
1169
2243
  ] });
1170
2244
  }
1171
2245
 
2246
+ // src/ui/App.tsx
2247
+ import { jsx as jsx12 } from "react/jsx-runtime";
2248
+ function App({ initial, initialCfg }) {
2249
+ return /* @__PURE__ */ jsx12(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx12(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx12(NavProvider, { initial, children: /* @__PURE__ */ jsx12(Fullscreen, { children: /* @__PURE__ */ jsx12(Router, {}) }) }) }) });
2250
+ }
2251
+ function Router() {
2252
+ const nav = useNav();
2253
+ const { cfg } = useAppState();
2254
+ const { exit } = useApp4();
2255
+ useInput8((input, key) => {
2256
+ if (key.ctrl && input === "c") exit();
2257
+ });
2258
+ const frame = nav.current;
2259
+ switch (frame.name) {
2260
+ case "main":
2261
+ return /* @__PURE__ */ jsx12(MainMenu, { cfg });
2262
+ case "practice":
2263
+ return /* @__PURE__ */ jsx12(PracticeScreen, { params: frame.params });
2264
+ case "dict":
2265
+ return /* @__PURE__ */ jsx12(DictBrowser, { params: frame.params });
2266
+ case "config":
2267
+ return /* @__PURE__ */ jsx12(ConfigEditor, {});
2268
+ case "stats":
2269
+ return /* @__PURE__ */ jsx12(StatsViewer, {});
2270
+ case "word":
2271
+ return /* @__PURE__ */ jsx12(WordLookup, {});
2272
+ }
2273
+ }
2274
+
1172
2275
  // src/commands/practice.ts
1173
2276
  var MODES = ["order", "dictation", "review", "random", "loop"];
1174
2277
  function isMode(v) {
1175
2278
  return MODES.includes(v);
1176
2279
  }
1177
- async function runChapter(opts) {
1178
- let outcome = "done";
1179
- const onSessionComplete = async (summary) => {
1180
- const rec = {
1181
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1182
- dictId: opts.dictId,
1183
- chapter: opts.chapterIndex,
1184
- mode: opts.mode,
1185
- wordCount: summary.wordCount,
1186
- errors: summary.errors,
1187
- durationMs: summary.durationMs,
1188
- perWordErrors: summary.perWordErrors
1189
- };
1190
- await appendSession(rec);
1191
- if (Object.keys(summary.perWordErrors).length > 0) {
1192
- let book = await loadMistakes();
1193
- for (const [word, n] of Object.entries(summary.perWordErrors)) {
1194
- book = bump(book, word, opts.dictId, n);
1195
- }
1196
- await saveMistakes(book);
1197
- }
1198
- };
1199
- const { waitUntilExit } = render(
1200
- createElement(PracticeScreen, {
1201
- dictId: opts.dictId,
1202
- chapterNumber: opts.chapterIndex + 1,
1203
- totalChapters: opts.totalChapters,
1204
- playlist: opts.playlist,
1205
- mode: opts.mode,
1206
- accent: opts.accent,
1207
- audio: opts.audio,
1208
- onSessionComplete,
1209
- onAdvanceChapter: () => {
1210
- outcome = "next";
1211
- },
1212
- onReviewMistakes: () => {
1213
- outcome = "review";
1214
- },
1215
- onQuit: () => {
1216
- outcome = "quit";
1217
- }
1218
- })
1219
- );
1220
- await waitUntilExit();
1221
- return outcome;
1222
- }
1223
2280
  async function runPractice(dictIdArg, options) {
1224
2281
  if (!process.stdout.isTTY) {
1225
2282
  console.error(chalk3.red("Practice requires an interactive TTY."));
@@ -1239,78 +2296,15 @@ async function runPractice(dictIdArg, options) {
1239
2296
  process.exitCode = 1;
1240
2297
  return;
1241
2298
  }
1242
- const words = await ensureDictionary(dictId);
1243
- const chapters = chunkChapters(words, cfg.chapterSize);
1244
- if (chapters.length === 0) {
1245
- console.error(chalk3.red(`Dictionary ${dictId} is empty.`));
1246
- process.exitCode = 1;
1247
- return;
1248
- }
1249
- let idx = Math.max(0, Math.min(chapters.length - 1, Number(options.chapter ?? 1) - 1));
1250
- const total = chapters.length;
1251
- const accent = cfg.accent;
1252
- const audioCfg = {
1253
- master: cfg.sounds.master,
1254
- keystroke: cfg.sounds.keystroke,
1255
- feedback: cfg.sounds.feedback,
1256
- autoplayPronunciation: cfg.autoplayPronunciation
1257
- };
1258
- if (mode === "review") {
1259
- const book = await loadMistakes();
1260
- const reviewWords = words.filter((w) => book[w.name]?.count);
1261
- if (reviewWords.length === 0) {
1262
- console.log(chalk3.yellow("Mistake book is empty for this dictionary. Practice some chapters first."));
1263
- return;
1264
- }
1265
- await runChapter({
1266
- dictId,
1267
- chapterIndex: 0,
1268
- totalChapters: 1,
1269
- playlist: reviewWords.slice(0, cfg.chapterSize),
1270
- mode,
1271
- accent,
1272
- audio: audioCfg
1273
- });
1274
- return;
1275
- }
1276
- while (idx < chapters.length) {
1277
- const chapter = chapters[idx];
1278
- const playlist = buildPlaylist(chapter, mode);
1279
- const result = await runChapter({
1280
- dictId,
1281
- chapterIndex: idx,
1282
- totalChapters: total,
1283
- playlist,
1284
- mode,
1285
- accent,
1286
- audio: audioCfg
1287
- });
1288
- if (result === "next") {
1289
- if (mode === "loop") continue;
1290
- idx++;
1291
- } else if (result === "review") {
1292
- const book = await loadMistakes();
1293
- const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
1294
- if (reviewWords.length === 0) {
1295
- console.log(chalk3.yellow("No mistakes to review."));
1296
- return;
1297
- }
1298
- await runChapter({
1299
- dictId,
1300
- chapterIndex: 0,
1301
- totalChapters: 1,
1302
- playlist: reviewWords,
1303
- mode: "order",
1304
- accent,
1305
- audio: audioCfg
1306
- });
1307
- return;
1308
- } else {
1309
- return;
1310
- }
1311
- }
1312
- console.log(chalk3.green(`Reached end of ${dictId} (${total} chapters). Nice work.`));
1313
- void chapterCount;
2299
+ const chapterIndex = Math.max(0, Number(options.chapter ?? 1) - 1);
2300
+ const { waitUntilExit } = render(
2301
+ createElement(App, {
2302
+ initial: { name: "practice", params: { dictId, chapterIndex, mode } },
2303
+ initialCfg: cfg
2304
+ }),
2305
+ { patchConsole: false, exitOnCtrlC: false }
2306
+ );
2307
+ await waitUntilExit();
1314
2308
  }
1315
2309
  function buildPracticeCommand() {
1316
2310
  return new Command3("practice").argument("[dictId]", "dictionary id; falls back to config.defaultDict").description("Start a typing practice session").option("-c, --chapter <n>", "chapter number (1-based)", "1").option("-m, --mode <mode>", "order | dictation | review | random | loop").action(async (dictIdArg, options) => {
@@ -1377,10 +2371,10 @@ Top ${top.length} mistakes`));
1377
2371
  // src/commands/word.ts
1378
2372
  import { Command as Command5 } from "commander";
1379
2373
  import chalk5 from "chalk";
1380
- import { readdir } from "fs/promises";
1381
- async function listLocalDictIds() {
2374
+ import { readdir as readdir2 } from "fs/promises";
2375
+ async function listLocalDictIds2() {
1382
2376
  try {
1383
- const files = await readdir(paths.dictsDir);
2377
+ const files = await readdir2(paths.dictsDir);
1384
2378
  return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
1385
2379
  } catch {
1386
2380
  return [];
@@ -1389,7 +2383,7 @@ async function listLocalDictIds() {
1389
2383
  function buildWordCommand() {
1390
2384
  return new Command5("word").argument("<keyword>").description("Look up a word across local dictionaries").option("--exact", "require exact (case-insensitive) match").action(async (keyword, opts) => {
1391
2385
  const q = keyword.toLowerCase();
1392
- const ids = await listLocalDictIds();
2386
+ const ids = await listLocalDictIds2();
1393
2387
  if (ids.length === 0) {
1394
2388
  console.log(chalk5.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
1395
2389
  return;
@@ -1442,107 +2436,22 @@ function buildWordCommand() {
1442
2436
  // src/commands/menu.ts
1443
2437
  import { render as render2 } from "ink";
1444
2438
  import { createElement as createElement2 } from "react";
1445
- import chalk6 from "chalk";
1446
-
1447
- // src/ui/screens/MainMenu.tsx
1448
- import { useState as useState3 } from "react";
1449
- import { Box as Box5, Text as Text6, useApp as useApp3, useInput as useInput3 } from "ink";
1450
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1451
- var ITEMS = [
1452
- { key: "p", label: "Practice", hint: "qwerty practice <dictId>" },
1453
- { key: "d", label: "Dictionaries", hint: "qwerty dict list / search / pull" },
1454
- { key: "w", label: "Word lookup", hint: "qwerty word <keyword>" },
1455
- { key: "s", label: "Stats", hint: "qwerty stats" },
1456
- { key: "c", label: "Config", hint: "qwerty config list" },
1457
- { key: "q", label: "Quit", hint: "Ctrl+C also exits" }
1458
- ];
1459
- function MainMenu({
1460
- defaultDict,
1461
- onAction
1462
- }) {
1463
- const [selected, setSelected] = useState3(0);
1464
- const { exit } = useApp3();
1465
- useInput3((input, key) => {
1466
- if (key.upArrow) setSelected((i) => (i - 1 + ITEMS.length) % ITEMS.length);
1467
- if (key.downArrow) setSelected((i) => (i + 1) % ITEMS.length);
1468
- if (key.return) {
1469
- const item = ITEMS[selected];
1470
- if (item.key === "q") {
1471
- exit();
1472
- return;
1473
- }
1474
- if (item.key === "p") onAction("practice");
1475
- }
1476
- for (const it of ITEMS) {
1477
- if (input === it.key) {
1478
- if (it.key === "q") {
1479
- exit();
1480
- return;
1481
- }
1482
- if (it.key === "p") onAction("practice");
1483
- }
1484
- }
1485
- });
1486
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
1487
- /* @__PURE__ */ jsx5(Text6, { bold: true, color: "cyan", children: "qwerty-cli" }),
1488
- /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: "typing practice for English vocabulary, in your terminal" }),
1489
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, flexDirection: "column", children: ITEMS.map((it, i) => /* @__PURE__ */ jsxs4(Text6, { children: [
1490
- i === selected ? chalkArrow() : " ",
1491
- /* @__PURE__ */ jsxs4(Text6, { bold: i === selected, color: i === selected ? "cyan" : void 0, children: [
1492
- "[",
1493
- it.key,
1494
- "]"
1495
- ] }),
1496
- " ",
1497
- /* @__PURE__ */ jsx5(Text6, { bold: i === selected, children: it.label.padEnd(14) }),
1498
- /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: it.hint })
1499
- ] }, it.key)) }),
1500
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
1501
- "default dict: ",
1502
- defaultDict ?? "none \u2014 set with `qwerty config set defaultDict <id>`"
1503
- ] }) }),
1504
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: "\u2191/\u2193 navigate \xB7 Enter to select \xB7 letters jump" }) })
1505
- ] });
1506
- }
1507
- function chalkArrow() {
1508
- return "> ";
1509
- }
1510
-
1511
- // src/commands/menu.ts
1512
2439
  async function runMainMenu() {
1513
2440
  if (!process.stdout.isTTY) {
1514
2441
  console.log("qwerty-cli \u2014 run `qwerty --help` for available commands.");
1515
2442
  return;
1516
2443
  }
1517
2444
  const cfg = await loadConfig();
1518
- let chosen = null;
1519
- const { waitUntilExit, unmount } = render2(
1520
- createElement2(MainMenu, {
1521
- defaultDict: cfg.defaultDict,
1522
- onAction: (action) => {
1523
- chosen = action;
1524
- unmount();
1525
- }
1526
- })
2445
+ const { waitUntilExit } = render2(
2446
+ createElement2(App, { initial: { name: "main" }, initialCfg: cfg }),
2447
+ { patchConsole: false, exitOnCtrlC: false }
1527
2448
  );
1528
2449
  await waitUntilExit();
1529
- if (chosen === "practice") {
1530
- if (cfg.defaultDict) {
1531
- console.log(chalk6.dim(`Tip: \`qwerty practice ${cfg.defaultDict}\` skips this menu next time.`));
1532
- await runPractice(cfg.defaultDict, {});
1533
- } else {
1534
- console.log(
1535
- chalk6.yellow(
1536
- "No default dictionary set. Run `qwerty dict pull <id>` then `qwerty config set defaultDict <id>`."
1537
- )
1538
- );
1539
- }
1540
- }
1541
2450
  }
1542
2451
 
1543
2452
  // src/cli.ts
1544
2453
  var program = new Command6();
1545
- program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version("0.1.0");
2454
+ program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version(package_default.version);
1546
2455
  program.addCommand(buildPracticeCommand());
1547
2456
  program.addCommand(buildDictCommand());
1548
2457
  program.addCommand(buildWordCommand());