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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,6 +1,66 @@
1
1
  // src/cli.ts
2
2
  import { Command as Command6 } from "commander";
3
3
 
4
+ // package.json
5
+ var package_default = {
6
+ name: "qwerty-cli",
7
+ version: "0.0.1-alpha.6",
8
+ description: "Terminal clone of qwerty-learner: typing practice for English vocabulary, with chapters, dictation, mistake book, and audio.",
9
+ type: "module",
10
+ bin: {
11
+ qwerty: "bin/qwerty.mjs"
12
+ },
13
+ files: [
14
+ "dist",
15
+ "bin",
16
+ "assets"
17
+ ],
18
+ scripts: {
19
+ build: "tsup",
20
+ dev: "tsup --watch",
21
+ typecheck: "tsc --noEmit",
22
+ test: "vitest run",
23
+ "test:watch": "vitest",
24
+ "sync-registry": "tsx scripts/sync-registry.ts",
25
+ prepublishOnly: "pnpm build"
26
+ },
27
+ engines: {
28
+ node: ">=20"
29
+ },
30
+ keywords: [
31
+ "qwerty",
32
+ "typing",
33
+ "cli",
34
+ "vocabulary",
35
+ "ink",
36
+ "terminal"
37
+ ],
38
+ license: "MIT",
39
+ dependencies: {
40
+ chalk: "^5.3.0",
41
+ commander: "^12.1.0",
42
+ ink: "^5.0.1",
43
+ "p-queue": "^8.0.1",
44
+ react: "^18.3.1",
45
+ undici: "^6.19.8",
46
+ zod: "^3.23.8"
47
+ },
48
+ devDependencies: {
49
+ "@types/node": "^20.14.10",
50
+ "@types/react": "^18.3.3",
51
+ "ts-morph": "^23.0.0",
52
+ tsup: "^8.2.4",
53
+ tsx: "^4.19.0",
54
+ typescript: "^5.5.4",
55
+ vitest: "^2.0.5"
56
+ },
57
+ pnpm: {
58
+ onlyBuiltDependencies: [
59
+ "esbuild"
60
+ ]
61
+ }
62
+ };
63
+
4
64
  // src/commands/config.ts
5
65
  import { Command } from "commander";
6
66
  import chalk from "chalk";
@@ -89,7 +149,8 @@ var ConfigSchema = z.object({
89
149
  }).default({ master: true, keystroke: true, feedback: true, keySoundName: "default" }),
90
150
  autoplayPronunciation: z.boolean().default(true),
91
151
  defaultMode: z.enum(["order", "dictation", "review", "random", "loop"]).default("order"),
92
- defaultDict: z.string().optional()
152
+ defaultDict: z.string().optional(),
153
+ language: z.enum(["auto", "zh", "en"]).default("auto")
93
154
  });
94
155
  var DEFAULTS = ConfigSchema.parse({});
