qwerty-cli 0.0.1-alpha.5 → 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
@@ -4,7 +4,7 @@ import { Command as Command6 } from "commander";
4
4
  // package.json
5
5
  var package_default = {
6
6
  name: "qwerty-cli",
7
- version: "0.0.1-alpha.5",
7
+ version: "0.0.1-alpha.6",
8
8
  description: "Terminal clone of qwerty-learner: typing practice for English vocabulary, with chapters, dictation, mistake book, and audio.",
9
9
  type: "module",
10
10
  bin: {
@@ -40,7 +40,6 @@ var package_default = {
40
40
  chalk: "^5.3.0",
41
41
  commander: "^12.1.0",
42
42
  ink: "^5.0.1",
43
- "ink-big-text": "^2.0.0",
44
43
  "p-queue": "^8.0.1",
45
44
  react: "^18.3.1",
46
45
  undici: "^6.19.8",
@@ -150,7 +149,8 @@ var ConfigSchema = z.object({
150
149
  }).default({ master: true, keystroke: true, feedback: true, keySoundName: "default" }),
151
150
  autoplayPronunciation: z.boolean().default(true),
152
151
  defaultMode: z.enum(["order", "dictation", "review", "random", "loop"]).default("order"),
153
- defaultDict: z.string().optional()
152
+ defaultDict: z.string().optional(),
153
+ language: z.enum(["auto", "zh", "en"]).default("auto")
154
154
  });
155
155
  var DEFAULTS = ConfigSchema.parse({});
156
156
  async function loadConfig() {
@@ -506,11 +506,12 @@ ${matches.length} matches`));
506
506
 
507
507
  // src/commands/practice.ts
508
508
  import { Command as Command3 } from "commander";
509
- import chalk3 from "chalk";
509
+ import chalk4 from "chalk";
510
510
  import { render } from "ink";
511
511
  import { createElement } from "react";
512
512
 
513
513
  // src/ui/App.tsx
514
+ import { useRef as useRef4 } from "react";
514
515
  import { useApp as useApp4, useInput as useInput8 } from "ink";
515
516
 
516
517
  // src/ui/nav.tsx
@@ -541,17 +542,27 @@ function useNav() {
541
542
  }
542
543
 
543
544
  // src/ui/Fullscreen.tsx
544
- import { useEffect } from "react";
545
- import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
546
- var ENTER = "\x1B[?1049h\x1B[?25l";
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";
547
549
  var LEAVE = "\x1B[?25h\x1B[?1049l";
548
550
  function shouldUse() {
549
551
  return Boolean(process.stdout.isTTY) && process.env.QWERTY_NO_ALTSCREEN !== "1";
550
552
  }
551
553
  function Fullscreen({ children }) {
554
+ const { stdout } = useStdout();
555
+ const [size, setSize] = useState2(() => ({
556
+ rows: stdout?.rows ?? 24,
557
+ cols: stdout?.columns ?? 80
558
+ }));
552
559
  useEffect(() => {
553
560
  if (!shouldUse()) return;
554
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);
555
566
  const leave = () => {
556
567
  try {
557
568
  process.stdout.write(LEAVE);
@@ -569,14 +580,15 @@ function Fullscreen({ children }) {
569
580
  process.off("SIGINT", onSignal);
570
581
  process.off("SIGTERM", onSignal);
571
582
  process.off("exit", leave);
583
+ process.stdout.off("resize", onResize);
572
584
  leave();
573
585
  };
574
586
  }, []);
575
- return /* @__PURE__ */ jsx2(Fragment, { children });
587
+ return /* @__PURE__ */ jsx2(Box, { width: size.cols, height: size.rows, flexDirection: "column", children });
576
588
  }
577
589
 
578
590
  // src/ui/audio-context.tsx
579
- 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";
580
592
 
581
593
  // src/infra/audio.ts
582
594
  import { spawn } from "child_process";
@@ -738,7 +750,7 @@ function AudioStatusProvider({
738
750
  disabled,
739
751
  children
740
752
  }) {
741
- const [status, setStatus] = useState2({ warning: null, ready: false });
753
+ const [status, setStatus] = useState3({ warning: null, ready: false });
742
754
  useEffect2(() => {
743
755
  let cancelled = false;
744
756
  initAudio(disabled).then(() => {
@@ -759,14 +771,14 @@ function useAudioStatus() {
759
771
  }
760
772
 
761
773
  // src/ui/app-state.tsx
762
- 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";
763
775
  import { jsx as jsx4 } from "react/jsx-runtime";
764
776
  var AppStateContext = createContext3(null);
765
777
  function AppStateProvider({
766
778
  initialCfg,
767
779
  children
768
780
  }) {
769
- const [cfg, setCfgState] = useState3(initialCfg);
781
+ const [cfg, setCfgState] = useState4(initialCfg);
770
782
  const setCfg = useCallback2(async (next) => {
771
783
  setCfgState(next);
772
784
  await saveConfig(next);
@@ -779,14 +791,367 @@ function useAppState() {
779
791
  return ctx;
780
792
  }
781
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
+
782
1148
  // src/ui/screens/MainMenu.tsx
783
- import { useState as useState4 } from "react";
784
- 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";
785
1151
 
786
1152
  // src/ui/components/BigWord.tsx
787
- import { Box, Text, useStdout } from "ink";
788
- import BigText from "ink-big-text";
789
- import { jsx as jsx5, jsxs } from "react/jsx-runtime";
1153
+ import { Box as Box2, Text, useStdout as useStdout2 } from "ink";
1154
+ import { jsx as jsx6, jsxs } from "react/jsx-runtime";
790
1155
  var PALETTE = {
791
1156
  accent: "#5eead4",
792
1157
  muted: "#6b7280",
@@ -797,42 +1162,58 @@ var PALETTE = {
797
1162
  error: "#f87171"
798
1163
  };
799
1164
  function BigWord({ target, typed, error = false, hideTarget = false }) {
800
- const { stdout } = useStdout();
1165
+ const { stdout } = useStdout2();
801
1166
  const cols = stdout?.columns ?? 80;
802
1167
  const chars = [...target];
803
1168
  const typedChars = [...typed];
804
- const useBig = cols >= 60 && process.env.QWERTY_NO_BIGTEXT !== "1";
805
- if (!useBig) {
806
- return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", children: chars.map((ch, i) => {
807
- const isTyped = i < typedChars.length;
808
- const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
809
- const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
810
- return /* @__PURE__ */ jsxs(Text, { bold: isTyped, color, underline: !isTyped && error, children: [
811
- display,
812
- " "
813
- ] }, i);
814
- }) });
815
- }
816
- return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", flexDirection: "row", children: chars.map((ch, i) => {
1169
+ const sep = cols < 60 ? " " : " ";
1170
+ return /* @__PURE__ */ jsx6(Box2, { justifyContent: "center", children: chars.map((ch, i) => {
817
1171
  const isTyped = i < typedChars.length;
818
1172
  const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
819
1173
  const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
820
- 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);
821
1178
  }) });
822
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
+ }
823
1202
 
824
1203
  // src/ui/screens/MainMenu.tsx
825
- import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
1204
+ import { jsx as jsx7, jsxs as jsxs2 } from "react/jsx-runtime";
826
1205
  function MainMenu({ cfg }) {
827
- const [selected, setSelected] = useState4(0);
1206
+ const [selected, setSelected] = useState5(0);
828
1207
  const { exit } = useApp();
829
1208
  const nav = useNav();
830
1209
  const audio = useAudioStatus();
1210
+ const t = useStrings();
1211
+ const m = t.mainMenu.items;
831
1212
  const items = [
832
1213
  {
833
1214
  key: "p",
834
- label: "Practice",
835
- hint: cfg.defaultDict ? `start ${cfg.defaultDict}` : "pick a dictionary",
1215
+ label: m.practiceLabel,
1216
+ hint: cfg.defaultDict ? m.practiceHintWith(cfg.defaultDict) : m.practiceHintNone,
836
1217
  run: () => {
837
1218
  if (cfg.defaultDict) {
838
1219
  nav.navigate({
@@ -844,12 +1225,13 @@ function MainMenu({ cfg }) {
844
1225
  }
845
1226
  }
846
1227
  },
847
- { key: "d", label: "Dictionaries", hint: "browse, pull, set default", run: () => nav.navigate({ name: "dict" }) },
848
- { key: "w", label: "Word lookup", hint: "search local dicts", run: () => nav.navigate({ name: "word" }) },
849
- { key: "s", label: "Stats", hint: "history & trends", run: () => nav.navigate({ name: "stats" }) },
850
- { key: "c", label: "Config", hint: "edit preferences", run: () => nav.navigate({ name: "config" }) },
851
- { key: "q", label: "Quit", hint: "Ctrl+C also exits", run: () => exit() }
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() }
852
1233
  ];
1234
+ const labelW = Math.max(...items.map((it) => visibleWidth2(it.label))) + 4;
853
1235
  useInput((input, key) => {
854
1236
  if (key.upArrow) setSelected((i) => (i - 1 + items.length) % items.length);
855
1237
  if (key.downArrow) setSelected((i) => (i + 1) % items.length);
@@ -864,41 +1246,41 @@ function MainMenu({ cfg }) {
864
1246
  }
865
1247
  }
866
1248
  });
867
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
868
- /* @__PURE__ */ jsxs2(Box2, { children: [
869
- /* @__PURE__ */ jsx6(Text2, { bold: true, color: PALETTE.accent, children: "qwerty" }),
870
- /* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: " \xB7 typing practice for the terminal" })
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
+ ] })
871
1256
  ] }),
872
- /* @__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) => {
873
1258
  const active = i === selected;
874
- return /* @__PURE__ */ jsxs2(Box2, { children: [
875
- /* @__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 " : " " }),
876
1262
  /* @__PURE__ */ jsxs2(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
877
1263
  "[",
878
1264
  it.key,
879
1265
  "]"
880
1266
  ] }),
881
- /* @__PURE__ */ jsx6(Text2, { children: " " }),
882
- /* @__PURE__ */ jsx6(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: it.label.padEnd(14) }),
883
- /* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: it.hint })
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 })
884
1273
  ] }, it.key);
885
1274
  }) }),
886
- /* @__PURE__ */ jsx6(Box2, { marginTop: 2, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
887
- "default dict: ",
888
- cfg.defaultDict ?? "(none \u2014 pick one in Dictionaries)"
889
- ] }) }),
890
- /* @__PURE__ */ jsx6(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump" }) }),
891
- audio.warning && /* @__PURE__ */ jsx6(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.warning, children: [
892
- "! ",
893
- audio.warning
894
- ] }) })
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 }) })
895
1278
  ] });
896
1279
  }
897
1280
 
898
1281
  // src/ui/screens/PracticeScreen.tsx
899
- import { useState as useState6, useEffect as useEffect5, useRef as useRef3 } from "react";
900
- import { Box as Box3, Text as Text3, useApp as useApp3, useInput as useInput3 } from "ink";
901
- import BigText2 from "ink-big-text";
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";
902
1284
 
903
1285
  // src/util/shuffle.ts
904
1286
  function shuffle(arr, rng = Math.random) {
@@ -942,25 +1324,25 @@ function buildPlaylist(chapter, mode, seed) {
942
1324
  function initialState(target) {
943
1325
  return { target, typed: "", errorsThisWord: 0 };
944
1326
  }
945
- function reduce(state, ev) {
1327
+ function reduce(state2, ev) {
946
1328
  switch (ev.type) {
947
1329
  case "reset":
948
- return { state: { ...state, typed: "" }, effect: "none" };
1330
+ return { state: { ...state2, typed: "" }, effect: "none" };
949
1331
  case "backspace": {
950
- if (state.typed.length === 0) return { state, effect: "none" };
951
- 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" };
952
1334
  }
953
1335
  case "char": {
954
- const candidate = state.typed + ev.ch;
955
- 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("");
956
1338
  if (candidate === targetUpToCandidate) {
957
- if (candidate.length === state.target.length) {
958
- return { state: { ...state, typed: candidate }, effect: "correct" };
1339
+ if (candidate.length === state2.target.length) {
1340
+ return { state: { ...state2, typed: candidate }, effect: "correct" };
959
1341
  }
960
- return { state: { ...state, typed: candidate }, effect: "progress" };
1342
+ return { state: { ...state2, typed: candidate }, effect: "progress" };
961
1343
  }
962
1344
  return {
963
- state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
1345
+ state: { ...state2, typed: "", errorsThisWord: state2.errorsThisWord + 1 },
964
1346
  effect: "wrong"
965
1347
  };
966
1348
  }
@@ -982,11 +1364,11 @@ function startSession(playlist, now = Date.now()) {
982
1364
  }
983
1365
  function feedSession(session, ev, now = Date.now()) {
984
1366
  if (!session.current) return { session, effect: "none" };
985
- const { state, effect } = reduce(session.current.input, ev);
1367
+ const { state: state2, effect } = reduce(session.current.input, ev);
986
1368
  if (effect === "correct") {
987
1369
  const finished = {
988
- word: state.target,
989
- errors: state.errorsThisWord,
1370
+ word: state2.target,
1371
+ errors: state2.errorsThisWord,
990
1372
  durationMs: now - session.current.wordStartedAt
991
1373
  };
992
1374
  const nextIndex = session.current.wordIndex + 1;
@@ -1013,7 +1395,7 @@ function feedSession(session, ev, now = Date.now()) {
1013
1395
  return {
1014
1396
  session: {
1015
1397
  ...session,
1016
- current: { ...session.current, input: state }
1398
+ current: { ...session.current, input: state2 }
1017
1399
  },
1018
1400
  effect
1019
1401
  };
@@ -1097,24 +1479,24 @@ function topN(book, n) {
1097
1479
  }
1098
1480
 
1099
1481
  // src/ui/hooks/useWordLoop.ts
1100
- import { useEffect as useEffect3, useReducer, useRef, useState as useState5 } from "react";
1482
+ import { useEffect as useEffect3, useReducer, useRef, useState as useState6 } from "react";
1101
1483
  import { useInput as useInput2, useApp as useApp2 } from "ink";
1102
- function reducer(state, action) {
1484
+ function reducer(state2, action) {
1103
1485
  if (action.type === "start") {
1104
1486
  return { session: startSession(action.playlist, action.now), lastEffect: null };
1105
1487
  }
1106
1488
  if (action.type === "skip") {
1107
- const r = skipSession(state.session, action.now);
1489
+ const r = skipSession(state2.session, action.now);
1108
1490
  return { session: r.session, lastEffect: r.effect };
1109
1491
  }
1110
1492
  if (action.type === "event") {
1111
1493
  if (action.key.backspace || action.key.delete) {
1112
- const r = feedSession(state.session, { type: "backspace" }, action.now);
1494
+ const r = feedSession(state2.session, { type: "backspace" }, action.now);
1113
1495
  return { session: r.session, lastEffect: r.effect };
1114
1496
  }
1115
- if (action.input.length === 0) return state;
1116
- let session = state.session;
1117
- let lastEffect = state.lastEffect;
1497
+ if (action.input.length === 0) return state2;
1498
+ let session = state2.session;
1499
+ let lastEffect = state2.lastEffect;
1118
1500
  for (const c of action.input) {
1119
1501
  const r = feedSession(session, { type: "char", ch: c }, action.now);
1120
1502
  session = r.session;
@@ -1123,15 +1505,15 @@ function reducer(state, action) {
1123
1505
  }
1124
1506
  return { session, lastEffect };
1125
1507
  }
1126
- return state;
1508
+ return state2;
1127
1509
  }
1128
1510
  function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled = true }) {
1129
- const [state, dispatch] = useReducer(reducer, void 0, () => ({
1511
+ const [state2, dispatch] = useReducer(reducer, void 0, () => ({
1130
1512
  session: startSession(playlist, Date.now()),
1131
1513
  lastEffect: null
1132
1514
  }));
1133
1515
  const completedRef = useRef(false);
1134
- const [tick, setTick] = useState5(0);
1516
+ const [tick, setTick] = useState6(0);
1135
1517
  const { exit } = useApp2();
1136
1518
  useInput2(
1137
1519
  (input, key) => {
@@ -1164,17 +1546,17 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled =
1164
1546
  { isActive: enabled }
1165
1547
  );
1166
1548
  useEffect3(() => {
1167
- if (state.session.finishedAt !== null && !completedRef.current) {
1549
+ if (state2.session.finishedAt !== null && !completedRef.current) {
1168
1550
  completedRef.current = true;
1169
- onComplete(state.session);
1551
+ onComplete(state2.session);
1170
1552
  }
1171
- }, [state.session, onComplete]);
1553
+ }, [state2.session, onComplete]);
1172
1554
  useEffect3(() => {
1173
- if (state.session.finishedAt !== null) return;
1555
+ if (state2.session.finishedAt !== null) return;
1174
1556
  const id = setInterval(() => setTick((t) => t + 1), 1e3);
1175
1557
  return () => clearInterval(id);
1176
- }, [state.session.finishedAt]);
1177
- return { session: state.session, lastEffect: state.lastEffect, tick };
1558
+ }, [state2.session.finishedAt]);
1559
+ return { session: state2.session, lastEffect: state2.lastEffect, tick };
1178
1560
  }
1179
1561
 
1180
1562
  // src/ui/hooks/useAudio.ts
@@ -1284,6 +1666,43 @@ function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
1284
1666
  return out;
1285
1667
  }
1286
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
+
1287
1706
  // src/ui/hooks/useSessionPersistence.ts
1288
1707
  function useSessionPersistence(meta) {
1289
1708
  return useCallback3(
@@ -1299,6 +1718,15 @@ function useSessionPersistence(meta) {
1299
1718
  perWordErrors: summary.perWordErrors
1300
1719
  };
1301
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
+ });
1302
1730
  const dirty = Object.entries(summary.perWordErrors).filter(([, n]) => n > 0);
1303
1731
  if (dirty.length === 0) return;
1304
1732
  let book = await loadMistakes();
@@ -1310,14 +1738,14 @@ function useSessionPersistence(meta) {
1310
1738
  }
1311
1739
 
1312
1740
  // src/ui/screens/PracticeScreen.tsx
1313
- import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
1741
+ import { jsx as jsx8, jsxs as jsxs3 } from "react/jsx-runtime";
1314
1742
  function PracticeScreen({ params }) {
1315
1743
  const { dictId, chapterIndex, mode } = params;
1316
1744
  const { cfg } = useAppState();
1317
- const nav = useNav();
1318
- const [phase, setPhase] = useState6("loading");
1319
- const [loaded, setLoaded] = useState6(null);
1320
- const [errorMsg, setErrorMsg] = useState6(null);
1745
+ const t = useStrings();
1746
+ const [phase, setPhase] = useState7("loading");
1747
+ const [loaded, setLoaded] = useState7(null);
1748
+ const [errorMsg, setErrorMsg] = useState7(null);
1321
1749
  useEffect5(() => {
1322
1750
  let cancelled = false;
1323
1751
  setPhase("loading");
@@ -1332,7 +1760,7 @@ function PracticeScreen({ params }) {
1332
1760
  if (cancelled) return;
1333
1761
  const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
1334
1762
  if (reviewWords.length === 0) {
1335
- setErrorMsg("No mistakes to review yet. Practice some chapters first.");
1763
+ setErrorMsg(t.practice.errors.noMistakes);
1336
1764
  setPhase("error");
1337
1765
  return;
1338
1766
  }
@@ -1342,7 +1770,7 @@ function PracticeScreen({ params }) {
1342
1770
  }
1343
1771
  const chapters = chunkChapters(words, cfg.chapterSize);
1344
1772
  if (chapters.length === 0) {
1345
- setErrorMsg(`Dictionary ${dictId} is empty.`);
1773
+ setErrorMsg(t.practice.errors.dictEmpty(dictId));
1346
1774
  setPhase("error");
1347
1775
  return;
1348
1776
  }
@@ -1359,15 +1787,15 @@ function PracticeScreen({ params }) {
1359
1787
  return () => {
1360
1788
  cancelled = true;
1361
1789
  };
1362
- }, [dictId, chapterIndex, mode, cfg.chapterSize]);
1790
+ }, [dictId, chapterIndex, mode, cfg.chapterSize, t]);
1363
1791
  if (phase === "loading") {
1364
- return /* @__PURE__ */ jsx7(Centered, { text: "loading\u2026", color: PALETTE.muted });
1792
+ return /* @__PURE__ */ jsx8(Centered, { text: t.practice.loading, color: PALETTE.muted });
1365
1793
  }
1366
1794
  if (phase === "error") {
1367
- return /* @__PURE__ */ jsx7(ErrorView, { msg: errorMsg ?? "Unknown error" });
1795
+ return /* @__PURE__ */ jsx8(ErrorView, { msg: errorMsg ?? t.practice.errors.unknown });
1368
1796
  }
1369
1797
  if (!loaded) return null;
1370
- return /* @__PURE__ */ jsx7(
1798
+ return /* @__PURE__ */ jsx8(
1371
1799
  PracticeRunner,
1372
1800
  {
1373
1801
  params,
@@ -1468,9 +1896,9 @@ function PracticeRunner({
1468
1896
  },
1469
1897
  { isActive: phase === "summary" }
1470
1898
  );
1471
- if (phase === "paused") return /* @__PURE__ */ jsx7(PausedView, {});
1899
+ if (phase === "paused") return /* @__PURE__ */ jsx8(PausedView, {});
1472
1900
  if (phase === "summary") {
1473
- return /* @__PURE__ */ jsx7(
1901
+ return /* @__PURE__ */ jsx8(
1474
1902
  SummaryView,
1475
1903
  {
1476
1904
  dictId,
@@ -1488,7 +1916,7 @@ function PracticeRunner({
1488
1916
  const errors = session.results.reduce((a, r) => a + r.errors, 0);
1489
1917
  const minutes = elapsedMs / 6e4;
1490
1918
  const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
1491
- return /* @__PURE__ */ jsx7(
1919
+ return /* @__PURE__ */ jsx8(
1492
1920
  TypingLayout,
1493
1921
  {
1494
1922
  dictId,
@@ -1522,9 +1950,10 @@ function fmtTime(ms) {
1522
1950
  return `${m}:${String(s).padStart(2, "0")}`;
1523
1951
  }
1524
1952
  function TypingLayout(props) {
1953
+ const t = useStrings();
1525
1954
  const progressFrac = props.total === 0 ? 0 : props.completed / props.total;
1526
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1527
- /* @__PURE__ */ jsx7(
1955
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1956
+ /* @__PURE__ */ jsx8(
1528
1957
  StatusBar,
1529
1958
  {
1530
1959
  dictId: props.dictId,
@@ -1537,8 +1966,8 @@ function TypingLayout(props) {
1537
1966
  elapsedMs: props.elapsedMs
1538
1967
  }
1539
1968
  ),
1540
- /* @__PURE__ */ jsxs3(Box3, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
1541
- /* @__PURE__ */ jsx7(
1969
+ /* @__PURE__ */ jsxs3(Box4, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
1970
+ /* @__PURE__ */ jsx8(
1542
1971
  BigWord,
1543
1972
  {
1544
1973
  target: props.target,
@@ -1547,12 +1976,12 @@ function TypingLayout(props) {
1547
1976
  hideTarget: props.hideTarget
1548
1977
  }
1549
1978
  ),
1550
- props.phonetic && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
1551
- props.translation.length > 0 && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((t, i) => /* @__PURE__ */ jsx7(Text3, { color: PALETTE.primary, children: t }, i)) })
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)) })
1552
1981
  ] }),
1553
- /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1554
- /* @__PURE__ */ jsx7(ProgressBar, { frac: progressFrac }),
1555
- /* @__PURE__ */ jsx7(Box3, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
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: [
1556
1985
  props.completed,
1557
1986
  "/",
1558
1987
  props.total,
@@ -1560,21 +1989,27 @@ function TypingLayout(props) {
1560
1989
  fmtTime(props.elapsedMs),
1561
1990
  " \xB7 ",
1562
1991
  props.wpm,
1563
- " wpm \xB7 ",
1992
+ " ",
1993
+ t.practice.statCards.wpm,
1994
+ " \xB7 ",
1564
1995
  props.errors,
1565
- " errors"
1996
+ " ",
1997
+ t.practice.statCards.errors
1566
1998
  ] }) }),
1567
- /* @__PURE__ */ jsx7(Box3, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay \xB7 Ctrl+C quit" }) })
1999
+ /* @__PURE__ */ jsx8(Box4, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.footers.typing }) })
1568
2000
  ] })
1569
2001
  ] });
1570
2002
  }
1571
2003
  function StatusBar(props) {
1572
- const left = props.mode === "review" ? `${props.dictId} \xB7 review \xB7 ${props.accent}` : `${props.dictId} \xB7 ch ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode} \xB7 ${props.accent}`;
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}`;
1573
2008
  const right = `${props.completed}/${props.total} \xB7 ${fmtTime(props.elapsedMs)}`;
1574
- return /* @__PURE__ */ jsxs3(Box3, { children: [
1575
- /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: left }),
1576
- /* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
1577
- /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: right })
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 })
1578
2013
  ] });
1579
2014
  }
1580
2015
  function ProgressBar({ frac }) {
@@ -1582,22 +2017,27 @@ function ProgressBar({ frac }) {
1582
2017
  const width = Math.max(20, Math.min(72, cols - 16));
1583
2018
  const filled = Math.round(width * Math.max(0, Math.min(1, frac)));
1584
2019
  const empty = width - filled;
1585
- return /* @__PURE__ */ jsxs3(Box3, { justifyContent: "center", children: [
1586
- /* @__PURE__ */ jsx7(Text3, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
1587
- /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
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) })
1588
2023
  ] });
