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

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