95
156
  async function loadConfig() {
@@ -445,11 +506,12 @@ ${matches.length} matches`));
445
506
 
446
507
  // src/commands/practice.ts
447
508
  import { Command as Command3 } from "commander";
448
- import chalk3 from "chalk";
509
+ import chalk4 from "chalk";
449
510
  import { render } from "ink";
450
511
  import { createElement } from "react";
451
512
 
452
513
  // src/ui/App.tsx
514
+ import { useRef as useRef4 } from "react";
453
515
  import { useApp as useApp4, useInput as useInput8 } from "ink";
454
516
 
455
517
  // src/ui/nav.tsx
@@ -480,17 +542,27 @@ function useNav() {
480
542
  }
481
543
 
482
544
  // 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";
545
+ import { useEffect, useState as useState2 } from "react";
546
+ import { Box, useStdout } from "ink";
547
+ import { jsx as jsx2 } from "react/jsx-runtime";
548
+ var ENTER = "\x1B[?1049h\x1B[?25l\x1B[2J\x1B[H";
486
549
  var LEAVE = "\x1B[?25h\x1B[?1049l";
487
550
  function shouldUse() {
488
551
  return Boolean(process.stdout.isTTY) && process.env.QWERTY_NO_ALTSCREEN !== "1";
489
552
  }
490
553
  function Fullscreen({ children }) {
554
+ const { stdout } = useStdout();
555
+ const [size, setSize] = useState2(() => ({
556
+ rows: stdout?.rows ?? 24,
557
+ cols: stdout?.columns ?? 80
558
+ }));
491
559
  useEffect(() => {
492
560
  if (!shouldUse()) return;
493
561
  process.stdout.write(ENTER);
562
+ const onResize = () => {
563
+ setSize({ rows: process.stdout.rows ?? 24, cols: process.stdout.columns ?? 80 });
564
+ };
565
+ process.stdout.on("resize", onResize);
494
566
  const leave = () => {
495
567
  try {
496
568
  process.stdout.write(LEAVE);
@@ -508,14 +580,15 @@ function Fullscreen({ children }) {
508
580
  process.off("SIGINT", onSignal);
509
581
  process.off("SIGTERM", onSignal);
510
582
  process.off("exit", leave);
583
+ process.stdout.off("resize", onResize);
511
584
  leave();
512
585
  };
513
586
  }, []);
514
- return /* @__PURE__ */ jsx2(Fragment, { children });
587
+ return /* @__PURE__ */ jsx2(Box, { width: size.cols, height: size.rows, flexDirection: "column", children });
515
588
  }
516
589
 
517
590
  // src/ui/audio-context.tsx
518
- import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useState as useState2 } from "react";
591
+ import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useState as useState3 } from "react";
519
592
 
520
593
  // src/infra/audio.ts
521
594
  import { spawn } from "child_process";
@@ -677,7 +750,7 @@ function AudioStatusProvider({
677
750
  disabled,
678
751
  children
679
752
  }) {
680
- const [status, setStatus] = useState2({ warning: null, ready: false });
753
+ const [status, setStatus] = useState3({ warning: null, ready: false });
681
754
  useEffect2(() => {
682
755
  let cancelled = false;
683
756
  initAudio(disabled).then(() => {
@@ -698,14 +771,14 @@ function useAudioStatus() {
698
771
  }
699
772
 
700
773
  // src/ui/app-state.tsx
701
- import { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useState as useState3 } from "react";
774
+ import { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useState as useState4 } from "react";
702
775
  import { jsx as jsx4 } from "react/jsx-runtime";
703
776
  var AppStateContext = createContext3(null);
704
777
  function AppStateProvider({
705
778
  initialCfg,
706
779
  children
707
780
  }) {
708
- const [cfg, setCfgState] = useState3(initialCfg);
781
+ const [cfg, setCfgState] = useState4(initialCfg);
709
782
  const setCfg = useCallback2(async (next) => {
710
783
  setCfgState(next);
711
784
  await saveConfig(next);
@@ -718,14 +791,367 @@ function useAppState() {
718
791
  return ctx;
719
792
  }
720
793
 
794
+ // src/i18n/context.tsx
795
+ import { createContext as createContext4, useContext as useContext4, useMemo } from "react";
796
+
797
+ // src/i18n/strings.ts
798
+ var en = {
799
+ app: {
800
+ title: "qwerty",
801
+ subtitle: "typing practice for the terminal"
802
+ },
803
+ common: {
804
+ back: "back",
805
+ quit: "quit",
806
+ on: "on",
807
+ off: "off"
808
+ },
809
+ mainMenu: {
810
+ items: {
811
+ practiceLabel: "Practice",
812
+ practiceHintWith: (id) => `start ${id}`,
813
+ practiceHintNone: "pick a dictionary",
814
+ dictLabel: "Dictionaries",
815
+ dictHint: "browse, pull, set default",
816
+ wordLabel: "Word lookup",
817
+ wordHint: "search local dicts",
818
+ statsLabel: "Stats",
819
+ statsHint: "history & trends",
820
+ configLabel: "Config",
821
+ configHint: "edit preferences",
822
+ quitLabel: "Quit",
823
+ quitHint: "Ctrl+C also exits"
824
+ },
825
+ defaultDict: (id) => `default dict: ${id}`,
826
+ noDefault: "default dict: (none \u2014 pick one in Dictionaries)",
827
+ hint: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump"
828
+ },
829
+ dict: {
830
+ title: "Dictionaries",
831
+ loading: "loading dictionaries\u2026",
832
+ entries: (n) => `${n} entries`,
833
+ filterPrompt: (q) => `/ ${q}`,
834
+ local: "local \u2713",
835
+ notLocal: "not local",
836
+ defaultMark: "default \u2605",
837
+ tagsLabel: (tags) => `tags: ${tags}`,
838
+ wordsLabel: (n) => `${n} words`,
839
+ pulling: (id) => `pulling ${id}\u2026`,
840
+ removing: (id) => `removing ${id}\u2026`,
841
+ errorOn: (id, msg) => `error on ${id}: ${msg}`,
842
+ footer: "\u2191/\u2193 select \xB7 Enter set default \xB7 p practice \xB7 u pull \xB7 r remove \xB7 / filter \xB7 Esc back"
843
+ },
844
+ config: {
845
+ title: "Config",
846
+ fields: {
847
+ defaultDict: "default dict",
848
+ defaultMode: "default mode",
849
+ accent: "accent",
850
+ mirror: "mirror",
851
+ chapterSize: "chapter size",
852
+ autoplayPronunciation: "autoplay pronunciation",
853
+ soundsMaster: "sounds master",
854
+ soundsKeystroke: "sounds keystroke",
855
+ soundsFeedback: "sounds feedback",
856
+ soundsKeySound: "sounds key sound",
857
+ language: "language"
858
+ },
859
+ hints: {
860
+ editing: "type to edit \xB7 Enter save \xB7 Esc cancel",
861
+ bool: "space toggle \xB7 \u2191/\u2193 move \xB7 Esc back",
862
+ enum: "\u2190/\u2192 cycle \xB7 \u2191/\u2193 move \xB7 Esc back",
863
+ dictRef: "Enter pick dict \xB7 \u2191/\u2193 move \xB7 Esc back",
864
+ stringOrInt: "Enter edit \xB7 \u2191/\u2193 move \xB7 Esc back"
865
+ }
866
+ },
867
+ stats: {
868
+ title: "Stats",
869
+ loading: "loading stats\u2026",
870
+ none: "No practice history yet.",
871
+ nonePractice: "Run a practice session first.",
872
+ lifetime: "lifetime",
873
+ sessions: "sessions",
874
+ words: "words",
875
+ errors: "errors",
876
+ wpm: "wpm",
877
+ accuracy: "accuracy",
878
+ streak: "streak",
879
+ last: (n) => `last ${n} days (n / N to cycle window)`,
880
+ cycleWindow: "n / N cycle window \xB7 q back",
881
+ recent: "recent sessions",
882
+ topMistakes: "top mistakes",
883
+ footer: "n / N cycle window \xB7 q back"
884
+ },
885
+ word: {
886
+ title: "Word lookup",
887
+ indexing: "indexing local dictionaries\u2026",
888
+ none: "No local dictionaries.",
889
+ pullFirst: "Pull one in Dictionaries first.",
890
+ countAcross: (n) => `${n} words across local dicts`,
891
+ noMatches: (q) => `no matches for "${q}"`,
892
+ inDict: (id) => `in: ${id}`,
893
+ mistakes: (n, date) => `mistakes: ${n} (last ${date})`,
894
+ footer: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back"
895
+ },
896
+ practice: {
897
+ loading: "loading\u2026",
898
+ paused: "PAUSED",
899
+ chapterComplete: "CHAPTER COMPLETE",
900
+ chapterLabel: (c, t) => `chapter ${c}/${t}`,
901
+ reviewLabel: "review",
902
+ statusBar: {
903
+ mode: "mode",
904
+ accent: "accent"
905
+ },
906
+ modes: {
907
+ order: "order",
908
+ dictation: "dictation",
909
+ review: "review",
910
+ random: "random",
911
+ loop: "loop"
912
+ },
913
+ accents: {
914
+ us: "us",
915
+ uk: "uk"
916
+ },
917
+ statCards: {
918
+ words: "words",
919
+ errors: "errors",
920
+ wpm: "wpm",
921
+ accuracy: "accuracy",
922
+ elapsed: (t) => `elapsed ${t}`
923
+ },
924
+ footers: {
925
+ typing: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay \xB7 Ctrl+C quit",
926
+ paused: "[r] resume \xB7 [q] quit to menu",
927
+ summary: "[n] next chapter \xB7 [m] review mistakes \xB7 [q] back to menu"
928
+ },
929
+ errors: {
930
+ noMistakes: "No mistakes to review yet. Practice some chapters first.",
931
+ dictEmpty: (id) => `Dictionary ${id} is empty.`,
932
+ unknown: "Unknown error"
933
+ }
934
+ },
935
+ audio: {
936
+ noPlayer: "! No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled."
937
+ },
938
+ report: {
939
+ title: "Session summary",
940
+ duration: "duration",
941
+ practiced: "practiced",
942
+ chapters: "chapters",
943
+ words: "words",
944
+ accuracy: "accuracy",
945
+ wpm: "wpm",
946
+ newMistakes: "new mistakes",
947
+ farewell: "see you next time."
948
+ }
949
+ };
950
+ var zh = {
951
+ app: {
952
+ title: "qwerty",
953
+ subtitle: "\u7EC8\u7AEF\u952E\u76D8\u7EC3\u4E60"
954
+ },
955
+ common: {
956
+ back: "\u8FD4\u56DE",
957
+ quit: "\u9000\u51FA",
958
+ on: "\u5F00",
959
+ off: "\u5173"
960
+ },
961
+ mainMenu: {
962
+ items: {
963
+ practiceLabel: "\u7EC3\u4E60",
964
+ practiceHintWith: (id) => `\u5F00\u59CB ${id}`,
965
+ practiceHintNone: "\u8BF7\u5148\u9009\u8BCD\u5178",
966
+ dictLabel: "\u8BCD\u5178",
967
+ dictHint: "\u6D4F\u89C8\u3001\u4E0B\u8F7D\u3001\u8BBE\u4E3A\u9ED8\u8BA4",
968
+ wordLabel: "\u67E5\u8BCD",
969
+ wordHint: "\u5728\u672C\u5730\u8BCD\u5178\u4E2D\u641C\u7D22",
970
+ statsLabel: "\u7EDF\u8BA1",
971
+ statsHint: "\u5386\u53F2\u4E0E\u8D8B\u52BF",
972
+ configLabel: "\u8BBE\u7F6E",
973
+ configHint: "\u4FEE\u6539\u504F\u597D",
974
+ quitLabel: "\u9000\u51FA",
975
+ quitHint: "Ctrl+C \u4EA6\u53EF\u9000\u51FA"
976
+ },
977
+ defaultDict: (id) => `\u9ED8\u8BA4\u8BCD\u5178:${id}`,
978
+ noDefault: "\u9ED8\u8BA4\u8BCD\u5178:(\u672A\u8BBE\u7F6E \u2014 \u5728\u300C\u8BCD\u5178\u300D\u4E2D\u9009\u62E9)",
979
+ hint: "\u2191/\u2193 \u79FB\u52A8 \xB7 Enter \u786E\u8BA4 \xB7 \u5B57\u6BCD\u76F4\u8FBE"
980
+ },
981
+ dict: {
982
+ title: "\u8BCD\u5178",
983
+ loading: "\u52A0\u8F7D\u8BCD\u5178\u4E2D\u2026",
984
+ entries: (n) => `${n} \u90E8\u8BCD\u5178`,
985
+ filterPrompt: (q) => `/ ${q}`,
986
+ local: "\u5DF2\u4E0B\u8F7D \u2713",
987
+ notLocal: "\u672A\u4E0B\u8F7D",
988
+ defaultMark: "\u9ED8\u8BA4 \u2605",
989
+ tagsLabel: (tags) => `\u6807\u7B7E:${tags}`,
990
+ wordsLabel: (n) => `${n} \u8BCD`,
991
+ pulling: (id) => `\u62C9\u53D6 ${id} \u4E2D\u2026`,
992
+ removing: (id) => `\u5220\u9664 ${id} \u4E2D\u2026`,
993
+ errorOn: (id, msg) => `${id} \u51FA\u9519:${msg}`,
994
+ footer: "\u2191/\u2193 \u9009\u62E9 \xB7 Enter \u8BBE\u4E3A\u9ED8\u8BA4 \xB7 p \u5F00\u59CB\u7EC3\u4E60 \xB7 u \u62C9\u53D6 \xB7 r \u5220\u9664 \xB7 / \u8FC7\u6EE4 \xB7 Esc \u8FD4\u56DE"
995
+ },
996
+ config: {
997
+ title: "\u8BBE\u7F6E",
998
+ fields: {
999
+ defaultDict: "\u9ED8\u8BA4\u8BCD\u5178",
1000
+ defaultMode: "\u9ED8\u8BA4\u6A21\u5F0F",
1001
+ accent: "\u53D1\u97F3",
1002
+ mirror: "\u955C\u50CF\u6E90",
1003
+ chapterSize: "\u7AE0\u8282\u5355\u8BCD\u6570",
1004
+ autoplayPronunciation: "\u81EA\u52A8\u64AD\u653E\u53D1\u97F3",
1005
+ soundsMaster: "\u97F3\u6548\u603B\u5F00\u5173",
1006
+ soundsKeystroke: "\u6309\u952E\u97F3",
1007
+ soundsFeedback: "\u53CD\u9988\u97F3",
1008
+ soundsKeySound: "\u6309\u952E\u97F3\u8272",
1009
+ language: "\u8BED\u8A00"
1010
+ },
1011
+ hints: {
1012
+ editing: "\u8F93\u5165\u4FEE\u6539 \xB7 Enter \u4FDD\u5B58 \xB7 Esc \u53D6\u6D88",
1013
+ bool: "\u7A7A\u683C\u5207\u6362 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1014
+ enum: "\u2190/\u2192 \u5207\u6362 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1015
+ dictRef: "Enter \u9009\u8BCD\u5178 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1016
+ stringOrInt: "Enter \u7F16\u8F91 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE"
1017
+ }
1018
+ },
1019
+ stats: {
1020
+ title: "\u7EDF\u8BA1",
1021
+ loading: "\u52A0\u8F7D\u7EDF\u8BA1\u4E2D\u2026",
1022
+ none: "\u8FD8\u6CA1\u6709\u7EC3\u4E60\u8BB0\u5F55\u3002",
1023
+ nonePractice: "\u5148\u6765\u4E00\u6B21\u7EC3\u4E60\u5427\u3002",
1024
+ lifetime: "\u7D2F\u8BA1",
1025
+ sessions: "\u4F1A\u8BDD",
1026
+ words: "\u8BCD\u6570",
1027
+ errors: "\u9519\u8BEF",
1028
+ wpm: "\u901F\u5EA6",
1029
+ accuracy: "\u51C6\u786E\u7387",
1030
+ streak: "\u8FDE\u7EED\u5929\u6570",
1031
+ last: (n) => `\u6700\u8FD1 ${n} \u5929 (n / N \u5207\u6362\u7A97\u53E3)`,
1032
+ cycleWindow: "n / N \u5207\u6362\u7A97\u53E3 \xB7 q \u8FD4\u56DE",
1033
+ recent: "\u6700\u8FD1\u4F1A\u8BDD",
1034
+ topMistakes: "\u9AD8\u9891\u9519\u8BCD",
1035
+ footer: "n / N \u5207\u6362\u7A97\u53E3 \xB7 q \u8FD4\u56DE"
1036
+ },
1037
+ word: {
1038
+ title: "\u67E5\u8BCD",
1039
+ indexing: "\u7D22\u5F15\u672C\u5730\u8BCD\u5178\u4E2D\u2026",
1040
+ none: "\u6CA1\u6709\u672C\u5730\u8BCD\u5178\u3002",
1041
+ pullFirst: "\u5148\u5728\u300C\u8BCD\u5178\u300D\u4E2D\u62C9\u53D6\u4E00\u90E8\u3002",
1042
+ countAcross: (n) => `\u672C\u5730\u8BCD\u5178\u5171 ${n} \u8BCD`,
1043
+ noMatches: (q) => `\u6CA1\u6709\u5339\u914D\u300C${q}\u300D\u7684\u8BCD`,
1044
+ inDict: (id) => `\u6765\u6E90:${id}`,
1045
+ mistakes: (n, date) => `\u9519\u8FC7 ${n} \u6B21 (\u6700\u8FD1 ${date})`,
1046
+ footer: "\u8F93\u5165\u8FC7\u6EE4 \xB7 \u2191/\u2193 \u9009\u62E9 \xB7 Esc \u8FD4\u56DE"
1047
+ },
1048
+ practice: {
1049
+ loading: "\u52A0\u8F7D\u4E2D\u2026",
1050
+ paused: "\u5DF2\u6682\u505C",
1051
+ chapterComplete: "\u672C\u7AE0\u5B8C\u6210",
1052
+ chapterLabel: (c, t) => `\u7B2C ${c}/${t} \u7AE0`,
1053
+ reviewLabel: "\u590D\u4E60",
1054
+ statusBar: {
1055
+ mode: "\u6A21\u5F0F",
1056
+ accent: "\u53D1\u97F3"
1057
+ },
1058
+ modes: {
1059
+ order: "\u987A\u5E8F",
1060
+ dictation: "\u9ED8\u5199",
1061
+ review: "\u590D\u4E60",
1062
+ random: "\u4E71\u5E8F",
1063
+ loop: "\u5FAA\u73AF"
1064
+ },
1065
+ accents: {
1066
+ us: "\u7F8E",
1067
+ uk: "\u82F1"
1068
+ },
1069
+ statCards: {
1070
+ words: "\u8BCD\u6570",
1071
+ errors: "\u9519\u8BEF",
1072
+ wpm: "\u901F\u5EA6",
1073
+ accuracy: "\u51C6\u786E\u7387",
1074
+ elapsed: (t) => `\u8017\u65F6 ${t}`
1075
+ },
1076
+ footers: {
1077
+ typing: "Ctrl+N \u8DF3\u8FC7 \xB7 Esc \u6682\u505C \xB7 Tab \u91CD\u64AD \xB7 Ctrl+C \u9000\u51FA",
1078
+ paused: "[r] \u7EE7\u7EED \xB7 [q] \u8FD4\u56DE\u83DC\u5355",
1079
+ summary: "[n] \u4E0B\u4E00\u7AE0 \xB7 [m] \u590D\u4E60\u9519\u8BCD \xB7 [q] \u8FD4\u56DE\u83DC\u5355"
1080
+ },
1081
+ errors: {
1082
+ noMistakes: "\u9519\u8BCD\u672C\u662F\u7A7A\u7684\u3002\u5148\u7EC3\u4E60\u51E0\u7AE0\u5427\u3002",
1083
+ dictEmpty: (id) => `\u8BCD\u5178 ${id} \u662F\u7A7A\u7684\u3002`,
1084
+ unknown: "\u672A\u77E5\u9519\u8BEF"
1085
+ }
1086
+ },
1087
+ audio: {
1088
+ noPlayer: "! \u672A\u5728 PATH \u4E2D\u627E\u5230\u97F3\u9891\u64AD\u653E\u5668(\u5C1D\u8BD5 afplay/ffplay/mpg123/paplay/aplay/powershell)\u3002\u97F3\u6548\u5DF2\u7981\u7528\u3002"
1089
+ },
1090
+ report: {
1091
+ title: "\u672C\u6B21\u4F1A\u8BDD",
1092
+ duration: "\u603B\u65F6\u957F",
1093
+ practiced: "\u7EC3\u4E60\u7528\u65F6",
1094
+ chapters: "\u5B8C\u6210\u7AE0\u8282",
1095
+ words: "\u8BCD\u6570",
1096
+ accuracy: "\u51C6\u786E\u7387",
1097
+ wpm: "\u901F\u5EA6",
1098
+ newMistakes: "\u65B0\u9519\u8BCD",
1099
+ farewell: "\u4E0B\u6B21\u89C1\u3002"
1100
+ }
1101
+ };
1102
+
1103
+ // src/i18n/locale.ts
1104
+ function pickFromString(s) {
1105
+ if (!s) return null;
1106
+ const lower = s.toLowerCase();
1107
+ if (lower.startsWith("zh")) return "zh";
1108
+ if (lower.startsWith("en")) return "en";
1109
+ return null;
1110
+ }
1111
+ function detectLocale(pref) {
1112
+ if (pref === "zh" || pref === "en") return pref;
1113
+ const env = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || process.env.LANGUAGE;
1114
+ const fromEnv = pickFromString(env);
1115
+ if (fromEnv) return fromEnv;
1116
+ try {
1117
+ const intlLocale = Intl.DateTimeFormat().resolvedOptions().locale;
1118
+ const fromIntl = pickFromString(intlLocale);
1119
+ if (fromIntl) return fromIntl;
1120
+ } catch {
1121
+ }
1122
+ return "en";
1123
+ }
1124
+
1125
+ // src/i18n/context.tsx
1126
+ import { jsx as jsx5 } from "react/jsx-runtime";
1127
+ var StringsContext = createContext4(null);
1128
+ function StringsProvider({
1129
+ pref,
1130
+ children
1131
+ }) {
1132
+ const value = useMemo(() => {
1133
+ const lang = detectLocale(pref);
1134
+ return { lang, t: lang === "zh" ? zh : en };
1135
+ }, [pref]);
1136
+ return /* @__PURE__ */ jsx5(StringsContext.Provider, { value, children });
1137
+ }
1138
+ function useStrings() {
1139
+ const ctx = useContext4(StringsContext);
1140
+ if (!ctx) throw new Error("useStrings must be used inside StringsProvider");
1141
+ return ctx.t;
1142
+ }
1143
+ function pickStrings(pref) {
1144
+ const lang = detectLocale(pref);
1145
+ return { lang, t: lang === "zh" ? zh : en };
1146
+ }
1147
+
721
1148
  // src/ui/screens/MainMenu.tsx
722
- import { useState as useState4 } from "react";
723
- import { Box as Box2, Text as Text2, useApp, useInput } from "ink";
1149
+ import { useState as useState5 } from "react";
1150
+ import { Box as Box3, Text as Text2, useApp, useInput } from "ink";
724
1151
 
725
1152
  // 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";
1153
+ import { Box as Box2, Text, useStdout as useStdout2 } from "ink";
1154
+ import { jsx as jsx6, jsxs } from "react/jsx-runtime";
729
1155
  var PALETTE = {
730
1156
  accent: "#5eead4",
731
1157
  muted: "#6b7280",
@@ -736,42 +1162,58 @@ var PALETTE = {
736
1162
  error: "#f87171"
737
1163
  };
738
1164
  function BigWord({ target, typed, error = false, hideTarget = false }) {
739
- const { stdout } = useStdout();
1165
+ const { stdout } = useStdout2();
740
1166
  const cols = stdout?.columns ?? 80;
741
1167
  const chars = [...target];
742
1168
  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) => {
1169
+ const sep = cols < 60 ? " " : " ";
1170
+ return /* @__PURE__ */ jsx6(Box2, { justifyContent: "center", children: chars.map((ch, i) => {
756
1171
  const isTyped = i < typedChars.length;
757
1172
  const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
758
1173
  const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
759
- return /* @__PURE__ */ jsx5(BigText, { text: display, font: "tiny", colors: [color], space: false }, i);
1174
+ return /* @__PURE__ */ jsxs(Text, { bold: true, color, underline: !isTyped && error, children: [
1175
+ display,
1176
+ i < chars.length - 1 ? sep : ""
1177
+ ] }, i);
760
1178
  }) });
761
1179
  }
1180
+ function BoldSpaced({ text, color = PALETTE.text }) {
1181
+ const chars = [...text];
1182
+ return /* @__PURE__ */ jsx6(Box2, { children: chars.map((ch, i) => /* @__PURE__ */ jsxs(Text, { bold: true, color, children: [
1183
+ ch,
1184
+ i < chars.length - 1 ? " " : ""
1185
+ ] }, i)) });
1186
+ }
1187
+
1188
+ // src/util/text.ts
1189
+ var ANSI_RE = /\x1b\[[0-9;]*m/g;
1190
+ function stripAnsi(s) {
1191
+ return s.replace(ANSI_RE, "");
1192
+ }
1193
+ function visibleWidth2(s) {
1194
+ const plain = stripAnsi(s);
1195
+ let w = 0;
1196
+ for (const ch of plain) {
1197
+ const code = ch.codePointAt(0);
1198
+ w += code > 11904 && code < 64256 ? 2 : 1;
1199
+ }
1200
+ return w;
1201
+ }
762
1202
 
763
1203
  // src/ui/screens/MainMenu.tsx
764
- import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
1204
+ import { jsx as jsx7, jsxs as jsxs2 } from "react/jsx-runtime";
765
1205
  function MainMenu({ cfg }) {
766
- const [selected, setSelected] = useState4(0);
1206
+ const [selected, setSelected] = useState5(0);
767
1207
  const { exit } = useApp();
768
1208
  const nav = useNav();
769
1209
  const audio = useAudioStatus();
1210
+ const t = useStrings();
1211
+ const m = t.mainMenu.items;
770
1212
  const items = [
771
1213
  {
772
1214
  key: "p",
773
- label: "Practice",
774
- hint: cfg.defaultDict ? `start ${cfg.defaultDict}` : "pick a dictionary",
1215
+ label: m.practiceLabel,
1216
+ hint: cfg.defaultDict ? m.practiceHintWith(cfg.defaultDict) : m.practiceHintNone,
775
1217
  run: () => {
776
1218
  if (cfg.defaultDict) {
777
1219
  nav.navigate({
@@ -783,12 +1225,13 @@ function MainMenu({ cfg }) {
783
1225
  }
784
1226
  }
785
1227
  },
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() }
1228
+ { key: "d", label: m.dictLabel, hint: m.dictHint, run: () => nav.navigate({ name: "dict" }) },
1229
+ { key: "w", label: m.wordLabel, hint: m.wordHint, run: () => nav.navigate({ name: "word" }) },
1230
+ { key: "s", label: m.statsLabel, hint: m.statsHint, run: () => nav.navigate({ name: "stats" }) },
1231
+ { key: "c", label: m.configLabel, hint: m.configHint, run: () => nav.navigate({ name: "config" }) },
1232
+ { key: "q", label: m.quitLabel, hint: m.quitHint, run: () => exit() }
791
1233
  ];
1234
+ const labelW = Math.max(...items.map((it) => visibleWidth2(it.label))) + 4;
792
1235
  useInput((input, key) => {
793
1236
  if (key.upArrow) setSelected((i) => (i - 1 + items.length) % items.length);
794
1237
  if (key.downArrow) setSelected((i) => (i + 1) % items.length);
@@ -803,41 +1246,41 @@ function MainMenu({ cfg }) {
803
1246
  }
804
1247
  }
805
1248
  });
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" })
1249
+ return /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
1250
+ /* @__PURE__ */ jsxs2(Box3, { children: [
1251
+ /* @__PURE__ */ jsx7(Text2, { bold: true, color: PALETTE.accent, children: t.app.title }),
1252
+ /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
1253
+ " \xB7 ",
1254
+ t.app.subtitle
1255
+ ] })
810
1256
  ] }),
811
- /* @__PURE__ */ jsx6(Box2, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
1257
+ /* @__PURE__ */ jsx7(Box3, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
812
1258
  const active = i === selected;
813
- return /* @__PURE__ */ jsxs2(Box2, { children: [
814
- /* @__PURE__ */ jsx6(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1259
+ const pad = " ".repeat(Math.max(0, labelW - visibleWidth2(it.label)));
1260
+ return /* @__PURE__ */ jsxs2(Box3, { children: [
1261
+ /* @__PURE__ */ jsx7(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
815
1262
  /* @__PURE__ */ jsxs2(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
816
1263
  "[",
817
1264
  it.key,
818
1265
  "]"
819
1266
  ] }),
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 })
1267
+ /* @__PURE__ */ jsx7(Text2, { children: " " }),
1268
+ /* @__PURE__ */ jsxs2(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
1269
+ it.label,
1270
+ pad
1271
+ ] }),
1272
+ /* @__PURE__ */ jsx7(Text2, { color: PALETTE.muted, children: it.hint })
823
1273
  ] }, it.key);
824
1274
  }) }),
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
- ] }) })
1275
+ /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text2, { color: PALETTE.muted, children: cfg.defaultDict ? t.mainMenu.defaultDict(cfg.defaultDict) : t.mainMenu.noDefault }) }),
1276
+ /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text2, { color: PALETTE.muted, children: t.mainMenu.hint }) }),
1277
+ audio.warning && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text2, { color: PALETTE.warning, children: t.audio.noPlayer }) })
834
1278
  ] });
835
1279
  }
836
1280
 
837
1281
  // 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";
1282
+ import { useState as useState7, useEffect as useEffect5, useRef as useRef3 } from "react";
1283
+ import { Box as Box4, Text as Text3, useApp as useApp3, useInput as useInput3 } from "ink";
841
1284
 
842
1285
  // src/util/shuffle.ts
843
1286
  function shuffle(arr, rng = Math.random) {
@@ -881,25 +1324,25 @@ function buildPlaylist(chapter, mode, seed) {
881
1324
  function initialState(target) {
882
1325
  return { target, typed: "", errorsThisWord: 0 };
883
1326
  }
884
- function reduce(state, ev) {
1327
+ function reduce(state2, ev) {
885
1328
  switch (ev.type) {
886
1329
  case "reset":
887
- return { state: { ...state, typed: "" }, effect: "none" };
1330
+ return { state: { ...state2, typed: "" }, effect: "none" };
888
1331
  case "backspace": {
889
- if (state.typed.length === 0) return { state, effect: "none" };
890
- return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
1332
+ if (state2.typed.length === 0) return { state: state2, effect: "none" };
1333
+ return { state: { ...state2, typed: state2.typed.slice(0, -1) }, effect: "none" };
891
1334
  }
892
1335
  case "char": {
893
- const candidate = state.typed + ev.ch;
894
- const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
1336
+ const candidate = state2.typed + ev.ch;
1337
+ const targetUpToCandidate = [...state2.target].slice(0, [...candidate].length).join("");
895
1338
  if (candidate === targetUpToCandidate) {
896
- if (candidate.length === state.target.length) {
897
- return { state: { ...state, typed: candidate }, effect: "correct" };
1339
+ if (candidate.length === state2.target.length) {
1340
+ return { state: { ...state2, typed: candidate }, effect: "correct" };
898
1341
  }
899
- return { state: { ...state, typed: candidate }, effect: "progress" };
1342
+ return { state: { ...state2, typed: candidate }, effect: "progress" };
900
1343
  }
901
1344
  return {
902
- state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
1345
+ state: { ...state2, typed: "", errorsThisWord: state2.errorsThisWord + 1 },
903
1346
  effect: "wrong"
904
1347
  };
905
1348
  }
@@ -921,11 +1364,11 @@ function startSession(playlist, now = Date.now()) {
921
1364
  }
922
1365
  function feedSession(session, ev, now = Date.now()) {
923
1366
  if (!session.current) return { session, effect: "none" };
924
- const { state, effect } = reduce(session.current.input, ev);
1367
+ const { state: state2, effect } = reduce(session.current.input, ev);
925
1368
  if (effect === "correct") {
926
1369
  const finished = {
927
- word: state.target,
928
- errors: state.errorsThisWord,
1370
+ word: state2.target,
1371
+ errors: state2.errorsThisWord,
929
1372
  durationMs: now - session.current.wordStartedAt
930
1373
  };
931
1374
  const nextIndex = session.current.wordIndex + 1;
@@ -952,7 +1395,7 @@ function feedSession(session, ev, now = Date.now()) {
952
1395
  return {
953
1396
  session: {
954
1397
  ...session,
955
- current: { ...session.current, input: state }
1398
+ current: { ...session.current, input: state2 }
956
1399
  },
957
1400
  effect
958
1401
  };
@@ -1036,24 +1479,24 @@ function topN(book, n) {
1036
1479
  }
1037
1480
 
1038
1481
  // src/ui/hooks/useWordLoop.ts
1039
- import { useEffect as useEffect3, useReducer, useRef, useState as useState5 } from "react";
1482
+ import { useEffect as useEffect3, useReducer, useRef, useState as useState6 } from "react";
1040
1483
  import { useInput as useInput2, useApp as useApp2 } from "ink";
1041
- function reducer(state, action) {
1484
+ function reducer(state2, action) {
1042
1485
  if (action.type === "start") {
1043
1486
  return { session: startSession(action.playlist, action.now), lastEffect: null };
1044
1487
  }
1045
1488
  if (action.type === "skip") {
1046
- const r = skipSession(state.session, action.now);
1489
+ const r = skipSession(state2.session, action.now);
1047
1490
  return { session: r.session, lastEffect: r.effect };
1048
1491
  }
1049
1492
  if (action.type === "event") {
1050
1493
  if (action.key.backspace || action.key.delete) {
1051
- const r = feedSession(state.session, { type: "backspace" }, action.now);
1494
+ const r = feedSession(state2.session, { type: "backspace" }, action.now);
1052
1495
  return { session: r.session, lastEffect: r.effect };
1053
1496
  }
1054
- if (action.input.length === 0) return state;
1055
- let session = state.session;
1056
- let lastEffect = state.lastEffect;
1497
+ if (action.input.length === 0) return state2;
1498
+ let session = state2.session;
1499
+ let lastEffect = state2.lastEffect;
1057
1500
  for (const c of action.input) {
1058
1501
  const r = feedSession(session, { type: "char", ch: c }, action.now);
1059
1502
  session = r.session;
@@ -1062,15 +1505,15 @@ function reducer(state, action) {
1062
1505
  }
1063
1506
  return { session, lastEffect };
1064
1507
  }
1065
- return state;
1508
+ return state2;
1066
1509
  }
1067
1510
  function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled = true }) {
1068
- const [state, dispatch] = useReducer(reducer, void 0, () => ({
1511
+ const [state2, dispatch] = useReducer(reducer, void 0, () => ({
1069
1512
  session: startSession(playlist, Date.now()),
1070
1513
  lastEffect: null
1071
1514
  }));
1072
1515
  const completedRef = useRef(false);
1073
- const [tick, setTick] = useState5(0);
1516
+ const [tick, setTick] = useState6(0);
1074
1517
  const { exit } = useApp2();
1075
1518
  useInput2(
1076
1519
  (input, key) => {
@@ -1103,17 +1546,17 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled =
1103
1546
  { isActive: enabled }
1104
1547
  );
1105
1548
  useEffect3(() => {
1106
- if (state.session.finishedAt !== null && !completedRef.current) {
1549
+ if (state2.session.finishedAt !== null && !completedRef.current) {
1107
1550
  completedRef.current = true;
1108
- onComplete(state.session);
1551
+ onComplete(state2.session);
1109
1552
  }
1110
- }, [state.session, onComplete]);
1553
+ }, [state2.session, onComplete]);
1111
1554
  useEffect3(() => {
1112
- if (state.session.finishedAt !== null) return;
1555
+ if (state2.session.finishedAt !== null) return;
1113
1556
  const id = setInterval(() => setTick((t) => t + 1), 1e3);
1114
1557
  return () => clearInterval(id);
1115
- }, [state.session.finishedAt]);
1116
- return { session: state.session, lastEffect: state.lastEffect, tick };
1558
+ }, [state2.session.finishedAt]);
1559
+ return { session: state2.session, lastEffect: state2.lastEffect, tick };
1117
1560
  }
1118
1561
 
1119
1562
  // src/ui/hooks/useAudio.ts
@@ -1223,6 +1666,43 @@ function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
1223
1666
  return out;
1224
1667
  }
1225
1668
 
1669
+ // src/infra/session-tracker.ts
1670
+ var state = {
1671
+ startedAt: null,
1672
+ chapters: []
1673
+ };
1674
+ function start(now = Date.now()) {
1675
+ if (state.startedAt === null) state.startedAt = now;
1676
+ }
1677
+ function addChapter(entry) {
1678
+ if (state.startedAt === null) state.startedAt = Date.now();
1679
+ state.chapters.push(entry);
1680
+ }
1681
+ function report(now = Date.now()) {
1682
+ const chapters = state.chapters;
1683
+ const wordCount = chapters.reduce((a, c) => a + c.wordCount, 0);
1684
+ const errors = chapters.reduce((a, c) => a + c.errors, 0);
1685
+ const practiceMs = chapters.reduce((a, c) => a + c.durationMs, 0);
1686
+ const minutes = practiceMs / 6e4;
1687
+ const wpm = minutes > 0 ? Math.round(wordCount / minutes * 10) / 10 : 0;
1688
+ const errorWordSet = /* @__PURE__ */ new Set();
1689
+ for (const c of chapters) {
1690
+ for (const w of Object.keys(c.perWordErrors)) errorWordSet.add(w);
1691
+ }
1692
+ const accuracy2 = wordCount === 0 ? 1 : Math.max(0, (wordCount - errorWordSet.size) / wordCount);
1693
+ return {
1694
+ startedAt: state.startedAt,
1695
+ totalDurationMs: state.startedAt === null ? 0 : now - state.startedAt,
1696
+ chaptersCompleted: chapters.length,
1697
+ wordCount,
1698
+ errors,
1699
+ wpm,
1700
+ accuracy: accuracy2,
1701
+ newMistakeWords: errorWordSet.size,
1702
+ practiceMs
1703
+ };
1704
+ }
1705
+
1226
1706
  // src/ui/hooks/useSessionPersistence.ts
1227
1707
  function useSessionPersistence(meta) {
1228
1708
  return useCallback3(
@@ -1238,6 +1718,15 @@ function useSessionPersistence(meta) {
1238
1718
  perWordErrors: summary.perWordErrors
1239
1719
  };
1240
1720
  await appendSession(rec);
1721
+ addChapter({
1722
+ dictId: meta.dictId,
1723
+ chapterIndex: meta.chapterIndex,
1724
+ mode: meta.mode,
1725
+ wordCount: summary.wordCount,
1726
+ errors: summary.errors,
1727
+ durationMs: summary.durationMs,
1728
+ perWordErrors: summary.perWordErrors
1729
+ });
1241
1730
  const dirty = Object.entries(summary.perWordErrors).filter(([, n]) => n > 0);
1242
1731
  if (dirty.length === 0) return;
1243
1732
  let book = await loadMistakes();
@@ -1249,14 +1738,14 @@ function useSessionPersistence(meta) {
1249
1738
  }
1250
1739
 
1251
1740
  // src/ui/screens/PracticeScreen.tsx
1252
- import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
1741
+ import { jsx as jsx8, jsxs as jsxs3 } from "react/jsx-runtime";
1253
1742
  function PracticeScreen({ params }) {
1254
1743
  const { dictId, chapterIndex, mode } = params;
1255
1744
  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);
1745
+ const t = useStrings();
1746
+ const [phase, setPhase] = useState7("loading");
1747
+ const [loaded, setLoaded] = useState7(null);
1748
+ const [errorMsg, setErrorMsg] = useState7(null);
1260
1749
  useEffect5(() => {
1261
1750
  let cancelled = false;
1262
1751
  setPhase("loading");
@@ -1271,7 +1760,7 @@ function PracticeScreen({ params }) {
1271
1760
  if (cancelled) return;
1272
1761
  const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
1273
1762
  if (reviewWords.length === 0) {
1274
- setErrorMsg("No mistakes to review yet. Practice some chapters first.");
1763
+ setErrorMsg(t.practice.errors.noMistakes);
1275
1764
  setPhase("error");
1276
1765
  return;
1277
1766
  }
@@ -1281,7 +1770,7 @@ function PracticeScreen({ params }) {
1281
1770
  }
1282
1771
  const chapters = chunkChapters(words, cfg.chapterSize);
1283
1772
  if (chapters.length === 0) {
1284
- setErrorMsg(`Dictionary ${dictId} is empty.`);
1773
+ setErrorMsg(t.practice.errors.dictEmpty(dictId));
1285
1774
  setPhase("error");
1286
1775
  return;
1287
1776
  }
@@ -1298,15 +1787,15 @@ function PracticeScreen({ params }) {
1298
1787
  return () => {
1299
1788
  cancelled = true;
1300
1789
  };
1301
- }, [dictId, chapterIndex, mode, cfg.chapterSize]);
1790
+ }, [dictId, chapterIndex, mode, cfg.chapterSize, t]);
1302
1791
  if (phase === "loading") {
1303
- return /* @__PURE__ */ jsx7(Centered, { text: "loading\u2026", color: PALETTE.muted });
1792
+ return /* @__PURE__ */ jsx8(Centered, { text: t.practice.loading, color: PALETTE.muted });
1304
1793
  }
1305
1794
  if (phase === "error") {
1306
- return /* @__PURE__ */ jsx7(ErrorView, { msg: errorMsg ?? "Unknown error" });
1795
+ return /* @__PURE__ */ jsx8(ErrorView, { msg: errorMsg ?? t.practice.errors.unknown });
1307
1796
  }
1308
1797
  if (!loaded) return null;
1309
- return /* @__PURE__ */ jsx7(
1798
+ return /* @__PURE__ */ jsx8(
1310
1799
  PracticeRunner,
1311
1800
  {
1312
1801
  params,
@@ -1407,9 +1896,9 @@ function PracticeRunner({
1407
1896
  },
1408
1897
  { isActive: phase === "summary" }
1409
1898
  );
1410
- if (phase === "paused") return /* @__PURE__ */ jsx7(PausedView, {});
1899
+ if (phase === "paused") return /* @__PURE__ */ jsx8(PausedView, {});
1411
1900
  if (phase === "summary") {
1412
- return /* @__PURE__ */ jsx7(
1901
+ return /* @__PURE__ */ jsx8(
1413
1902
  SummaryView,
1414
1903
  {
1415
1904
  dictId,
@@ -1427,7 +1916,7 @@ function PracticeRunner({
1427
1916
  const errors = session.results.reduce((a, r) => a + r.errors, 0);
1428
1917
  const minutes = elapsedMs / 6e4;
1429
1918
  const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
1430
- return /* @__PURE__ */ jsx7(
1919
+ return /* @__PURE__ */ jsx8(
1431
1920
  TypingLayout,
1432
1921
  {
1433
1922
  dictId,
@@ -1461,9 +1950,10 @@ function fmtTime(ms) {
1461
1950
  return `${m}:${String(s).padStart(2, "0")}`;
1462
1951
  }
1463
1952
  function TypingLayout(props) {
1953
+ const t = useStrings();
1464
1954
  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(
1955
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1956
+ /* @__PURE__ */ jsx8(
1467
1957
  StatusBar,
1468
1958
  {
1469
1959
  dictId: props.dictId,
@@ -1476,8 +1966,8 @@ function TypingLayout(props) {
1476
1966
  elapsedMs: props.elapsedMs
1477
1967
  }
1478
1968
  ),
1479
- /* @__PURE__ */ jsxs3(Box3, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
1480
- /* @__PURE__ */ jsx7(
1969
+ /* @__PURE__ */ jsxs3(Box4, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
1970
+ /* @__PURE__ */ jsx8(
1481
1971
  BigWord,
1482
1972
  {
1483
1973
  target: props.target,
@@ -1486,12 +1976,12 @@ function TypingLayout(props) {
1486
1976
  hideTarget: props.hideTarget
1487
1977
  }
1488
1978
  ),
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)) })
1979
+ props.phonetic && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text3, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
1980
+ props.translation.length > 0 && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((tr, i) => /* @__PURE__ */ jsx8(Text3, { color: PALETTE.primary, children: tr }, i)) })
1491
1981
  ] }),
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: [
1982
+ /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
1983
+ /* @__PURE__ */ jsx8(ProgressBar, { frac: progressFrac }),
1984
+ /* @__PURE__ */ jsx8(Box4, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
1495
1985
  props.completed,
1496
1986
  "/",
1497
1987
  props.total,
@@ -1499,21 +1989,27 @@ function TypingLayout(props) {
1499
1989
  fmtTime(props.elapsedMs),
1500
1990
  " \xB7 ",
1501
1991
  props.wpm,
1502
- " wpm \xB7 ",
1992
+ " ",
1993
+ t.practice.statCards.wpm,
1994
+ " \xB7 ",
1503
1995
  props.errors,
1504
- " errors"
1996
+ " ",
1997
+ t.practice.statCards.errors
1505
1998
  ] }) }),
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" }) })
1999
+ /* @__PURE__ */ jsx8(Box4, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.footers.typing }) })
1507
2000
  ] })
1508
2001
  ] });
1509
2002
  }
1510
2003
  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}`;
2004
+ const t = useStrings();
2005
+ const modeName = t.practice.modes[props.mode];
2006
+ const accentName = t.practice.accents[props.accent];
2007
+ const left = props.mode === "review" ? `${props.dictId} \xB7 ${t.practice.reviewLabel} \xB7 ${accentName}` : `${props.dictId} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName} \xB7 ${accentName}`;
1512
2008
  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 })