1589
2024
  }
1590
2025
  function PausedView() {
1591
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
1592
- /* @__PURE__ */ jsx7(BigText2, { text: "paused", font: "tiny", colors: [PALETTE.warning] }),
1593
- /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[r] resume \xB7 [q] quit to menu" }) })
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 }) })
1594
2030
  ] });
1595
2031
  }
1596
2032
  function ErrorView({ msg }) {
1597
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
1598
- /* @__PURE__ */ jsx7(Text3, { color: PALETTE.error, children: msg }),
1599
- /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[q] back to menu" }) }),
1600
- /* @__PURE__ */ jsx7(BackKey, {})
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, {})
1601
2041
  ] });
1602
2042
  }
1603
2043
  function BackKey() {
@@ -1608,7 +2048,7 @@ function BackKey() {
1608
2048
  return null;
1609
2049
  }
1610
2050
  function Centered({ text, color }) {
1611
- 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 }) });
1612
2052
  }
1613
2053
  function SummaryView(props) {
1614
2054
  const { summary } = props;
@@ -1617,52 +2057,52 @@ function SummaryView(props) {
1617
2057
  const errorWords = Object.keys(summary.perWordErrors).length;
1618
2058
  const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
1619
2059
  const accPct = Math.round(acc * 1e3) / 10;
1620
- const subtitle = props.mode === "review" ? `${props.dictId} \xB7 review` : `${props.dictId} \xB7 chapter ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode}`;
1621
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
1622
- /* @__PURE__ */ jsx7(BigText2, { text: "complete", font: "tiny", colors: [PALETTE.success] }),
1623
- /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: subtitle }) }),
1624
- /* @__PURE__ */ jsxs3(Box3, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
1625
- /* @__PURE__ */ jsx7(StatCard, { label: "words", value: String(summary.wordCount), color: PALETTE.text }),
1626
- /* @__PURE__ */ jsx7(
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(
1627
2069
  StatCard,
1628
2070
  {
1629
- label: "errors",
2071
+ label: t.practice.statCards.errors,
1630
2072
  value: String(summary.errors),
1631
2073
  color: summary.errors > 0 ? PALETTE.error : PALETTE.muted
1632
2074
  }
1633
2075
  ),
1634
- /* @__PURE__ */ jsx7(StatCard, { label: "wpm", value: String(wpm), color: PALETTE.accent }),
1635
- /* @__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 })
1636
2078
  ] }),
1637
- /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
1638
- "elapsed ",
1639
- fmtTime(summary.durationMs)
1640
- ] }) }),
1641
- /* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
1642
- /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[n] next chapter \xB7 [m] review mistakes \xB7 [q] back to menu" }) })
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 }) })
1643
2082
  ] });
1644
2083
  }
1645
2084
  function StatCard({ label, value, color }) {
1646
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", marginX: 2, children: [
1647
- /* @__PURE__ */ jsx7(BigText2, { text: value, font: "tiny", colors: [color] }),
1648
- /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: label })
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 })
1649
2088
  ] });
1650
2089
  }
1651
2090
 
1652
2091
  // src/ui/screens/DictBrowser.tsx
1653
- import { useEffect as useEffect6, useState as useState7 } from "react";
1654
- import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
1655
- import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
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";
1656
2095
  function DictBrowser({ params }) {
1657
2096
  const nav = useNav();
1658
2097
  const { cfg, setCfg } = useAppState();
1659
- const [rows, setRows] = useState7([]);
1660
- const [loading, setLoading] = useState7(true);
1661
- const [selected, setSelected] = useState7(0);
1662
- const [filter, setFilter] = useState7("");
1663
- const [filterFocus, setFilterFocus] = useState7(false);
1664
- const [pending, setPending] = useState7(null);
1665
- const [tick, setTick] = useState7(0);
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);
1666
2106
  const refresh = async () => {
1667
2107
  const reg = await loadRegistry();
1668
2108
  const flagged = await Promise.all(
@@ -1730,7 +2170,7 @@ function DictBrowser({ params }) {
1730
2170
  try {
1731
2171
  await removeDictionary(current.entry.id);
1732
2172
  setPending(null);
1733
- setTick((t) => t + 1);
2173
+ setTick((n) => n + 1);
1734
2174
  } catch (err) {
1735
2175
  setPending({ kind: "error", id: current.entry.id, msg: err.message });
1736
2176
  }
@@ -1743,7 +2183,7 @@ function DictBrowser({ params }) {
1743
2183
  try {
1744
2184
  await pullDictionary(current.entry.id);
1745
2185
  setPending(null);
1746
- setTick((t) => t + 1);
2186
+ setTick((n) => n + 1);
1747
2187
  } catch (err) {
1748
2188
  setPending({ kind: "error", id: current.entry.id, msg: err.message });
1749
2189
  }
@@ -1751,88 +2191,67 @@ function DictBrowser({ params }) {
1751
2191
  }
1752
2192
  });
1753
2193
  if (loading) {
1754
- return /* @__PURE__ */ jsx8(Box4, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: "loading dictionaries\u2026" }) });
2194
+ return /* @__PURE__ */ jsx9(Box5, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: t.dict.loading }) });
1755
2195
  }