2009
+ return /* @__PURE__ */ jsxs3(Box4, { children: [
2010
+ /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: left }),
2011
+ /* @__PURE__ */ jsx8(Box4, { flexGrow: 1 }),
2012
+ /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: right })
1517
2013
  ] });
1518
2014
  }
1519
2015
  function ProgressBar({ frac }) {
@@ -1521,22 +2017,27 @@ function ProgressBar({ frac }) {
1521
2017
  const width = Math.max(20, Math.min(72, cols - 16));
1522
2018
  const filled = Math.round(width * Math.max(0, Math.min(1, frac)));
1523
2019
  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) })
2020
+ return /* @__PURE__ */ jsxs3(Box4, { justifyContent: "center", children: [
2021
+ /* @__PURE__ */ jsx8(Text3, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
2022
+ /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
1527
2023
  ] });
1528
2024
  }
1529
2025
  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" }) })
2026
+ const t = useStrings();
2027
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2028
+ /* @__PURE__ */ jsx8(BoldSpaced, { text: t.practice.paused, color: PALETTE.warning }),
2029
+ /* @__PURE__ */ jsx8(Box4, { marginTop: 2, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.footers.paused }) })
1533
2030
  ] });
1534
2031
  }
1535
2032
  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, {})
2033
+ const t = useStrings();
2034
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2035
+ /* @__PURE__ */ jsx8(Text3, { color: PALETTE.error, children: msg }),
2036
+ /* @__PURE__ */ jsx8(Box4, { marginTop: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
2037
+ "[q] ",
2038
+ t.common.back
2039
+ ] }) }),
2040
+ /* @__PURE__ */ jsx8(BackKey, {})
1540
2041
  ] });