1756
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1757
- /* @__PURE__ */ jsxs4(Box4, { children: [
1758
- /* @__PURE__ */ jsx8(Text4, { bold: true, color: PALETTE.accent, children: "Dictionaries" }),
1759
- /* @__PURE__ */ jsx8(Box4, { flexGrow: 1 }),
1760
- /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: filterFocus ? `/ ${filter}_` : filter ? `/ ${filter}` : `${filtered.length} entries` })
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) })
1761
2201
  ] }),
1762
- /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexGrow: 1, children: [
1763
- /* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "60%", children: filtered.slice(Math.max(0, safeSelected - 8), safeSelected + 16).map((row, vi) => {
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) => {
1764
2204
  const i = Math.max(0, safeSelected - 8) + vi;
1765
2205
  const active = i === safeSelected;
1766
2206
  const isDefault = cfg.defaultDict === row.entry.id;
1767
- return /* @__PURE__ */ jsxs4(Box4, { children: [
1768
- /* @__PURE__ */ jsx8(Text4, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1769
- /* @__PURE__ */ jsx8(Text4, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }),
1770
- /* @__PURE__ */ jsx8(Text4, { children: " " }),
1771
- /* @__PURE__ */ jsx8(Text4, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }),
1772
- /* @__PURE__ */ jsx8(Text4, { children: " " }),
1773
- /* @__PURE__ */ jsx8(Text4, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: row.entry.id.slice(0, 14).padEnd(15) }),
1774
- /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) })
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) }) })
1775
2213
  ] }, row.entry.id);