1541
2042
  }
1542
2043
  function BackKey() {
@@ -1547,7 +2048,7 @@ function BackKey() {
1547
2048
  return null;
1548
2049
  }
1549
2050
  function Centered({ text, color }) {
1550
- return /* @__PURE__ */ jsx7(Box3, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx7(Text3, { color, children: text }) });
2051
+ return /* @__PURE__ */ jsx8(Box4, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx8(Text3, { color, children: text }) });
1551
2052
  }
1552
2053
  function SummaryView(props) {
1553
2054
  const { summary } = props;
@@ -1556,52 +2057,52 @@ function SummaryView(props) {
1556
2057
  const errorWords = Object.keys(summary.perWordErrors).length;
1557
2058
  const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
1558
2059
  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(
2060
+ const t = useStrings();
2061
+ const modeName = t.practice.modes[props.mode];
2062
+ const subtitle = props.mode === "review" ? `${props.dictId} \xB7 ${t.practice.reviewLabel}` : `${props.dictId} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName}`;
2063
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
2064
+ /* @__PURE__ */ jsx8(BoldSpaced, { text: t.practice.chapterComplete, color: PALETTE.success }),
2065
+ /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: subtitle }) }),
2066
+ /* @__PURE__ */ jsxs3(Box4, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
2067
+ /* @__PURE__ */ jsx8(StatCard, { label: t.practice.statCards.words, value: String(summary.wordCount), color: PALETTE.text }),
2068
+ /* @__PURE__ */ jsx8(
1566
2069
  StatCard,
1567
2070
  {
1568
- label: "errors",
2071
+ label: t.practice.statCards.errors,
1569
2072
  value: String(summary.errors),
1570
2073
  color: summary.errors > 0 ? PALETTE.error : PALETTE.muted
1571
2074
  }
1572
2075
  ),
1573
- /* @__PURE__ */ jsx7(StatCard, { label: "wpm", value: String(wpm), color: PALETTE.accent }),
1574
- /* @__PURE__ */ jsx7(StatCard, { label: "accuracy", value: `${accPct}%`, color: PALETTE.accent })
2076
+ /* @__PURE__ */ jsx8(StatCard, { label: t.practice.statCards.wpm, value: String(wpm), color: PALETTE.accent }),
2077
+ /* @__PURE__ */ jsx8(StatCard, { label: t.practice.statCards.accuracy, value: `${accPct}%`, color: PALETTE.accent })
1575
2078
  ] }),
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" }) })
2079
+ /* @__PURE__ */ jsx8(Box4, { marginTop: 2, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.statCards.elapsed(fmtTime(summary.durationMs)) }) }),
2080
+ /* @__PURE__ */ jsx8(Box4, { flexGrow: 1 }),
2081
+ /* @__PURE__ */ jsx8(Box4, { marginTop: 2, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.footers.summary }) })
1582
2082
  ] });
1583
2083
  }
1584
2084
  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 })
2085
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", alignItems: "center", marginX: 3, children: [
2086
+ /* @__PURE__ */ jsx8(Text3, { bold: true, color, children: value }),
2087
+ /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: label })
1588
2088
  ] });
1589
2089
  }
1590
2090
 
1591
2091
  // 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";
2092
+ import { useEffect as useEffect6, useState as useState8 } from "react";
2093
+ import { Box as Box5, Text as Text4, useInput as useInput4 } from "ink";
2094
+ import { Fragment, jsx as jsx9, jsxs as jsxs4 } from "react/jsx-runtime";
1595
2095
  function DictBrowser({ params }) {
1596
2096
  const nav = useNav();
1597
2097
  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);
2098
+ const t = useStrings();
2099
+ const [rows, setRows] = useState8([]);
2100
+ const [loading, setLoading] = useState8(true);
2101
+ const [selected, setSelected] = useState8(0);
2102
+ const [filter, setFilter] = useState8("");
2103
+ const [filterFocus, setFilterFocus] = useState8(false);
2104
+ const [pending, setPending] = useState8(null);
2105
+ const [tick, setTick] = useState8(0);
1605
2106
  const refresh = async () => {
1606
2107
  const reg = await loadRegistry();
1607
2108
  const flagged = await Promise.all(
@@ -1669,7 +2170,7 @@ function DictBrowser({ params }) {
1669
2170
  try {
1670
2171
  await removeDictionary(current.entry.id);
1671
2172
  setPending(null);
1672
- setTick((t) => t + 1);
2173
+ setTick((n) => n + 1);
1673
2174
  } catch (err) {
1674
2175
  setPending({ kind: "error", id: current.entry.id, msg: err.message });
1675
2176
  }
@@ -1682,7 +2183,7 @@ function DictBrowser({ params }) {
1682
2183
  try {
1683
2184
  await pullDictionary(current.entry.id);
1684
2185
  setPending(null);
1685
- setTick((t) => t + 1);
2186
+ setTick((n) => n + 1);
1686
2187
  } catch (err) {
1687
2188
  setPending({ kind: "error", id: current.entry.id, msg: err.message });
1688
2189
  }
@@ -1690,88 +2191,67 @@ function DictBrowser({ params }) {
1690
2191
  }
1691
2192
  });
1692
2193
  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" }) });
2194
+ return /* @__PURE__ */ jsx9(Box5, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: t.dict.loading }) });
1694
2195
  }
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` })
2196
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2197
+ /* @__PURE__ */ jsxs4(Box5, { children: [
2198
+ /* @__PURE__ */ jsx9(Text4, { bold: true, color: PALETTE.accent, children: t.dict.title }),
2199
+ /* @__PURE__ */ jsx9(Box5, { flexGrow: 1 }),
2200
+ /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: filterFocus ? `/ ${filter}_` : filter ? t.dict.filterPrompt(filter) : t.dict.entries(filtered.length) })
1700
2201
  ] }),
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) => {
2202
+ /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexGrow: 1, children: [
2203
+ /* @__PURE__ */ jsx9(Box5, { flexDirection: "column", width: "75%", paddingRight: 1, children: filtered.slice(Math.max(0, safeSelected - 8), safeSelected + 16).map((row, vi) => {
1703
2204
  const i = Math.max(0, safeSelected - 8) + vi;
1704
2205
  const active = i === safeSelected;
1705
2206
  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) })
2207
+ return /* @__PURE__ */ jsxs4(Box5, { children: [
2208
+ /* @__PURE__ */ jsx9(Box5, { width: 2, children: /* @__PURE__ */ jsx9(Text4, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }) }),
2209
+ /* @__PURE__ */ jsx9(Box5, { width: 2, children: /* @__PURE__ */ jsx9(Text4, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }) }),
2210
+ /* @__PURE__ */ jsx9(Box5, { width: 2, children: /* @__PURE__ */ jsx9(Text4, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }) }),
2211
+ /* @__PURE__ */ jsx9(Box5, { flexGrow: 1, children: /* @__PURE__ */ jsx9(Text4, { bold: active, color: active ? PALETTE.text : PALETTE.muted, wrap: "wrap", children: row.entry.name }) }),
2212
+ /* @__PURE__ */ jsx9(Box5, { width: 6, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) }) })
1714
2213
  ] }, row.entry.id);
1715
2214
  }) }),
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: [
2215
+ /* @__PURE__ */ jsx9(Box5, { flexDirection: "column", width: "25%", paddingLeft: 1, children: current && /* @__PURE__ */ jsxs4(Fragment, { children: [
2216
+ /* @__PURE__ */ jsx9(Text4, { bold: true, color: PALETTE.text, wrap: "wrap", children: current.entry.name }),
2217
+ /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: current.entry.id }),
2218
+ /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, wrap: "wrap", children: [
1720
2219
  current.entry.language,
1721
2220
  " \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(", ")
2221
+ current.entry.category
1731
2222
  ] }) }),
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
- ] })
2223
+ /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: t.dict.wordsLabel(current.entry.length) }) }),
2224
+ current.entry.description && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.primary, wrap: "wrap", children: current.entry.description }) }),
2225
+ current.entry.tags.length > 0 && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, wrap: "wrap", children: t.dict.tagsLabel(current.entry.tags.join(", ")) }) }),
2226
+ /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: current.local ? PALETTE.accent : PALETTE.muted, children: current.local ? t.dict.local : t.dict.notLocal }) }),
2227
+ cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx9(Box5, { children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.success, children: t.dict.defaultMark }) })
1736
2228
  ] }) })
1737
2229
  ] }),
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
- ] })
2230
+ pending && /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, children: [
2231
+ pending.kind === "pulling" && /* @__PURE__ */ jsx9(Text4, { color: PALETTE.warning, children: t.dict.pulling(pending.id) }),
2232
+ pending.kind === "removing" && /* @__PURE__ */ jsx9(Text4, { color: PALETTE.warning, children: t.dict.removing(pending.id) }),
2233
+ pending.kind === "error" && /* @__PURE__ */ jsx9(Text4, { color: PALETTE.error, children: t.dict.errorOn(pending.id, pending.msg) })
1755
2234
  ] }),
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" }) })
2235
+ /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: t.dict.footer }) })
1757
2236
  ] });
1758
2237
  }
1759
2238
 
1760
2239
  // 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";
2240
+ import { useState as useState9 } from "react";
2241
+ import { Box as Box6, Text as Text5, useInput as useInput5 } from "ink";
2242
+ import { jsx as jsx10, jsxs as jsxs5 } from "react/jsx-runtime";
1764
2243
  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" }
2244
+ { kind: "dictRef", path: "defaultDict", labelKey: "defaultDict" },
2245
+ { kind: "enum", path: "defaultMode", labelKey: "defaultMode", options: ["order", "dictation", "review", "random", "loop"] },
2246
+ { kind: "enum", path: "accent", labelKey: "accent", options: ["us", "uk"] },
2247
+ { kind: "enum", path: "language", labelKey: "language", options: ["auto", "zh", "en"] },
2248
+ { kind: "enum", path: "mirror", labelKey: "mirror", options: ["jsdelivr", "github"] },
2249
+ { kind: "int", path: "chapterSize", labelKey: "chapterSize", min: 1, max: 200 },
2250
+ { kind: "bool", path: "autoplayPronunciation", labelKey: "autoplayPronunciation" },
2251
+ { kind: "bool", path: "sounds.master", labelKey: "soundsMaster" },
2252
+ { kind: "bool", path: "sounds.keystroke", labelKey: "soundsKeystroke" },
2253
+ { kind: "bool", path: "sounds.feedback", labelKey: "soundsFeedback" },
2254
+ { kind: "string", path: "sounds.keySoundName", labelKey: "soundsKeySound" }
1775
2255
  ];
1776
2256
  function getByPath2(cfg, path) {
1777
2257
  return path.split(".").reduce((acc, k) => {
@@ -1782,10 +2262,11 @@ function getByPath2(cfg, path) {
1782
2262
  function ConfigEditor() {
1783
2263
  const nav = useNav();
1784
2264
  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);
2265
+ const t = useStrings();
2266
+ const [selected, setSelected] = useState9(0);
2267
+ const [editing, setEditing] = useState9(false);
2268
+ const [draft, setDraft] = useState9("");
2269
+ const [error, setError] = useState9(null);
1789
2270
  const field = FIELDS[selected];
1790
2271
  const currentValue = getByPath2(cfg, field.path);
1791
2272
  const commit = async (raw) => {
@@ -1868,50 +2349,57 @@ function ConfigEditor() {
1868
2349
  setError(null);
1869
2350
  }
1870
2351
  });
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) => {
2352
+ const labelW = Math.max(...FIELDS.map((f) => visibleWidth2(t.config.fields[f.labelKey]))) + 4;
2353
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2354
+ /* @__PURE__ */ jsx10(Text5, { bold: true, color: PALETTE.accent, children: t.config.title }),
2355
+ /* @__PURE__ */ jsx10(Box6, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
1874
2356
  const active = i === selected;
1875
2357
  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 })
2358
+ const display = renderValue(f, value, active && editing ? draft : null, t);
2359
+ const label = t.config.fields[f.labelKey];
2360
+ const pad = " ".repeat(Math.max(0, labelW - visibleWidth2(label)));
2361
+ return /* @__PURE__ */ jsxs5(Box6, { children: [
2362
+ /* @__PURE__ */ jsx10(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2363
+ /* @__PURE__ */ jsxs5(Text5, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
2364
+ label,
2365
+ pad
2366
+ ] }),
2367
+ /* @__PURE__ */ jsx10(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
1881
2368
  ] }, f.path);
1882
2369
  }) }),
1883
- error && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: PALETTE.error, children: [
2370
+ error && /* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: PALETTE.error, children: [
1884
2371
  "! ",
1885
2372
  error
1886
2373
  ] }) }),
1887
- /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text5, { color: PALETTE.muted, children: hintFor(field, editing) }) })
2374
+ /* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text5, { color: PALETTE.muted, children: hintFor(field, editing, t) }) })
1888
2375
  ] });
1889
2376
  }
1890
- function renderValue(field, value, draft) {
2377
+ function renderValue(field, value, draft, t) {
1891
2378
  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)");
2379
+ if (field.kind === "bool") return value ? `\u2713 ${t.common.on}` : `\u2717 ${t.common.off}`;
2380
+ if (field.kind === "dictRef") return String(value ?? "\u2014");
1894
2381
  if (field.kind === "enum") return `< ${value} >`;
1895
2382
  return String(value ?? "");
1896
2383
  }
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";
2384
+ function hintFor(field, editing, t) {
2385
+ if (editing) return t.config.hints.editing;
2386
+ if (field.kind === "bool") return t.config.hints.bool;
2387
+ if (field.kind === "enum") return t.config.hints.enum;
2388
+ if (field.kind === "dictRef") return t.config.hints.dictRef;
2389
+ return t.config.hints.stringOrInt;
1903
2390
  }
1904
2391
 
1905
2392
  // 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";
2393
+ import { useEffect as useEffect7, useState as useState10 } from "react";
2394
+ import { Box as Box7, Text as Text6, useInput as useInput6 } from "ink";
2395
+ import { jsx as jsx11, jsxs as jsxs6 } from "react/jsx-runtime";
1909
2396
  var DAY_WINDOWS = [7, 14, 30, 90];
1910
2397
  function StatsViewer() {
1911
2398
  const nav = useNav();
1912
- const [sessions, setSessions] = useState9(null);
1913
- const [book, setBook] = useState9(null);
1914
- const [windowIdx, setWindowIdx] = useState9(1);
2399
+ const t = useStrings();
2400
+ const [sessions, setSessions] = useState10(null);
2401
+ const [book, setBook] = useState10(null);
2402
+ const [windowIdx, setWindowIdx] = useState10(1);
1915
2403
  useEffect7(() => {
1916
2404
  void (async () => {
1917
2405
  const [s, b] = await Promise.all([loadSessions(), loadMistakes()]);
@@ -1928,13 +2416,16 @@ function StatsViewer() {
1928
2416
  if (input === "N") setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
1929
2417
  });
1930
2418
  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" }) });
2419
+ return /* @__PURE__ */ jsx11(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.loading }) });
1932
2420
  }
1933
2421
  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" }) })
2422
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2423
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.none }),
2424
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.nonePractice }) }),
2425
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 2, children: /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2426
+ "[q] ",
2427
+ t.common.back
2428
+ ] }) })
1938
2429
  ] });
1939
2430
  }
1940
2431
  const days = DAY_WINDOWS[windowIdx];
@@ -1951,53 +2442,49 @@ function StatsViewer() {
1951
2442
  const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
1952
2443
  const recent = sessions.slice(-5).reverse();
1953
2444
  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 })
2445
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2446
+ /* @__PURE__ */ jsx11(Text6, { bold: true, color: PALETTE.accent, children: t.stats.title }),
2447
+ /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, flexDirection: "column", children: [
2448
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.lifetime }),
2449
+ /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, children: [
2450
+ /* @__PURE__ */ jsx11(Stat, { label: t.stats.sessions, value: String(sessions.length) }),
2451
+ /* @__PURE__ */ jsx11(Stat, { label: t.stats.words, value: String(totalWords) }),
2452
+ /* @__PURE__ */ jsx11(Stat, { label: t.stats.errors, value: String(totalErrors) }),
2453
+ /* @__PURE__ */ jsx11(Stat, { label: t.stats.wpm, value: String(overallWpm), accent: true }),
2454
+ /* @__PURE__ */ jsx11(Stat, { label: t.stats.accuracy, value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
2455
+ /* @__PURE__ */ jsx11(Stat, { label: t.stats.streak, value: `${streak}d`, accent: true })
1965
2456
  ] })
1966
2457
  ] }),
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)"
1972
- ] }),
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)) }),
2458
+ /* @__PURE__ */ jsxs6(Box7, { marginTop: 2, flexDirection: "column", children: [
2459
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.last(days) }),
2460
+ /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, flexDirection: "column", children: [
2461
+ /* @__PURE__ */ jsxs6(Box7, { children: [
2462
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.wpm.padEnd(10) }),
2463
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.wpm)) }),
1977
2464
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
1978
2465
  " max ",
1979
2466
  Math.round(Math.max(...buckets.map((b) => b.wpm)))
1980
2467
  ] })
1981
2468
  ] }),
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)) })
2469
+ /* @__PURE__ */ jsxs6(Box7, { children: [
2470
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.accuracy.padEnd(10) }),
2471
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.accuracy * 100)) })
1985
2472
  ] }),
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)) })
2473
+ /* @__PURE__ */ jsxs6(Box7, { children: [
2474
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.sessions.padEnd(10) }),
2475
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.sessions)) })
1989
2476
  ] })
1990
2477
  ] })
1991
2478
  ] }),
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: [
2479
+ /* @__PURE__ */ jsxs6(Box7, { marginTop: 2, flexDirection: "column", children: [
2480
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.recent }),
2481
+ recent.map((s, i) => /* @__PURE__ */ jsxs6(Box7, { children: [
1995
2482
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
1996
2483
  " ",
1997
2484
  s.ts.replace("T", " ").slice(0, 16),
1998
2485
  " "
1999
2486
  ] }),
2000
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
2487
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
2001
2488
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2002
2489
  " ",
2003
2490
  "ch",
@@ -2016,37 +2503,37 @@ function StatsViewer() {
2016
2503
  ] })
2017
2504
  ] }, i))
2018
2505
  ] }),
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: [
2506
+ top.length > 0 && /* @__PURE__ */ jsxs6(Box7, { marginTop: 2, flexDirection: "column", children: [
2507
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.topMistakes }),
2508
+ top.map(([word, entry]) => /* @__PURE__ */ jsxs6(Box7, { children: [
2022
2509
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.error, children: [
2023
2510
  " ",
2024
2511
  String(entry.count).padStart(3),
2025
2512
  " "
2026
2513
  ] }),
2027
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: word.padEnd(20) }),
2028
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: entry.dictIds.join(", ") })
2514
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.text, children: word.padEnd(20) }),
2515
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: entry.dictIds.join(", ") })
2029
2516
  ] }, word))
2030
2517
  ] }),
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" }) })
2518
+ /* @__PURE__ */ jsx11(Box7, { flexGrow: 1 }),
2519
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.footer }) })
2033
2520
  ] });
2034
2521
  }
2035
2522
  function Stat({ label, value, accent = false }) {
2036
- return /* @__PURE__ */ jsxs6(Box6, { marginRight: 3, children: [
2523
+ return /* @__PURE__ */ jsxs6(Box7, { marginRight: 3, children: [
2037
2524
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2038
2525
  label,
2039
2526
  " "
2040
2527
  ] }),
2041
- /* @__PURE__ */ jsx10(Text6, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
2528
+ /* @__PURE__ */ jsx11(Text6, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
2042
2529
  ] });
2043
2530
  }
2044
2531
 
2045
2532
  // 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";
2533
+ import { useEffect as useEffect8, useState as useState11 } from "react";
2534
+ import { Box as Box8, Text as Text7, useInput as useInput7 } from "ink";
2048
2535
  import { readdir } from "fs/promises";
2049
- import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
2536
+ import { Fragment as Fragment2, jsx as jsx12, jsxs as jsxs7 } from "react/jsx-runtime";
2050
2537
  async function listLocalDictIds() {
2051
2538
  try {
2052
2539
  const files = await readdir(paths.dictsDir);
@@ -2057,11 +2544,12 @@ async function listLocalDictIds() {
2057
2544
  }
2058
2545
  function WordLookup() {
2059
2546
  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);
2547
+ const t = useStrings();
2548
+ const [query, setQuery] = useState11("");
2549
+ const [allWords, setAllWords] = useState11([]);
2550
+ const [book, setBook] = useState11({});
2551
+ const [loading, setLoading] = useState11(true);
2552
+ const [selected, setSelected] = useState11(0);
2065
2553
  useEffect8(() => {
2066
2554
  void (async () => {
2067
2555
  const ids = await listLocalDictIds();
@@ -2102,49 +2590,45 @@ function WordLookup() {
2102
2590
  }
2103
2591
  });
2104
2592
  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" }) });
2593
+ return /* @__PURE__ */ jsx12(Box8, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.indexing }) });
2106
2594
  }
2107
2595
  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" }) })
2596
+ return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2597
+ /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.none }),
2598
+ /* @__PURE__ */ jsx12(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.pullFirst }) }),
2599
+ /* @__PURE__ */ jsx12(Box8, { marginTop: 2, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2600
+ "[Esc] ",
2601
+ t.common.back
2602
+ ] }) })
2112
2603
  ] });
2113
2604
  }
2114
2605
  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"
2122
- ] })
2606
+ return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2607
+ /* @__PURE__ */ jsxs7(Box8, { children: [
2608
+ /* @__PURE__ */ jsx12(Text7, { bold: true, color: PALETTE.accent, children: t.word.title }),
2609
+ /* @__PURE__ */ jsx12(Box8, { flexGrow: 1 }),
2610
+ /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.countAcross(allWords.length) })
2123
2611
  ] }),
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: "_" })
2612
+ /* @__PURE__ */ jsxs7(Box8, { marginTop: 1, children: [
2613
+ /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: "> " }),
2614
+ /* @__PURE__ */ jsx12(Text7, { color: PALETTE.text, children: query }),
2615
+ /* @__PURE__ */ jsx12(Text7, { color: PALETTE.accent, children: "_" })
2128
2616
  ] }),
2129
- /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexGrow: 1, children: [
2130
- /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", width: "40%", children: [
2617
+ /* @__PURE__ */ jsxs7(Box8, { marginTop: 1, flexGrow: 1, children: [
2618
+ /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", width: "40%", children: [
2131
2619
  filtered.map((h, i) => {
2132
2620
  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 })
2621
+ return /* @__PURE__ */ jsxs7(Box8, { children: [
2622
+ /* @__PURE__ */ jsx12(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2623
+ /* @__PURE__ */ jsx12(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: h.word.name.padEnd(20) }),
2624
+ /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: h.dictId })
2137
2625
  ] }, `${h.dictId}-${h.word.name}-${i}`);
2138
2626
  }),
2139
- filtered.length === 0 && q && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2140
- 'no matches for "',
2141
- query,
2142
- '"'
2143
- ] })
2627
+ filtered.length === 0 && q && /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.noMatches(query) })
2144
2628
  ] }),
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: [
2629
+ /* @__PURE__ */ jsx12(Box8, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs7(Fragment2, { children: [
2630
+ /* @__PURE__ */ jsx12(Text7, { bold: true, color: PALETTE.text, children: current.word.name }),
2631
+ /* @__PURE__ */ jsxs7(Box8, { marginTop: 1, children: [
2148
2632
  current.word.usphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2149
2633
  "US /",
2150
2634
  current.word.usphone,
@@ -2156,59 +2640,109 @@ function WordLookup() {
2156
2640
  "/"
2157
2641
  ] })
2158
2642
  ] }),
2159
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, flexDirection: "column", children: (current.word.trans ?? []).map((t, i) => /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.primary, children: [
2643
+ /* @__PURE__ */ jsx12(Box8, { marginTop: 1, flexDirection: "column", children: (current.word.trans ?? []).map((tr, i) => /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.primary, children: [
2160
2644
  "\xB7 ",
2161
- t
2645
+ tr
2162
2646
  ] }, 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
- ] })
2647
+ /* @__PURE__ */ jsx12(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.inDict(current.dictId) }) }),
2648
+ book[current.word.name] && /* @__PURE__ */ jsx12(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.error, children: t.word.mistakes(book[current.word.name].count, book[current.word.name].lastSeen.slice(0, 10)) }) })
2179
2649
  ] }) })
2180
2650
  ] }),
2181
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back" }) })
2651
+ /* @__PURE__ */ jsx12(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.footer }) })
2182
2652
  ] });
2183
2653
  }
2184
2654
 
2185
2655
  // src/ui/App.tsx
2186
- import { jsx as jsx12 } from "react/jsx-runtime";
2656
+ import { jsx as jsx13 } from "react/jsx-runtime";
2187
2657
  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, {}) }) }) }) });
2658
+ return /* @__PURE__ */ jsx13(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx13(LangBridge, { children: /* @__PURE__ */ jsx13(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx13(NavProvider, { initial, children: /* @__PURE__ */ jsx13(Fullscreen, { children: /* @__PURE__ */ jsx13(Router, {}) }) }) }) }) });
2659
+ }
2660
+ function LangBridge({ children }) {
2661
+ const { cfg } = useAppState();
2662
+ return /* @__PURE__ */ jsx13(StringsProvider, { pref: cfg.language, children });
2663
+ }
2664
+ function screenKey(frame) {
2665
+ if (frame.name === "practice") {
2666
+ const p = frame.params;
2667
+ return `practice:${p.dictId}:${p.chapterIndex}:${p.mode}`;
2668
+ }
2669
+ return frame.name;
2189
2670
  }
2190
2671
  function Router() {
2191
2672
  const nav = useNav();
2192
2673
  const { cfg } = useAppState();
2193
2674
  const { exit } = useApp4();
2194
- useInput8((input, key) => {
2195
- if (key.ctrl && input === "c") exit();
2675
+ const lastKeyRef = useRef4(null);
2676
+ useInput8((input, key2) => {
2677
+ if (key2.ctrl && input === "c") exit();
2196
2678
  });
2197
2679
  const frame = nav.current;
2680
+ const key = screenKey(frame);
2681
+ if (lastKeyRef.current !== key) {
2682
+ if (process.stdout.isTTY) process.stdout.write("\x1B[2J\x1B[H");
2683
+ lastKeyRef.current = key;
2684
+ }
2198
2685
  switch (frame.name) {
2199
2686
  case "main":
2200
- return /* @__PURE__ */ jsx12(MainMenu, { cfg });
2687
+ return /* @__PURE__ */ jsx13(MainMenu, { cfg });
2201
2688
  case "practice":
2202
- return /* @__PURE__ */ jsx12(PracticeScreen, { params: frame.params });
2689
+ return /* @__PURE__ */ jsx13(PracticeScreen, { params: frame.params });
2203
2690
  case "dict":
2204
- return /* @__PURE__ */ jsx12(DictBrowser, { params: frame.params });
2691
+ return /* @__PURE__ */ jsx13(DictBrowser, { params: frame.params });
2205
2692
  case "config":
2206
- return /* @__PURE__ */ jsx12(ConfigEditor, {});
2693
+ return /* @__PURE__ */ jsx13(ConfigEditor, {});
2207
2694
  case "stats":
2208
- return /* @__PURE__ */ jsx12(StatsViewer, {});
2695
+ return /* @__PURE__ */ jsx13(StatsViewer, {});
2209
2696
  case "word":
2210
- return /* @__PURE__ */ jsx12(WordLookup, {});
2697
+ return /* @__PURE__ */ jsx13(WordLookup, {});
2698
+ }
2699
+ }
2700
+
2701
+ // src/util/report.ts
2702
+ import chalk3 from "chalk";
2703
+ var LEAVE_ALTSCREEN = "\x1B[?25h\x1B[?1049l";
2704
+ function ensureMainScreen() {
2705
+ if (process.stdout.isTTY) process.stdout.write(LEAVE_ALTSCREEN);
2706
+ }
2707
+ function fmtDuration(ms, lang) {
2708
+ const total = Math.floor(ms / 1e3);
2709
+ const m = Math.floor(total / 60);
2710
+ const s = total % 60;
2711
+ if (lang === "zh") {
2712
+ if (m === 0) return `${s} \u79D2`;
2713
+ return `${m} \u5206 ${s} \u79D2`;
2714
+ }
2715
+ if (m === 0) return `${s}s`;
2716
+ return `${m}m ${s}s`;
2717
+ }
2718
+ function printSessionReport(r, t, lang) {
2719
+ if (r.chaptersCompleted === 0) return;
2720
+ const accPct = Math.round(r.accuracy * 1e3) / 10;
2721
+ const labels = [
2722
+ t.report.duration,
2723
+ t.report.practiced,
2724
+ t.report.chapters,
2725
+ t.report.words,
2726
+ t.report.accuracy,
2727
+ t.report.wpm
2728
+ ];
2729
+ if (r.newMistakeWords > 0) labels.push(t.report.newMistakes);
2730
+ const labelW = Math.max(...labels.map(visibleWidth2)) + 2;
2731
+ const pad = (label) => label + " ".repeat(Math.max(0, labelW - visibleWidth2(label)));
2732
+ console.log();
2733
+ console.log(chalk3.bold.cyan(t.report.title));
2734
+ console.log(` ${chalk3.dim(pad(t.report.duration))} ${fmtDuration(r.totalDurationMs, lang)}`);
2735
+ console.log(` ${chalk3.dim(pad(t.report.practiced))} ${fmtDuration(r.practiceMs, lang)}`);
2736
+ console.log(` ${chalk3.dim(pad(t.report.chapters))} ${r.chaptersCompleted}`);
2737
+ console.log(` ${chalk3.dim(pad(t.report.words))} ${r.wordCount}`);
2738
+ console.log(` ${chalk3.dim(pad(t.report.accuracy))} ${accPct}%`);
2739
+ console.log(` ${chalk3.dim(pad(t.report.wpm))} ${r.wpm}`);
2740
+ if (r.newMistakeWords > 0) {
2741
+ console.log(` ${chalk3.dim(pad(t.report.newMistakes))} ${r.newMistakeWords}`);
2211
2742
  }
2743
+ console.log();
2744
+ console.log(chalk3.dim(` ${t.report.farewell}`));
2745
+ console.log();
2212
2746
  }
2213
2747
 
2214
2748
  // src/commands/practice.ts
@@ -2218,24 +2752,25 @@ function isMode(v) {
2218
2752
  }
2219
2753
  async function runPractice(dictIdArg, options) {
2220
2754
  if (!process.stdout.isTTY) {
2221
- console.error(chalk3.red("Practice requires an interactive TTY."));
2755
+ console.error(chalk4.red("Practice requires an interactive TTY."));
2222
2756
  process.exitCode = 1;
2223
2757
  return;
2224
2758
  }
2225
2759
  const cfg = await loadConfig();
2226
2760
  const dictId = dictIdArg ?? cfg.defaultDict;
2227
2761
  if (!dictId) {
2228
- console.error(chalk3.red("No dictionary specified. Pass an id or set config.defaultDict."));
2762
+ console.error(chalk4.red("No dictionary specified. Pass an id or set config.defaultDict."));
2229
2763
  process.exitCode = 1;
2230
2764
  return;
2231
2765
  }
2232
2766
  const mode = options.mode ?? cfg.defaultMode;
2233
2767
  if (!isMode(mode)) {
2234
- console.error(chalk3.red(`Invalid mode "${mode}". Valid: ${MODES.join(", ")}`));
2768
+ console.error(chalk4.red(`Invalid mode "${mode}". Valid: ${MODES.join(", ")}`));
2235
2769
  process.exitCode = 1;
2236
2770
  return;
2237
2771
  }
2238
2772
  const chapterIndex = Math.max(0, Number(options.chapter ?? 1) - 1);
2773
+ start();
2239
2774
  const { waitUntilExit } = render(
2240
2775
  createElement(App, {
2241
2776
  initial: { name: "practice", params: { dictId, chapterIndex, mode } },
@@ -2244,6 +2779,9 @@ async function runPractice(dictIdArg, options) {
2244
2779
  { patchConsole: false, exitOnCtrlC: false }
2245
2780
  );
2246
2781
  await waitUntilExit();
2782
+ ensureMainScreen();
2783
+ const { lang, t } = pickStrings(cfg.language);
2784
+ printSessionReport(report(), t, lang);
2247
2785
  }
2248
2786
  function buildPracticeCommand() {
2249
2787
  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) => {
@@ -2253,7 +2791,7 @@ function buildPracticeCommand() {
2253
2791
 
2254
2792
  // src/commands/stats.ts
2255
2793
  import { Command as Command4 } from "commander";
2256
- import chalk4 from "chalk";
2794
+ import chalk5 from "chalk";
2257
2795
  function buildStatsCommand() {
2258
2796
  return new Command4("stats").description("Show practice history and trends").option("-d, --days <n>", "window size for trend (default 14)", "14").option("--top <n>", "how many top mistakes to show (default 10)", "10").action(async (opts) => {
2259
2797
  const days = Math.max(1, Number(opts.days) || 14);
@@ -2261,7 +2799,7 @@ function buildStatsCommand() {
2261
2799
  const sessions = await loadSessions();
2262
2800
  const book = await loadMistakes();
2263
2801
  if (sessions.length === 0) {
2264
- console.log(chalk4.yellow("No practice history yet. Run `qwerty practice <dict>` to get started."));
2802
+ console.log(chalk5.yellow("No practice history yet. Run `qwerty practice <dict>` to get started."));
2265
2803
  return;
2266
2804
  }
2267
2805
  const buckets = dailyBuckets(sessions, days);
@@ -2275,33 +2813,33 @@ function buildStatsCommand() {
2275
2813
  );
2276
2814
  const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
2277
2815
  const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
2278
- console.log(chalk4.bold("\nLifetime"));
2279
- console.log(` ${chalk4.dim("sessions")} ${sessions.length} ${chalk4.dim("words")} ${totalWords} ${chalk4.dim("errors")} ${totalErrors}`);
2280
- console.log(` ${chalk4.dim("avg wpm")} ${overallWpm} ${chalk4.dim("avg accuracy")} ${Math.round(overallAcc * 1e3) / 10}% ${chalk4.dim("streak")} ${chalk4.bold(streak)}d`);
2281
- console.log(chalk4.bold(`
2816
+ console.log(chalk5.bold("\nLifetime"));
2817
+ console.log(` ${chalk5.dim("sessions")} ${sessions.length} ${chalk5.dim("words")} ${totalWords} ${chalk5.dim("errors")} ${totalErrors}`);
2818
+ console.log(` ${chalk5.dim("avg wpm")} ${overallWpm} ${chalk5.dim("avg accuracy")} ${Math.round(overallAcc * 1e3) / 10}% ${chalk5.dim("streak")} ${chalk5.bold(streak)}d`);
2819
+ console.log(chalk5.bold(`
2282
2820
  Last ${days} days`));