1776
2214
  }) }),
1777
- /* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "40%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs4(Fragment2, { children: [
1778
- /* @__PURE__ */ jsx8(Text4, { bold: true, color: PALETTE.text, children: current.entry.name }),
1779
- /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: current.entry.id }),
1780
- /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
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: [
1781
2219
  current.entry.language,
1782
2220
  " \xB7 ",
1783
- current.entry.category,
1784
- " \xB7 ",
1785
- current.entry.length,
1786
- " words"
2221
+ current.entry.category
1787
2222
  ] }) }),
1788
- /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.primary, children: current.entry.description || "(no description)" }) }),
1789
- current.entry.tags.length > 0 && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
1790
- "tags: ",
1791
- current.entry.tags.join(", ")
1792
- ] }) }),
1793
- /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
1794
- /* @__PURE__ */ jsx8(Text4, { color: current.local ? PALETTE.accent : PALETTE.muted, children: current.local ? "local \u2713" : "not local" }),
1795
- cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx8(Text4, { color: PALETTE.success, children: " \xB7 default \u2605" })
1796
- ] })
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 }) })
1797
2228
  ] }) })
1798
2229
  ] }),
1799
- pending && /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
1800
- pending.kind === "pulling" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.warning, children: [
1801
- "pulling ",
1802
- pending.id,
1803
- "\u2026"
1804
- ] }),
1805
- pending.kind === "removing" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.warning, children: [
1806
- "removing ",
1807
- pending.id,
1808
- "\u2026"
1809
- ] }),
1810
- pending.kind === "error" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.error, children: [
1811
- "error on ",
1812
- pending.id,
1813
- ": ",
1814
- pending.msg
1815
- ] })
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) })
1816
2234
  ] }),
1817
- /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: "\u2191/\u2193 select \xB7 Enter set default \xB7 p practice \xB7 u pull \xB7 r remove \xB7 / filter \xB7 Esc back" }) })
2235
+ /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: t.dict.footer }) })
1818
2236
  ] });
1819
2237
  }
1820
2238
 
1821
2239
  // src/ui/screens/ConfigEditor.tsx
1822
- import { useState as useState8 } from "react";
1823
- import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
1824
- import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
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";
1825
2243
  var FIELDS = [
1826
- { kind: "dictRef", path: "defaultDict", label: "default dict" },
1827
- { kind: "enum", path: "defaultMode", label: "default mode", options: ["order", "dictation", "review", "random", "loop"] },
1828
- { kind: "enum", path: "accent", label: "accent", options: ["us", "uk"] },
1829
- { kind: "enum", path: "mirror", label: "mirror", options: ["jsdelivr", "github"] },
1830
- { kind: "int", path: "chapterSize", label: "chapter size", min: 1, max: 200 },
1831
- { kind: "bool", path: "autoplayPronunciation", label: "autoplay pronunciation" },
1832
- { kind: "bool", path: "sounds.master", label: "sounds master" },
1833
- { kind: "bool", path: "sounds.keystroke", label: "sounds keystroke" },
1834
- { kind: "bool", path: "sounds.feedback", label: "sounds feedback" },
1835
- { kind: "string", path: "sounds.keySoundName", label: "sounds key sound" }
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" }
1836
2255
  ];
1837
2256
  function getByPath2(cfg, path) {
1838
2257
  return path.split(".").reduce((acc, k) => {
@@ -1843,10 +2262,11 @@ function getByPath2(cfg, path) {
1843
2262
  function ConfigEditor() {
1844
2263
  const nav = useNav();
1845
2264
  const { cfg, setCfg } = useAppState();
1846
- const [selected, setSelected] = useState8(0);
1847
- const [editing, setEditing] = useState8(false);
1848
- const [draft, setDraft] = useState8("");
1849
- const [error, setError] = useState8(null);
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);
1850
2270
  const field = FIELDS[selected];
1851
2271
  const currentValue = getByPath2(cfg, field.path);
1852
2272
  const commit = async (raw) => {
@@ -1929,50 +2349,57 @@ function ConfigEditor() {
1929
2349
  setError(null);
1930
2350
  }
1931
2351
  });
1932
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1933
- /* @__PURE__ */ jsx9(Text5, { bold: true, color: PALETTE.accent, children: "Config" }),
1934
- /* @__PURE__ */ jsx9(Box5, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
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) => {
1935
2356
  const active = i === selected;
1936
2357
  const value = getByPath2(cfg, f.path);
1937
- const display = renderValue(f, value, active && editing ? draft : null);
1938
- return /* @__PURE__ */ jsxs5(Box5, { children: [
1939
- /* @__PURE__ */ jsx9(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1940
- /* @__PURE__ */ jsx9(Text5, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: f.label.padEnd(28) }),
1941
- /* @__PURE__ */ jsx9(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
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 })
1942
2368
  ] }, f.path);
1943
2369
  }) }),
1944
- 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: [
1945
2371
  "! ",
1946
2372
  error
1947
2373
  ] }) }),
1948
- /* @__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) }) })
1949
2375
  ] });
1950
2376
  }
1951
- function renderValue(field, value, draft) {
2377
+ function renderValue(field, value, draft, t) {
1952
2378
  if (draft !== null) return `${draft}_`;
1953
- if (field.kind === "bool") return value ? "\u2713 on" : "\u2717 off";
1954
- if (field.kind === "dictRef") return String(value ?? "(none)");
2379
+ if (field.kind === "bool") return value ? `\u2713 ${t.common.on}` : `\u2717 ${t.common.off}`;
2380
+ if (field.kind === "dictRef") return String(value ?? "\u2014");
1955
2381
  if (field.kind === "enum") return `< ${value} >`;
1956
2382
  return String(value ?? "");
1957
2383
  }
1958
- function hintFor(field, editing) {
1959
- if (editing) return "type to edit \xB7 Enter save \xB7 Esc cancel";
1960
- if (field.kind === "bool") return "space toggle \xB7 \u2191/\u2193 move \xB7 Esc back";
1961
- if (field.kind === "enum") return "\u2190/\u2192 cycle \xB7 \u2191/\u2193 move \xB7 Esc back";
1962
- if (field.kind === "dictRef") return "Enter pick dict \xB7 \u2191/\u2193 move \xB7 Esc back";
1963
- return "Enter edit \xB7 \u2191/\u2193 move \xB7 Esc back";
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;
1964
2390
  }
1965
2391
 
1966
2392
  // src/ui/screens/StatsViewer.tsx
1967
- import { useEffect as useEffect7, useState as useState9 } from "react";
1968
- import { Box as Box6, Text as Text6, useInput as useInput6 } from "ink";
1969
- import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
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";
1970
2396
  var DAY_WINDOWS = [7, 14, 30, 90];
1971
2397
  function StatsViewer() {
1972
2398
  const nav = useNav();
1973
- const [sessions, setSessions] = useState9(null);
1974
- const [book, setBook] = useState9(null);
1975
- 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);
1976
2403
  useEffect7(() => {
1977
2404
  void (async () => {
1978
2405
  const [s, b] = await Promise.all([loadSessions(), loadMistakes()]);
@@ -1989,13 +2416,16 @@ function StatsViewer() {
1989
2416
  if (input === "N") setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
1990
2417
  });
1991
2418
  if (!sessions || !book) {
1992
- return /* @__PURE__ */ jsx10(Box6, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "loading stats\u2026" }) });
2419
+ return /* @__PURE__ */ jsx11(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.loading }) });
1993
2420
  }
1994
2421
  if (sessions.length === 0) {
1995
- return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
1996
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "No practice history yet." }),
1997
- /* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "Run a practice session first." }) }),
1998
- /* @__PURE__ */ jsx10(Box6, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "[q] back" }) })
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
+ ] }) })
1999
2429
  ] });
2000
2430
  }
2001
2431
  const days = DAY_WINDOWS[windowIdx];
@@ -2012,53 +2442,49 @@ function StatsViewer() {
2012
2442
  const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
2013
2443
  const recent = sessions.slice(-5).reverse();
2014
2444
  const top = topN(book, 8);
2015
- return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2016
- /* @__PURE__ */ jsx10(Text6, { bold: true, color: PALETTE.accent, children: "Stats" }),
2017
- /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
2018
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "lifetime" }),
2019
- /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, children: [
2020
- /* @__PURE__ */ jsx10(Stat, { label: "sessions", value: String(sessions.length) }),
2021
- /* @__PURE__ */ jsx10(Stat, { label: "words", value: String(totalWords) }),
2022
- /* @__PURE__ */ jsx10(Stat, { label: "errors", value: String(totalErrors) }),
2023
- /* @__PURE__ */ jsx10(Stat, { label: "wpm", value: String(overallWpm), accent: true }),
2024
- /* @__PURE__ */ jsx10(Stat, { label: "accuracy", value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
2025
- /* @__PURE__ */ jsx10(Stat, { label: "streak", value: `${streak}d`, accent: true })
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 })
2026
2456
  ] })
2027
2457
  ] }),
2028
- /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
2029
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2030
- "last ",
2031
- days,
2032
- " days (n / N to cycle window)"
2033
- ] }),
2034
- /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
2035
- /* @__PURE__ */ jsxs6(Box6, { children: [
2036
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "wpm ".padEnd(10) }),
2037
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.wpm)) }),
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)) }),
2038
2464
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2039
2465
  " max ",
2040
2466
  Math.round(Math.max(...buckets.map((b) => b.wpm)))
2041
2467
  ] })
2042
2468
  ] }),
2043
- /* @__PURE__ */ jsxs6(Box6, { children: [
2044
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "accuracy".padEnd(10) }),
2045
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.accuracy * 100)) })
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)) })
2046
2472
  ] }),
2047
- /* @__PURE__ */ jsxs6(Box6, { children: [
2048
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "sessions".padEnd(10) }),
2049
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.sessions)) })
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)) })
2050
2476
  ] })
2051
2477
  ] })
2052
2478
  ] }),
2053
- /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
2054
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "recent sessions" }),
2055
- recent.map((s, i) => /* @__PURE__ */ jsxs6(Box6, { children: [
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: [
2056
2482
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2057
2483
  " ",
2058
2484
  s.ts.replace("T", " ").slice(0, 16),
2059
2485
  " "
2060
2486
  ] }),
2061
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
2487
+ /* @__PURE__ */ jsx11(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
2062
2488
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2063
2489
  " ",
2064
2490
  "ch",
@@ -2077,37 +2503,37 @@ function StatsViewer() {
2077
2503
  ] })
2078
2504
  ] }, i))
2079
2505
  ] }),
2080
- top.length > 0 && /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
2081
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "top mistakes" }),
2082
- top.map(([word, entry]) => /* @__PURE__ */ jsxs6(Box6, { children: [
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: [
2083
2509
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.error, children: [
2084
2510
  " ",
2085
2511
  String(entry.count).padStart(3),
2086
2512
  " "
2087
2513
  ] }),
2088
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: word.padEnd(20) }),
2089
- /* @__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(", ") })
2090
2516
  ] }, word))
2091
2517
  ] }),
2092
- /* @__PURE__ */ jsx10(Box6, { flexGrow: 1 }),
2093
- /* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "n / N cycle window \xB7 q back" }) })
2518
+ /* @__PURE__ */ jsx11(Box7, { flexGrow: 1 }),
2519
+ /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.footer }) })
2094
2520
  ] });
2095
2521
  }