2283
- console.log(` ${chalk4.dim("wpm ")} ${sparkline(buckets.map((b) => b.wpm))} ${chalk4.dim("max")} ${Math.round(Math.max(...buckets.map((b) => b.wpm)))}`);
2284
- console.log(` ${chalk4.dim("accuracy")} ${sparkline(buckets.map((b) => b.accuracy * 100))} ${chalk4.dim("range")} ${Math.round(Math.min(...buckets.map((b) => b.accuracy * 100)))}-${Math.round(Math.max(...buckets.map((b) => b.accuracy * 100)))}%`);
2285
- console.log(` ${chalk4.dim("sessions")} ${sparkline(buckets.map((b) => b.sessions))}`);
2821
+ console.log(` ${chalk5.dim("wpm ")} ${sparkline(buckets.map((b) => b.wpm))} ${chalk5.dim("max")} ${Math.round(Math.max(...buckets.map((b) => b.wpm)))}`);
2822
+ console.log(` ${chalk5.dim("accuracy")} ${sparkline(buckets.map((b) => b.accuracy * 100))} ${chalk5.dim("range")} ${Math.round(Math.min(...buckets.map((b) => b.accuracy * 100)))}-${Math.round(Math.max(...buckets.map((b) => b.accuracy * 100)))}%`);
2823
+ console.log(` ${chalk5.dim("sessions")} ${sparkline(buckets.map((b) => b.sessions))}`);
2286
2824
  const recent = sessions.slice(-5).reverse();