2096
2522
  function Stat({ label, value, accent = false }) {
2097
- return /* @__PURE__ */ jsxs6(Box6, { marginRight: 3, children: [
2523
+ return /* @__PURE__ */ jsxs6(Box7, { marginRight: 3, children: [
2098
2524
  /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2099
2525
  label,
2100
2526
  " "
2101
2527
  ] }),
2102
- /* @__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 })
2103
2529
  ] });
2104
2530
  }
2105
2531
 
2106
2532
  // src/ui/screens/WordLookup.tsx
2107
- import { useEffect as useEffect8, useState as useState10 } from "react";
2108
- import { Box as Box7, Text as Text7, useInput as useInput7 } from "ink";
2533
+ import { useEffect as useEffect8, useState as useState11 } from "react";
2534
+ import { Box as Box8, Text as Text7, useInput as useInput7 } from "ink";
2109
2535
  import { readdir } from "fs/promises";
2110
- 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";
2111
2537
  async function listLocalDictIds() {
2112
2538
  try {
2113
2539
  const files = await readdir(paths.dictsDir);
@@ -2118,11 +2544,12 @@ async function listLocalDictIds() {
2118
2544
  }
2119
2545
  function WordLookup() {
2120
2546
  const nav = useNav();
2121
- const [query, setQuery] = useState10("");
2122
- const [allWords, setAllWords] = useState10([]);
2123
- const [book, setBook] = useState10({});
2124
- const [loading, setLoading] = useState10(true);
2125
- const [selected, setSelected] = useState10(0);
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);
2126
2553
  useEffect8(() => {
2127
2554
  void (async () => {
2128
2555
  const ids = await listLocalDictIds();
@@ -2163,49 +2590,45 @@ function WordLookup() {
2163
2590
  }
2164
2591
  });
2165
2592
  if (loading) {
2166
- return /* @__PURE__ */ jsx11(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "indexing local dictionaries\u2026" }) });
2593
+ return /* @__PURE__ */ jsx12(Box8, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.indexing }) });
2167
2594
  }
2168
2595
  if (allWords.length === 0) {
2169
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2170
- /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "No local dictionaries." }),
2171
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "Pull one in Dictionaries first." }) }),
2172
- /* @__PURE__ */ jsx11(Box7, { marginTop: 2, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "[Esc] back" }) })
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
+ ] }) })
2173
2603
  ] });
2174
2604
  }
2175
2605
  const current = filtered[selected];
2176
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2177
- /* @__PURE__ */ jsxs7(Box7, { children: [
2178
- /* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.accent, children: "Word lookup" }),
2179
- /* @__PURE__ */ jsx11(Box7, { flexGrow: 1 }),
2180
- /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2181
- allWords.length,
2182
- " words across local dicts"
2183
- ] })
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) })
2184
2611
  ] }),
2185
- /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
2186
- /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "> " }),
2187
- /* @__PURE__ */ jsx11(Text7, { color: PALETTE.text, children: query }),
2188
- /* @__PURE__ */ jsx11(Text7, { color: PALETTE.accent, children: "_" })
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: "_" })
2189
2616
  ] }),
2190
- /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexGrow: 1, children: [
2191
- /* @__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: [
2192
2619
  filtered.map((h, i) => {
2193
2620
  const active = i === selected;
2194
- return /* @__PURE__ */ jsxs7(Box7, { children: [
2195
- /* @__PURE__ */ jsx11(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2196
- /* @__PURE__ */ jsx11(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: h.word.name.padEnd(20) }),
2197
- /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: h.dictId })
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 })
2198
2625
  ] }, `${h.dictId}-${h.word.name}-${i}`);
2199
2626
  }),
2200
- filtered.length === 0 && q && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2201
- 'no matches for "',
2202
- query,
2203
- '"'
2204
- ] })
2627
+ filtered.length === 0 && q && /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.noMatches(query) })
2205
2628
  ] }),
2206
- /* @__PURE__ */ jsx11(Box7, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs7(Fragment3, { children: [
2207
- /* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.text, children: current.word.name }),
2208
- /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
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: [
2209
2632
  current.word.usphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2210
2633
  "US /",
2211
2634
  current.word.usphone,
@@ -2217,59 +2640,109 @@ function WordLookup() {
2217
2640
  "/"
2218
2641
  ] })
2219
2642
  ] }),
2220
- /* @__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: [
2221
2644
  "\xB7 ",
2222
- t
2645
+ tr
2223
2646
  ] }, i)) }),
2224
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2225
- "in: ",
2226
- current.dictId
2227
- ] }) }),
2228
- book[current.word.name] && /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
2229
- /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.error, children: [
2230
- "mistakes: ",
2231
- book[current.word.name].count
2232
- ] }),
2233
- /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2234
- " ",
2235
- "(last ",
2236
- book[current.word.name].lastSeen.slice(0, 10),
2237
- ")"
2238
- ] })
2239
- ] })
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)) }) })
2240
2649
  ] }) })
2241
2650
  ] }),
2242
- /* @__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 }) })
2243
2652
  ] });
2244
2653
  }
2245
2654
 
2246
2655
  // src/ui/App.tsx
2247
- import { jsx as jsx12 } from "react/jsx-runtime";
2656
+ import { jsx as jsx13 } from "react/jsx-runtime";
2248
2657
  function App({ initial, initialCfg }) {
2249
- return /* @__PURE__ */ jsx12(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx12(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx12(NavProvider, { initial, children: /* @__PURE__ */ jsx12(Fullscreen, { children: /* @__PURE__ */ jsx12(Router, {}) }) }) }) });
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;
2250
2670
  }
2251
2671
  function Router() {
2252
2672
  const nav = useNav();
2253
2673
  const { cfg } = useAppState();
2254
2674
  const { exit } = useApp4();
2255
- useInput8((input, key) => {
2256
- if (key.ctrl && input === "c") exit();
2675
+ const lastKeyRef = useRef4(null);
2676
+ useInput8((input, key2) => {
2677
+ if (key2.ctrl && input === "c") exit();
2257
2678
  });
2258
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
+ }
2259
2685
  switch (frame.name) {
2260
2686
  case "main":
2261
- return /* @__PURE__ */ jsx12(MainMenu, { cfg });
2687
+ return /* @__PURE__ */ jsx13(MainMenu, { cfg });
2262
2688
  case "practice":
2263
- return /* @__PURE__ */ jsx12(PracticeScreen, { params: frame.params });
2689
+ return /* @__PURE__ */ jsx13(PracticeScreen, { params: frame.params });
2264
2690
  case "dict":
2265
- return /* @__PURE__ */ jsx12(DictBrowser, { params: frame.params });
2691
+ return /* @__PURE__ */ jsx13(DictBrowser, { params: frame.params });
2266
2692
  case "config":
2267
- return /* @__PURE__ */ jsx12(ConfigEditor, {});
2693
+ return /* @__PURE__ */ jsx13(ConfigEditor, {});
2268
2694
  case "stats":
2269
- return /* @__PURE__ */ jsx12(StatsViewer, {});
2695
+ return /* @__PURE__ */ jsx13(StatsViewer, {});
2270
2696
  case "word":
2271
- 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}`);
2272
2742
  }
2743
+ console.log();
2744
+ console.log(chalk3.dim(` ${t.report.farewell}`));
2745
+ console.log();
2273
2746
  }
2274
2747
 
2275
2748
  // src/commands/practice.ts
@@ -2279,24 +2752,25 @@ function isMode(v) {
2279
2752
  }
2280
2753
  async function runPractice(dictIdArg, options) {
2281
2754
  if (!process.stdout.isTTY) {
2282
- console.error(chalk3.red("Practice requires an interactive TTY."));
2755
+ console.error(chalk4.red("Practice requires an interactive TTY."));
2283
2756
  process.exitCode = 1;
2284
2757
  return;
2285
2758
  }
2286
2759
  const cfg = await loadConfig();
2287
2760
  const dictId = dictIdArg ?? cfg.defaultDict;
2288
2761
  if (!dictId) {
2289
- 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."));
2290
2763
  process.exitCode = 1;
2291
2764
  return;
2292
2765
  }
2293
2766
  const mode = options.mode ?? cfg.defaultMode;
2294
2767
  if (!isMode(mode)) {
2295
- console.error(chalk3.red(`Invalid mode "${mode}". Valid: ${MODES.join(", ")}`));
2768
+ console.error(chalk4.red(`Invalid mode "${mode}". Valid: ${MODES.join(", ")}`));
2296
2769
  process.exitCode = 1;
2297
2770
  return;
2298
2771
  }
2299
2772
  const chapterIndex = Math.max(0, Number(options.chapter ?? 1) - 1);
2773
+ start();
2300
2774
  const { waitUntilExit } = render(
2301
2775
  createElement(App, {
2302
2776
  initial: { name: "practice", params: { dictId, chapterIndex, mode } },
@@ -2305,6 +2779,9 @@ async function runPractice(dictIdArg, options) {
2305
2779
  { patchConsole: false, exitOnCtrlC: false }
2306
2780
  );
2307
2781
  await waitUntilExit();
2782
+ ensureMainScreen();
2783
+ const { lang, t } = pickStrings(cfg.language);
2784
+ printSessionReport(report(), t, lang);
2308
2785
  }
2309
2786
  function buildPracticeCommand() {
2310
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) => {
@@ -2314,7 +2791,7 @@ function buildPracticeCommand() {
2314
2791
 
2315
2792
  // src/commands/stats.ts
2316
2793
  import { Command as Command4 } from "commander";
2317
- import chalk4 from "chalk";
2794
+ import chalk5 from "chalk";
2318
2795
  function buildStatsCommand() {
2319
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) => {
2320
2797
  const days = Math.max(1, Number(opts.days) || 14);
@@ -2322,7 +2799,7 @@ function buildStatsCommand() {
2322
2799
  const sessions = await loadSessions();
2323
2800
  const book = await loadMistakes();
2324
2801
  if (sessions.length === 0) {
2325
- 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."));
2326
2803
  return;
2327
2804
  }
2328
2805
  const buckets = dailyBuckets(sessions, days);
@@ -2336,33 +2813,33 @@ function buildStatsCommand() {
2336
2813
  );
2337
2814
  const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
2338
2815
  const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
2339
- console.log(chalk4.bold("\nLifetime"));
2340
- console.log(` ${chalk4.dim("sessions")} ${sessions.length} ${chalk4.dim("words")} ${totalWords} ${chalk4.dim("errors")} ${totalErrors}`);
2341
- console.log(` ${chalk4.dim("avg wpm")} ${overallWpm} ${chalk4.dim("avg accuracy")} ${Math.round(overallAcc * 1e3) / 10}% ${chalk4.dim("streak")} ${chalk4.bold(streak)}d`);
2342
- 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(`
2343
2820
  Last ${days} days`));
2344
- console.log(` ${chalk4.dim("wpm ")} ${sparkline(buckets.map((b) => b.wpm))} ${chalk4.dim("max")} ${Math.round(Math.max(...buckets.map((b) => b.wpm)))}`);
2345
- 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)))}%`);
2346
- 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))}`);
2347
2824
  const recent = sessions.slice(-5).reverse();
2348
- console.log(chalk4.bold("\nLast 5 sessions"));
2825
+ console.log(chalk5.bold("\nLast 5 sessions"));
2349
2826
  for (const s of recent) {
2350
2827
  const wpm = computeWPM(s);
2351
2828
  const acc = Math.round(accuracy(s) * 1e3) / 10;
2352
2829
  console.log(
2353
- ` ${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}%`
2354
2831
  );
2355
2832
  }
2356
2833
  const top = topN(book, topCount);
2357
2834
  if (top.length > 0) {
2358
- console.log(chalk4.bold(`
2835
+ console.log(chalk5.bold(`
2359
2836
  Top ${top.length} mistakes`));