2287
- console.log(chalk4.bold("\nLast 5 sessions"));
2825
+ console.log(chalk5.bold("\nLast 5 sessions"));
2288
2826
  for (const s of recent) {
2289
2827
  const wpm = computeWPM(s);
2290
2828
  const acc = Math.round(accuracy(s) * 1e3) / 10;
2291
2829
  console.log(
2292
- ` ${chalk4.dim(s.ts.replace("T", " ").slice(0, 16))} ${chalk4.cyan(s.dictId.padEnd(14))} ch${String(s.chapter + 1).padStart(3)} ${s.mode.padEnd(9)} ${String(s.wordCount).padStart(3)}w ${s.errors}err ${wpm}wpm ${acc}%`
2830
+ ` ${chalk5.dim(s.ts.replace("T", " ").slice(0, 16))} ${chalk5.cyan(s.dictId.padEnd(14))} ch${String(s.chapter + 1).padStart(3)} ${s.mode.padEnd(9)} ${String(s.wordCount).padStart(3)}w ${s.errors}err ${wpm}wpm ${acc}%`
2293
2831
  );
2294
2832
  }
2295
2833
  const top = topN(book, topCount);
2296
2834
  if (top.length > 0) {
2297
- console.log(chalk4.bold(`
2835
+ console.log(chalk5.bold(`
2298
2836
  Top ${top.length} mistakes`));
2299
2837
  for (const [word, entry] of top) {
2300
- console.log(` ${chalk4.red(String(entry.count).padStart(3))} ${chalk4.bold(word.padEnd(20))} ${chalk4.dim(entry.dictIds.join(", "))}`);
2838
+ console.log(` ${chalk5.red(String(entry.count).padStart(3))} ${chalk5.bold(word.padEnd(20))} ${chalk5.dim(entry.dictIds.join(", "))}`);
2301
2839
  }
2302
2840
  } else {
2303
- console.log(chalk4.bold("\nTop mistakes"));
2304
- console.log(chalk4.dim(" none \u2014 keep going"));
2841
+ console.log(chalk5.bold("\nTop mistakes"));
2842
+ console.log(chalk5.dim(" none \u2014 keep going"));
2305
2843
  }
2306
2844
  console.log();
2307
2845
  });
@@ -2309,7 +2847,7 @@ Top ${top.length} mistakes`));
2309
2847
 
2310
2848
  // src/commands/word.ts
2311
2849
  import { Command as Command5 } from "commander";
2312
- import chalk5 from "chalk";
2850
+ import chalk6 from "chalk";
2313
2851
  import { readdir as readdir2 } from "fs/promises";
2314
2852
  async function listLocalDictIds2() {
2315
2853
  try {
@@ -2324,7 +2862,7 @@ function buildWordCommand() {
2324
2862
  const q = keyword.toLowerCase();
2325
2863
  const ids = await listLocalDictIds2();
2326
2864
  if (ids.length === 0) {
2327
- console.log(chalk5.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
2865
+ console.log(chalk6.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
2328
2866
  return;
2329
2867
  }
2330
2868
  const hits = [];
@@ -2338,7 +2876,7 @@ function buildWordCommand() {
2338
2876
  }
2339
2877
  }
2340
2878
  if (hits.length === 0) {
2341
- console.log(chalk5.yellow(`No matches for "${keyword}" in ${ids.length} local dictionaries`));
2879
+ console.log(chalk6.yellow(`No matches for "${keyword}" in ${ids.length} local dictionaries`));
2342
2880
  return;
2343
2881
  }
2344
2882
  const byName = /* @__PURE__ */ new Map();
@@ -2351,21 +2889,21 @@ function buildWordCommand() {
2351
2889
  for (const [name, group] of byName) {
2352
2890
  const first = group[0].word;
2353
2891
  console.log();
2354
- console.log(chalk5.bold.white(name));
2892
+ console.log(chalk6.bold.white(name));
2355
2893
  const us = first.usphone ? `US /${first.usphone}/` : "";
2356
2894
  const uk = first.ukphone ? `UK /${first.ukphone}/` : "";
2357
- if (us || uk) console.log(chalk5.dim(` ${[us, uk].filter(Boolean).join(" ")}`));
2358
- for (const t of first.trans ?? []) console.log(chalk5.cyan(` \xB7 ${t}`));
2895
+ if (us || uk) console.log(chalk6.dim(` ${[us, uk].filter(Boolean).join(" ")}`));
2896
+ for (const t of first.trans ?? []) console.log(chalk6.cyan(` \xB7 ${t}`));
2359
2897
  const sources = await Promise.all(
2360
2898
  group.map(async (h) => {
2361
2899
  const reg = await findEntry(h.dictId);
2362
2900
  return reg?.name ?? h.dictId;
2363
2901
  })
2364
2902
  );
2365
- console.log(chalk5.dim(` in: ${sources.join(", ")}`));
2903
+ console.log(chalk6.dim(` in: ${sources.join(", ")}`));
2366
2904
  const mistake = book[name];
2367
2905
  if (mistake) {
2368
- console.log(chalk5.dim(` mistakes: ${mistake.count} (last ${mistake.lastSeen.slice(0, 10)})`));
2906
+ console.log(chalk6.dim(` mistakes: ${mistake.count} (last ${mistake.lastSeen.slice(0, 10)})`));
2369
2907
  }
2370
2908
  }
2371
2909
  console.log();
@@ -2381,16 +2919,20 @@ async function runMainMenu() {
2381
2919
  return;
2382
2920
  }
2383
2921
  const cfg = await loadConfig();
2922
+ start();
2384
2923
  const { waitUntilExit } = render2(
2385
2924
  createElement2(App, { initial: { name: "main" }, initialCfg: cfg }),
2386
2925
  { patchConsole: false, exitOnCtrlC: false }
2387
2926
  );
2388
2927
  await waitUntilExit();
2928
+ ensureMainScreen();
2929
+ const { lang, t } = pickStrings(cfg.language);
2930
+ printSessionReport(report(), t, lang);
2389
2931
  }
2390
2932
 
2391
2933
  // src/cli.ts
2392
2934
  var program = new Command6();
2393
- program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version("0.1.0");
2935
+ program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version(package_default.version);
2394
2936
  program.addCommand(buildPracticeCommand());
2395
2937
  program.addCommand(buildDictCommand());
2396
2938
  program.addCommand(buildWordCommand());