2360
2837
  for (const [word, entry] of top) {
2361
- 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(", "))}`);
2362
2839
  }
2363
2840
  } else {
2364
- console.log(chalk4.bold("\nTop mistakes"));
2365
- console.log(chalk4.dim(" none \u2014 keep going"));
2841
+ console.log(chalk5.bold("\nTop mistakes"));
2842
+ console.log(chalk5.dim(" none \u2014 keep going"));
2366
2843
  }
2367
2844
  console.log();
2368
2845
  });
@@ -2370,7 +2847,7 @@ Top ${top.length} mistakes`));
2370
2847
 
2371
2848
  // src/commands/word.ts
2372
2849
  import { Command as Command5 } from "commander";
2373
- import chalk5 from "chalk";
2850
+ import chalk6 from "chalk";
2374
2851
  import { readdir as readdir2 } from "fs/promises";
2375
2852
  async function listLocalDictIds2() {
2376
2853
  try {
@@ -2385,7 +2862,7 @@ function buildWordCommand() {
2385
2862
  const q = keyword.toLowerCase();
2386
2863
  const ids = await listLocalDictIds2();
2387
2864
  if (ids.length === 0) {
2388
- 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."));
2389
2866
  return;
2390
2867
  }
2391
2868
  const hits = [];
@@ -2399,7 +2876,7 @@ function buildWordCommand() {
2399
2876
  }
2400
2877
  }
2401
2878
  if (hits.length === 0) {
2402
- 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`));
2403
2880
  return;
2404
2881
  }
2405
2882
  const byName = /* @__PURE__ */ new Map();
@@ -2412,21 +2889,21 @@ function buildWordCommand() {
2412
2889
  for (const [name, group] of byName) {
2413
2890
  const first = group[0].word;
2414
2891
  console.log();
2415
- console.log(chalk5.bold.white(name));
2892
+ console.log(chalk6.bold.white(name));
2416
2893
  const us = first.usphone ? `US /${first.usphone}/` : "";
2417
2894
  const uk = first.ukphone ? `UK /${first.ukphone}/` : "";
2418
- if (us || uk) console.log(chalk5.dim(` ${[us, uk].filter(Boolean).join(" ")}`));
2419
- 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}`));
2420
2897
  const sources = await Promise.all(
2421
2898
  group.map(async (h) => {
2422
2899
  const reg = await findEntry(h.dictId);
2423
2900
  return reg?.name ?? h.dictId;
2424
2901
  })
2425
2902
  );
2426
- console.log(chalk5.dim(` in: ${sources.join(", ")}`));
2903
+ console.log(chalk6.dim(` in: ${sources.join(", ")}`));
2427
2904
  const mistake = book[name];
2428
2905
  if (mistake) {
2429
- 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)})`));
2430
2907
  }
2431
2908
  }
2432
2909
  console.log();
@@ -2442,11 +2919,15 @@ async function runMainMenu() {
2442
2919
  return;
2443
2920
  }
2444
2921
  const cfg = await loadConfig();
2922
+ start();
2445
2923
  const { waitUntilExit } = render2(
2446
2924
  createElement2(App, { initial: { name: "main" }, initialCfg: cfg }),
2447
2925
  { patchConsole: false, exitOnCtrlC: false }
2448
2926
  );
2449
2927
  await waitUntilExit();
2928
+ ensureMainScreen();
2929
+ const { lang, t } = pickStrings(cfg.language);
2930
+ printSessionReport(report(), t, lang);
2450
2931
  }
2451
2932
 
2452
2933
  // src/cli.ts