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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +1759 -571
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +1 -2
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/cli.ts
2
- import { Command as Command6 } from "commander";
2
+ import { Command as Command7 } from "commander";
3
3
 
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.7",
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,9 @@ 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
+ stealth: z.enum(["off", "menu", "default"]).default("off")
154
155
  });
155
156
  var DEFAULTS = ConfigSchema.parse({});
156
157
  async function loadConfig() {
@@ -506,12 +507,13 @@ ${matches.length} matches`));
506
507
 
507
508
  // src/commands/practice.ts
508
509
  import { Command as Command3 } from "commander";
509
- import chalk3 from "chalk";
510
+ import chalk4 from "chalk";
510
511
  import { render } from "ink";
511
512
  import { createElement } from "react";
512
513
 
513
514
  // src/ui/App.tsx
514
- import { useApp as useApp4, useInput as useInput8 } from "ink";
515
+ import { useRef as useRef4 } from "react";
516
+ import { useApp as useApp4, useInput as useInput10 } from "ink";
515
517
 
516
518
  // src/ui/nav.tsx
517
519
  import { createContext, useContext, useState, useCallback } from "react";
@@ -541,17 +543,27 @@ function useNav() {
541
543
  }
542
544
 
543
545
  // 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";
546
+ import { useEffect, useState as useState2 } from "react";
547
+ import { Box, useStdout } from "ink";
548
+ import { jsx as jsx2 } from "react/jsx-runtime";
549
+ var ENTER = "\x1B[?1049h\x1B[?25l\x1B[2J\x1B[H";
547
550
  var LEAVE = "\x1B[?25h\x1B[?1049l";
548
551
  function shouldUse() {
549
552
  return Boolean(process.stdout.isTTY) && process.env.QWERTY_NO_ALTSCREEN !== "1";
550
553
  }
551
554
  function Fullscreen({ children }) {
555
+ const { stdout } = useStdout();
556
+ const [size, setSize] = useState2(() => ({
557
+ rows: stdout?.rows ?? 24,
558
+ cols: stdout?.columns ?? 80
559
+ }));
552
560
  useEffect(() => {
553
561
  if (!shouldUse()) return;
554
562
  process.stdout.write(ENTER);
563
+ const onResize = () => {
564
+ setSize({ rows: process.stdout.rows ?? 24, cols: process.stdout.columns ?? 80 });
565
+ };
566
+ process.stdout.on("resize", onResize);
555
567
  const leave = () => {
556
568
  try {
557
569
  process.stdout.write(LEAVE);
@@ -569,14 +581,15 @@ function Fullscreen({ children }) {
569
581
  process.off("SIGINT", onSignal);
570
582
  process.off("SIGTERM", onSignal);
571
583
  process.off("exit", leave);
584
+ process.stdout.off("resize", onResize);
572
585
  leave();
573
586
  };
574
587
  }, []);
575
- return /* @__PURE__ */ jsx2(Fragment, { children });
588
+ return /* @__PURE__ */ jsx2(Box, { width: size.cols, height: size.rows, flexDirection: "column", children });
576
589
  }
577
590
 
578
591
  // src/ui/audio-context.tsx
579
- import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useState as useState2 } from "react";
592
+ import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useState as useState3 } from "react";
580
593
 
581
594
  // src/infra/audio.ts
582
595
  import { spawn } from "child_process";
@@ -738,7 +751,7 @@ function AudioStatusProvider({
738
751
  disabled,
739
752
  children
740
753
  }) {
741
- const [status, setStatus] = useState2({ warning: null, ready: false });
754
+ const [status, setStatus] = useState3({ warning: null, ready: false });
742
755
  useEffect2(() => {
743
756
  let cancelled = false;
744
757
  initAudio(disabled).then(() => {
@@ -759,14 +772,14 @@ function useAudioStatus() {
759
772
  }
760
773
 
761
774
  // src/ui/app-state.tsx
762
- import { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useState as useState3 } from "react";
775
+ import { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useState as useState4 } from "react";
763
776
  import { jsx as jsx4 } from "react/jsx-runtime";
764
777
  var AppStateContext = createContext3(null);
765
778
  function AppStateProvider({
766
779
  initialCfg,
767
780
  children
768
781
  }) {
769
- const [cfg, setCfgState] = useState3(initialCfg);
782
+ const [cfg, setCfgState] = useState4(initialCfg);
770
783
  const setCfg = useCallback2(async (next) => {
771
784
  setCfgState(next);
772
785
  await saveConfig(next);
@@ -779,14 +792,555 @@ function useAppState() {
779
792
  return ctx;
780
793
  }
781
794
 
795
+ // src/i18n/context.tsx
796
+ import { createContext as createContext4, useContext as useContext4, useMemo } from "react";
797
+
798
+ // src/i18n/strings.ts
799
+ var en = {
800
+ app: {
801
+ title: "qwerty",
802
+ subtitle: "typing practice for the terminal"
803
+ },
804
+ common: {
805
+ back: "back",
806
+ quit: "quit",
807
+ on: "on",
808
+ off: "off",
809
+ cancel: "cancel"
810
+ },
811
+ mainMenu: {
812
+ items: {
813
+ practiceLabel: "Practice",
814
+ practiceHintWith: (name) => `start ${name}`,
815
+ practiceHintNone: "pick a dictionary",
816
+ dictLabel: "Dictionaries",
817
+ dictHint: "browse, pull, set default",
818
+ wordLabel: "Word lookup",
819
+ wordHint: "search local dicts",
820
+ statsLabel: "Stats",
821
+ statsHint: "history & trends",
822
+ configLabel: "Config",
823
+ configHint: "edit preferences",
824
+ stealthLabel: "Stealth",
825
+ stealthHint: "quiet practice mode",
826
+ quitLabel: "Quit",
827
+ quitHint: "Esc or Ctrl+C also exits"
828
+ },
829
+ hint: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump",
830
+ helpHint: "? help"
831
+ },
832
+ dict: {
833
+ title: "Dictionaries",
834
+ loading: "loading dictionaries\u2026",
835
+ entries: (n) => `${n} entries`,
836
+ filterPlaceholder: "type to filter",
837
+ local: "local \u2713",
838
+ notLocal: "not local",
839
+ defaultMark: "default \u2605",
840
+ tagsLabel: (tags) => `tags: ${tags}`,
841
+ wordsLabel: (n) => `${n} words`,
842
+ pulling: (id) => `pulling ${id}\u2026`,
843
+ removing: (id) => `removing ${id}\u2026`,
844
+ errorOn: (id, msg) => `error on ${id}: ${msg}`,
845
+ footer: "\u2191/\u2193 select \xB7 Enter actions \xB7 Ctrl+K more \xB7 Esc back",
846
+ action: {
847
+ title: "current dictionary",
848
+ setDefault: "set as default",
849
+ practice: "practice now",
850
+ delete: "delete local"
851
+ },
852
+ command: {
853
+ title: "more actions",
854
+ pull: "pull selected",
855
+ import: "import .json",
856
+ refreshList: "update dictionary list"
857
+ }
858
+ },
859
+ config: {
860
+ title: "Config",
861
+ fields: {
862
+ defaultDict: "default dict",
863
+ defaultMode: "default mode",
864
+ accent: "accent",
865
+ mirror: "dict mirror",
866
+ chapterSize: "chapter size",
867
+ autoplayPronunciation: "autoplay pronunciation",
868
+ soundsMaster: "sounds master",
869
+ soundsKeystroke: "sounds keystroke",
870
+ soundsFeedback: "sounds feedback",
871
+ soundsKeySound: "sounds key sound",
872
+ language: "language",
873
+ stealth: "stealth mode"
874
+ },
875
+ enumValues: {
876
+ stealth: { off: "off", menu: "show in menu", default: "default practice" }
877
+ },
878
+ hints: {
879
+ editing: "type to edit \xB7 Enter save \xB7 Esc cancel",
880
+ bool: "space toggle \xB7 \u2191/\u2193 move \xB7 Esc back",
881
+ enum: "\u2190/\u2192 cycle \xB7 \u2191/\u2193 move \xB7 Esc back",
882
+ dictRef: "Enter pick dict \xB7 \u2191/\u2193 move \xB7 Esc back",
883
+ stringOrInt: "Enter edit \xB7 \u2191/\u2193 move \xB7 Esc back"
884
+ }
885
+ },
886
+ stats: {
887
+ title: "Stats \xB7 overview",
888
+ loading: "loading stats\u2026",
889
+ none: "No practice history yet.",
890
+ nonePractice: "Run a practice session first.",
891
+ lifetime: "lifetime",
892
+ sessions: "sessions",
893
+ words: "words",
894
+ errors: "errors",
895
+ wpm: "wpm",
896
+ accuracy: "accuracy",
897
+ streak: "streak",
898
+ last: (n) => `last ${n} days (\u2190/\u2192 cycle window)`,
899
+ cycleWindow: "\u2190/\u2192 cycle window \xB7 Esc back",
900
+ recent: "recent sessions",
901
+ topMistakes: "top mistakes",
902
+ footer: "\u2190/\u2192 cycle window \xB7 Esc back",
903
+ maxLabel: "max",
904
+ recentUnits: { words: "w", errors: "err", wpm: "wpm" },
905
+ multiDictSuffix: (n) => ` +${n} more`
906
+ },
907
+ word: {
908
+ title: "Word lookup",
909
+ indexing: "indexing local dictionaries\u2026",
910
+ none: "No local dictionaries.",
911
+ pullFirst: "Pull one in Dictionaries first.",
912
+ countAcross: (n) => `${n} words across local dicts`,
913
+ noMatches: (q) => `no matches for "${q}"`,
914
+ inDict: (name) => `in: ${name}`,
915
+ mistakes: (n, date) => `mistakes: ${n} (last ${date})`,
916
+ footer: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back"
917
+ },
918
+ practice: {
919
+ loading: "loading\u2026",
920
+ paused: "PAUSED",
921
+ chapterComplete: "CHAPTER COMPLETE",
922
+ chapterLabel: (c, t) => `chapter ${c}/${t}`,
923
+ reviewLabel: "review",
924
+ statusBar: {
925
+ mode: "mode",
926
+ accent: "accent"
927
+ },
928
+ modes: {
929
+ order: "order",
930
+ dictation: "dictation",
931
+ review: "review",
932
+ random: "random",
933
+ loop: "loop"
934
+ },
935
+ accents: {
936
+ us: "us",
937
+ uk: "uk"
938
+ },
939
+ statCards: {
940
+ words: "words",
941
+ errors: "errors",
942
+ wpm: "wpm",
943
+ accuracy: "accuracy",
944
+ elapsed: (t) => `elapsed ${t}`
945
+ },
946
+ pause: {
947
+ title: "PAUSED",
948
+ chapter: (c, t) => `chapter ${c}/${t}`,
949
+ progress: (completed, total) => `${completed}/${total}`,
950
+ hint: "Enter resume \xB7 Esc back to menu"
951
+ },
952
+ summary: {
953
+ loopAgain: "again",
954
+ nextChapter: "next chapter",
955
+ reviewMistakes: "review mistakes",
956
+ backMenu: "back to menu"
957
+ },
958
+ footers: {
959
+ typing: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay"
960
+ },
961
+ errors: {
962
+ noMistakes: "No mistakes to review yet. Practice some chapters first.",
963
+ dictEmpty: (id) => `Dictionary ${id} is empty.`,
964
+ unknown: "Unknown error"
965
+ }
966
+ },
967
+ audio: {
968
+ noPlayer: "! No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled."
969
+ },
970
+ report: {
971
+ title: "Session summary",
972
+ duration: "duration",
973
+ practiced: "practiced",
974
+ chapters: "chapters",
975
+ words: "words",
976
+ accuracy: "accuracy",
977
+ wpm: "wpm",
978
+ newMistakes: "new mistakes",
979
+ farewell: "see you next time.",
980
+ notPracticed: "no practice this run"
981
+ },
982
+ help: {
983
+ title: "Help",
984
+ subtitle: "all shortcuts",
985
+ sections: {
986
+ main: "main menu",
987
+ practice: "practice",
988
+ dict: "dictionaries",
989
+ config: "config",
990
+ stats: "stats",
991
+ word: "word lookup",
992
+ global: "global"
993
+ },
994
+ keys: {
995
+ navigate: "\u2191/\u2193 navigate items",
996
+ select: "Enter confirm / continue",
997
+ letterJump: "letter jump to menu item",
998
+ pause: "Esc pause practice",
999
+ skip: "Ctrl+N skip current word (neutral)",
1000
+ replay: "Tab replay pronunciation",
1001
+ resume: "Enter resume from pause",
1002
+ backMenu: "Esc back to previous screen",
1003
+ backScreen: "Esc close panel or back",
1004
+ nextChapter: "Enter next chapter",
1005
+ reviewMistakes: "m review mistakes",
1006
+ filter: "type to filter list",
1007
+ itemActions: "Enter open actions panel",
1008
+ moreActions: "Ctrl+K more actions panel",
1009
+ cycleWindow: "\u2190/\u2192 cycle day window",
1010
+ stealthToggle: "Ctrl+I toggle stealth info row",
1011
+ helpScreen: "? open this help screen",
1012
+ quit: "Ctrl+C quit immediately"
1013
+ },
1014
+ footer: "Esc back"
1015
+ },
1016
+ stealth: {
1017
+ paused: "paused",
1018
+ chapterDone: "chapter done",
1019
+ resumeHint: "Enter resume \xB7 Esc menu",
1020
+ nextHint: "Enter next \xB7 Esc menu",
1021
+ infoFmt: (dict, chapter, completed, total, wpm, accPct) => `${dict} \xB7 ${chapter} \xB7 ${completed}/${total} \xB7 ${wpm} wpm \xB7 ${accPct}%`
1022
+ }
1023
+ };
1024
+ var zh = {
1025
+ app: {
1026
+ title: "qwerty",
1027
+ subtitle: "\u7EC8\u7AEF\u952E\u76D8\u7EC3\u4E60"
1028
+ },
1029
+ common: {
1030
+ back: "\u8FD4\u56DE",
1031
+ quit: "\u9000\u51FA",
1032
+ on: "\u5F00",
1033
+ off: "\u5173",
1034
+ cancel: "\u53D6\u6D88"
1035
+ },
1036
+ mainMenu: {
1037
+ items: {
1038
+ practiceLabel: "\u7EC3\u4E60",
1039
+ practiceHintWith: (name) => `\u5F00\u59CB ${name}`,
1040
+ practiceHintNone: "\u8BF7\u5148\u9009\u8BCD\u5178",
1041
+ dictLabel: "\u8BCD\u5178",
1042
+ dictHint: "\u6D4F\u89C8\u3001\u4E0B\u8F7D\u3001\u8BBE\u4E3A\u9ED8\u8BA4",
1043
+ wordLabel: "\u67E5\u8BCD",
1044
+ wordHint: "\u5728\u672C\u5730\u8BCD\u5178\u4E2D\u641C\u7D22",
1045
+ statsLabel: "\u7EDF\u8BA1",
1046
+ statsHint: "\u5386\u53F2\u4E0E\u8D8B\u52BF",
1047
+ configLabel: "\u8BBE\u7F6E",
1048
+ configHint: "\u4FEE\u6539\u504F\u597D",
1049
+ stealthLabel: "\u6478\u9C7C",
1050
+ stealthHint: "\u5B89\u9759\u7EC3\u4E60\u6A21\u5F0F",
1051
+ quitLabel: "\u9000\u51FA",
1052
+ quitHint: "Esc \u6216 Ctrl+C \u9000\u51FA"
1053
+ },
1054
+ hint: "\u2191/\u2193 \u79FB\u52A8 \xB7 Enter \u786E\u8BA4 \xB7 \u5B57\u6BCD\u76F4\u8FBE",
1055
+ helpHint: "? \u5E2E\u52A9"
1056
+ },
1057
+ dict: {
1058
+ title: "\u8BCD\u5178",
1059
+ loading: "\u52A0\u8F7D\u8BCD\u5178\u4E2D\u2026",
1060
+ entries: (n) => `${n} \u90E8\u8BCD\u5178`,
1061
+ filterPlaceholder: "\u8F93\u5165\u8FC7\u6EE4",
1062
+ local: "\u5DF2\u4E0B\u8F7D \u2713",
1063
+ notLocal: "\u672A\u4E0B\u8F7D",
1064
+ defaultMark: "\u9ED8\u8BA4 \u2605",
1065
+ tagsLabel: (tags) => `\u6807\u7B7E:${tags}`,
1066
+ wordsLabel: (n) => `${n} \u8BCD`,
1067
+ pulling: (id) => `\u62C9\u53D6 ${id} \u4E2D\u2026`,
1068
+ removing: (id) => `\u5220\u9664 ${id} \u4E2D\u2026`,
1069
+ errorOn: (id, msg) => `${id} \u51FA\u9519:${msg}`,
1070
+ footer: "\u2191/\u2193 \u9009\u62E9 \xB7 Enter \u64CD\u4F5C \xB7 Ctrl+K \u66F4\u591A \xB7 Esc \u8FD4\u56DE",
1071
+ action: {
1072
+ title: "\u5F53\u524D\u8BCD\u5178",
1073
+ setDefault: "\u8BBE\u4E3A\u9ED8\u8BA4",
1074
+ practice: "\u7ACB\u5373\u7EC3\u4E60",
1075
+ delete: "\u5220\u9664\u672C\u5730"
1076
+ },
1077
+ command: {
1078
+ title: "\u66F4\u591A\u529F\u80FD",
1079
+ pull: "\u62C9\u53D6\u9009\u4E2D",
1080
+ import: "\u5BFC\u5165 .json",
1081
+ refreshList: "\u66F4\u65B0\u8BCD\u5178\u5217\u8868"
1082
+ }
1083
+ },
1084
+ config: {
1085
+ title: "\u8BBE\u7F6E",
1086
+ fields: {
1087
+ defaultDict: "\u9ED8\u8BA4\u8BCD\u5178",
1088
+ defaultMode: "\u9ED8\u8BA4\u6A21\u5F0F",
1089
+ accent: "\u53D1\u97F3",
1090
+ mirror: "\u8BCD\u5178\u955C\u50CF\u6E90",
1091
+ chapterSize: "\u7AE0\u8282\u5355\u8BCD\u6570",
1092
+ autoplayPronunciation: "\u81EA\u52A8\u64AD\u653E\u53D1\u97F3",
1093
+ soundsMaster: "\u97F3\u6548\u603B\u5F00\u5173",
1094
+ soundsKeystroke: "\u6309\u952E\u97F3",
1095
+ soundsFeedback: "\u53CD\u9988\u97F3",
1096
+ soundsKeySound: "\u6309\u952E\u97F3\u8272",
1097
+ language: "\u8BED\u8A00",
1098
+ stealth: "\u6478\u9C7C\u6A21\u5F0F"
1099
+ },
1100
+ enumValues: {
1101
+ stealth: { off: "\u5173\u95ED", menu: "\u4E3B\u83DC\u5355\u663E\u793A", default: "\u9ED8\u8BA4\u7EC3\u4E60\u6A21\u5F0F" }
1102
+ },
1103
+ hints: {
1104
+ editing: "\u8F93\u5165\u4FEE\u6539 \xB7 Enter \u4FDD\u5B58 \xB7 Esc \u53D6\u6D88",
1105
+ bool: "\u7A7A\u683C\u5207\u6362 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1106
+ enum: "\u2190/\u2192 \u5207\u6362 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1107
+ dictRef: "Enter \u9009\u8BCD\u5178 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1108
+ stringOrInt: "Enter \u7F16\u8F91 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE"
1109
+ }
1110
+ },
1111
+ stats: {
1112
+ title: "\u7EDF\u8BA1 \xB7 \u6982\u89C8",
1113
+ loading: "\u52A0\u8F7D\u7EDF\u8BA1\u4E2D\u2026",
1114
+ none: "\u8FD8\u6CA1\u6709\u7EC3\u4E60\u8BB0\u5F55\u3002",
1115
+ nonePractice: "\u5148\u6765\u4E00\u6B21\u7EC3\u4E60\u5427\u3002",
1116
+ lifetime: "\u7D2F\u8BA1",
1117
+ sessions: "\u4F1A\u8BDD",
1118
+ words: "\u8BCD\u6570",
1119
+ errors: "\u9519\u8BEF",
1120
+ wpm: "\u901F\u5EA6",
1121
+ accuracy: "\u51C6\u786E\u7387",
1122
+ streak: "\u8FDE\u7EED\u5929\u6570",
1123
+ last: (n) => `\u6700\u8FD1 ${n} \u5929 (\u2190/\u2192 \u5207\u6362\u7A97\u53E3)`,
1124
+ cycleWindow: "\u2190/\u2192 \u5207\u6362\u7A97\u53E3 \xB7 Esc \u8FD4\u56DE",
1125
+ recent: "\u6700\u8FD1\u4F1A\u8BDD",
1126
+ topMistakes: "\u9AD8\u9891\u9519\u8BCD",
1127
+ footer: "\u2190/\u2192 \u5207\u6362\u7A97\u53E3 \xB7 Esc \u8FD4\u56DE",
1128
+ maxLabel: "\u6700\u5927",
1129
+ recentUnits: { words: "\u8BCD", errors: "\u9519", wpm: "\u901F" },
1130
+ multiDictSuffix: (n) => ` \u7B49 ${n} \u90E8`
1131
+ },
1132
+ word: {
1133
+ title: "\u67E5\u8BCD",
1134
+ indexing: "\u7D22\u5F15\u672C\u5730\u8BCD\u5178\u4E2D\u2026",
1135
+ none: "\u6CA1\u6709\u672C\u5730\u8BCD\u5178\u3002",
1136
+ pullFirst: "\u5148\u5728\u300C\u8BCD\u5178\u300D\u4E2D\u62C9\u53D6\u4E00\u90E8\u3002",
1137
+ countAcross: (n) => `\u672C\u5730\u8BCD\u5178\u5171 ${n} \u8BCD`,
1138
+ noMatches: (q) => `\u6CA1\u6709\u5339\u914D\u300C${q}\u300D\u7684\u8BCD`,
1139
+ inDict: (name) => `\u6765\u6E90:${name}`,
1140
+ mistakes: (n, date) => `\u9519\u8FC7 ${n} \u6B21 (\u6700\u8FD1 ${date})`,
1141
+ footer: "\u8F93\u5165\u8FC7\u6EE4 \xB7 \u2191/\u2193 \u9009\u62E9 \xB7 Esc \u8FD4\u56DE"
1142
+ },
1143
+ practice: {
1144
+ loading: "\u52A0\u8F7D\u4E2D\u2026",
1145
+ paused: "\u5DF2\u6682\u505C",
1146
+ chapterComplete: "\u672C\u7AE0\u5B8C\u6210",
1147
+ chapterLabel: (c, t) => `\u7B2C ${c}/${t} \u7AE0`,
1148
+ reviewLabel: "\u590D\u4E60",
1149
+ statusBar: {
1150
+ mode: "\u6A21\u5F0F",
1151
+ accent: "\u53D1\u97F3"
1152
+ },
1153
+ modes: {
1154
+ order: "\u987A\u5E8F",
1155
+ dictation: "\u9ED8\u5199",
1156
+ review: "\u590D\u4E60",
1157
+ random: "\u4E71\u5E8F",
1158
+ loop: "\u5FAA\u73AF"
1159
+ },
1160
+ accents: {
1161
+ us: "\u7F8E",
1162
+ uk: "\u82F1"
1163
+ },
1164
+ statCards: {
1165
+ words: "\u8BCD\u6570",
1166
+ errors: "\u9519\u8BEF",
1167
+ wpm: "\u901F\u5EA6",
1168
+ accuracy: "\u51C6\u786E\u7387",
1169
+ elapsed: (t) => `\u8017\u65F6 ${t}`
1170
+ },
1171
+ pause: {
1172
+ title: "\u5DF2\u6682\u505C",
1173
+ chapter: (c, t) => `\u7B2C ${c}/${t} \u7AE0`,
1174
+ progress: (completed, total) => `${completed}/${total}`,
1175
+ hint: "Enter \u7EE7\u7EED \xB7 Esc \u8FD4\u56DE\u83DC\u5355"
1176
+ },
1177
+ summary: {
1178
+ loopAgain: "\u518D\u6765\u4E00\u904D",
1179
+ nextChapter: "\u4E0B\u4E00\u7AE0",
1180
+ reviewMistakes: "\u590D\u4E60\u9519\u8BCD",
1181
+ backMenu: "\u8FD4\u56DE\u83DC\u5355"
1182
+ },
1183
+ footers: {
1184
+ typing: "Ctrl+N \u8DF3\u8FC7 \xB7 Esc \u6682\u505C \xB7 Tab \u91CD\u64AD"
1185
+ },
1186
+ errors: {
1187
+ noMistakes: "\u9519\u8BCD\u672C\u662F\u7A7A\u7684\u3002\u5148\u7EC3\u4E60\u51E0\u7AE0\u5427\u3002",
1188
+ dictEmpty: (id) => `\u8BCD\u5178 ${id} \u662F\u7A7A\u7684\u3002`,
1189
+ unknown: "\u672A\u77E5\u9519\u8BEF"
1190
+ }
1191
+ },
1192
+ audio: {
1193
+ 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"
1194
+ },
1195
+ report: {
1196
+ title: "\u672C\u6B21\u4F1A\u8BDD",
1197
+ duration: "\u603B\u65F6\u957F",
1198
+ practiced: "\u7EC3\u4E60\u7528\u65F6",
1199
+ chapters: "\u5B8C\u6210\u7AE0\u8282",
1200
+ words: "\u8BCD\u6570",
1201
+ accuracy: "\u51C6\u786E\u7387",
1202
+ wpm: "\u901F\u5EA6",
1203
+ newMistakes: "\u65B0\u9519\u8BCD",
1204
+ farewell: "\u4E0B\u6B21\u89C1\u3002",
1205
+ notPracticed: "\u672C\u6B21\u672A\u7EC3\u4E60"
1206
+ },
1207
+ help: {
1208
+ title: "\u5E2E\u52A9",
1209
+ subtitle: "\u5168\u90E8\u5FEB\u6377\u952E",
1210
+ sections: {
1211
+ main: "\u4E3B\u83DC\u5355",
1212
+ practice: "\u7EC3\u4E60",
1213
+ dict: "\u8BCD\u5178",
1214
+ config: "\u8BBE\u7F6E",
1215
+ stats: "\u7EDF\u8BA1",
1216
+ word: "\u67E5\u8BCD",
1217
+ global: "\u5168\u5C40"
1218
+ },
1219
+ keys: {
1220
+ navigate: "\u2191/\u2193 \u79FB\u52A8\u9009\u9879",
1221
+ select: "Enter \u786E\u8BA4 / \u7EE7\u7EED",
1222
+ letterJump: "\u5B57\u6BCD\u952E \u76F4\u8FBE\u83DC\u5355\u9879",
1223
+ pause: "Esc \u6682\u505C\u7EC3\u4E60",
1224
+ skip: "Ctrl+N \u8DF3\u8FC7\u5F53\u524D\u8BCD(\u4E0D\u8BA1\u9519)",
1225
+ replay: "Tab \u91CD\u64AD\u53D1\u97F3",
1226
+ resume: "Enter \u7EE7\u7EED\u7EC3\u4E60",
1227
+ backMenu: "Esc \u8FD4\u56DE\u4E0A\u4E00\u5C4F",
1228
+ backScreen: "Esc \u5173\u95ED\u9762\u677F / \u8FD4\u56DE",
1229
+ nextChapter: "Enter \u4E0B\u4E00\u7AE0",
1230
+ reviewMistakes: "m \u590D\u4E60\u9519\u8BCD",
1231
+ filter: "\u8F93\u5165 \u8FC7\u6EE4\u5217\u8868",
1232
+ itemActions: "Enter \u5F39\u51FA\u52A8\u4F5C\u9762\u677F",
1233
+ moreActions: "Ctrl+K \u5F39\u51FA\u66F4\u591A\u529F\u80FD",
1234
+ cycleWindow: "\u2190/\u2192 \u5207\u6362\u65E5\u7A97\u53E3",
1235
+ stealthToggle: "Ctrl+I \u5207\u6362\u6478\u9C7C\u4FE1\u606F\u884C",
1236
+ helpScreen: "? \u6253\u5F00\u672C\u5E2E\u52A9\u9875",
1237
+ quit: "Ctrl+C \u7ACB\u5373\u9000\u51FA"
1238
+ },
1239
+ footer: "Esc \u8FD4\u56DE"
1240
+ },
1241
+ stealth: {
1242
+ paused: "paused",
1243
+ chapterDone: "chapter done",
1244
+ resumeHint: "Enter resume \xB7 Esc menu",
1245
+ nextHint: "Enter next \xB7 Esc menu",
1246
+ infoFmt: (dict, chapter, completed, total, wpm, accPct) => `${dict} \xB7 ${chapter} \xB7 ${completed}/${total} \xB7 ${wpm} wpm \xB7 ${accPct}%`
1247
+ }
1248
+ };
1249
+
1250
+ // src/i18n/locale.ts
1251
+ function pickFromString(s) {
1252
+ if (!s) return null;
1253
+ const lower = s.toLowerCase();
1254
+ if (lower.startsWith("zh")) return "zh";
1255
+ if (lower.startsWith("en")) return "en";
1256
+ return null;
1257
+ }
1258
+ function detectLocale(pref) {
1259
+ if (pref === "zh" || pref === "en") return pref;
1260
+ const env = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || process.env.LANGUAGE;
1261
+ const fromEnv = pickFromString(env);
1262
+ if (fromEnv) return fromEnv;
1263
+ try {
1264
+ const intlLocale = Intl.DateTimeFormat().resolvedOptions().locale;
1265
+ const fromIntl = pickFromString(intlLocale);
1266
+ if (fromIntl) return fromIntl;
1267
+ } catch {
1268
+ }
1269
+ return "en";
1270
+ }
1271
+
1272
+ // src/i18n/context.tsx
1273
+ import { jsx as jsx5 } from "react/jsx-runtime";
1274
+ var StringsContext = createContext4(null);
1275
+ function StringsProvider({
1276
+ pref,
1277
+ children
1278
+ }) {
1279
+ const value = useMemo(() => {
1280
+ const lang = detectLocale(pref);
1281
+ return { lang, t: lang === "zh" ? zh : en };
1282
+ }, [pref]);
1283
+ return /* @__PURE__ */ jsx5(StringsContext.Provider, { value, children });
1284
+ }
1285
+ function useStrings() {
1286
+ const ctx = useContext4(StringsContext);
1287
+ if (!ctx) throw new Error("useStrings must be used inside StringsProvider");
1288
+ return ctx.t;
1289
+ }
1290
+ function pickStrings(pref) {
1291
+ const lang = detectLocale(pref);
1292
+ return { lang, t: lang === "zh" ? zh : en };
1293
+ }
1294
+
1295
+ // src/ui/registry-context.tsx
1296
+ import { createContext as createContext5, useContext as useContext5, useEffect as useEffect3, useState as useState5 } from "react";
1297
+ import { jsx as jsx6 } from "react/jsx-runtime";
1298
+ var RegistryContext = createContext5(null);
1299
+ function RegistryProvider({ children }) {
1300
+ const [registry, setRegistry] = useState5(null);
1301
+ const [byId, setById] = useState5(/* @__PURE__ */ new Map());
1302
+ useEffect3(() => {
1303
+ let cancelled = false;
1304
+ (async () => {
1305
+ try {
1306
+ const reg = await loadRegistry();
1307
+ if (cancelled) return;
1308
+ const map = /* @__PURE__ */ new Map();
1309
+ for (const e of reg) map.set(e.id, e);
1310
+ setRegistry(reg);
1311
+ setById(map);
1312
+ } catch {
1313
+ if (!cancelled) {
1314
+ setRegistry([]);
1315
+ setById(/* @__PURE__ */ new Map());
1316
+ }
1317
+ }
1318
+ })();
1319
+ return () => {
1320
+ cancelled = true;
1321
+ };
1322
+ }, []);
1323
+ return /* @__PURE__ */ jsx6(RegistryContext.Provider, { value: { registry, byId }, children });
1324
+ }
1325
+ function useRegistry() {
1326
+ const ctx = useContext5(RegistryContext);
1327
+ if (!ctx) throw new Error("useRegistry must be used inside RegistryProvider");
1328
+ return ctx;
1329
+ }
1330
+ function useDictName(id) {
1331
+ const { byId } = useRegistry();
1332
+ if (!id) return "";
1333
+ const entry = byId.get(id);
1334
+ return entry?.name ?? id;
1335
+ }
1336
+
782
1337
  // src/ui/screens/MainMenu.tsx
783
- import { useState as useState4 } from "react";
784
- import { Box as Box2, Text as Text2, useApp, useInput } from "ink";
1338
+ import { useState as useState6 } from "react";
1339
+ import { Box as Box3, Text as Text2, useApp, useInput } from "ink";
785
1340
 
786
1341
  // 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";
1342
+ import { Box as Box2, Text, useStdout as useStdout2 } from "ink";
1343
+ import { jsx as jsx7, jsxs } from "react/jsx-runtime";
790
1344
  var PALETTE = {
791
1345
  accent: "#5eead4",
792
1346
  muted: "#6b7280",
@@ -797,66 +1351,127 @@ var PALETTE = {
797
1351
  error: "#f87171"
798
1352
  };
799
1353
  function BigWord({ target, typed, error = false, hideTarget = false }) {
800
- const { stdout } = useStdout();
1354
+ const { stdout } = useStdout2();
801
1355
  const cols = stdout?.columns ?? 80;
802
1356
  const chars = [...target];
803
1357
  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) => {
1358
+ const sep = cols >= 80 ? " " : cols >= 60 ? " " : " ";
1359
+ return /* @__PURE__ */ jsxs(Box2, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [
1360
+ /* @__PURE__ */ jsx7(Box2, { children: chars.map((ch, i) => {
807
1361
  const isTyped = i < typedChars.length;
808
1362
  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: [
1363
+ const color = error ? PALETTE.error : isTyped ? PALETTE.accent : PALETTE.muted;
1364
+ return /* @__PURE__ */ jsxs(Text, { bold: true, color, children: [
811
1365
  display,
812
- " "
1366
+ i < chars.length - 1 ? sep : ""
813
1367
  ] }, i);
814
- }) });
1368
+ }) }),
1369
+ /* @__PURE__ */ jsx7(Box2, { children: chars.map((ch, i) => {
1370
+ const isTyped = i < typedChars.length;
1371
+ const trackChar = isTyped ? "\u2501" : "\u2500";
1372
+ const color = error ? PALETTE.error : isTyped ? PALETTE.accent : PALETTE.muted;
1373
+ return /* @__PURE__ */ jsxs(Text, { color, children: [
1374
+ trackChar,
1375
+ i < chars.length - 1 ? sep : ""
1376
+ ] }, i);
1377
+ }) })
1378
+ ] });
1379
+ }
1380
+
1381
+ // src/util/text.ts
1382
+ var ANSI_RE = /\x1b\[[0-9;]*m/g;
1383
+ function stripAnsi(s) {
1384
+ return s.replace(ANSI_RE, "");
1385
+ }
1386
+ function visibleWidth2(s) {
1387
+ const plain = stripAnsi(s);
1388
+ let w = 0;
1389
+ for (const ch of plain) {
1390
+ const code = ch.codePointAt(0);
1391
+ w += code > 11904 && code < 64256 ? 2 : 1;
815
1392
  }
816
- return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", flexDirection: "row", children: chars.map((ch, i) => {
817
- const isTyped = i < typedChars.length;
818
- const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
819
- const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
820
- return /* @__PURE__ */ jsx5(BigText, { text: display, font: "tiny", colors: [color], space: false }, i);
821
- }) });
1393
+ return w;
1394
+ }
1395
+
1396
+ // src/util/dict-name.ts
1397
+ function truncateName(name, max) {
1398
+ if (visibleWidth2(name) <= max) return name;
1399
+ let out = "";
1400
+ let w = 0;
1401
+ for (const ch of name) {
1402
+ const code = ch.codePointAt(0);
1403
+ const cw = code > 11904 && code < 64256 ? 2 : 1;
1404
+ if (w + cw > max - 1) break;
1405
+ out += ch;
1406
+ w += cw;
1407
+ }
1408
+ return out + "\u2026";
822
1409
  }
823
1410
 
824
1411
  // src/ui/screens/MainMenu.tsx
825
- import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
1412
+ import { jsx as jsx8, jsxs as jsxs2 } from "react/jsx-runtime";
826
1413
  function MainMenu({ cfg }) {
827
- const [selected, setSelected] = useState4(0);
1414
+ const [selected, setSelected] = useState6(0);
828
1415
  const { exit } = useApp();
829
1416
  const nav = useNav();
830
1417
  const audio = useAudioStatus();
1418
+ const t = useStrings();
1419
+ const defaultDictName = useDictName(cfg.defaultDict);
1420
+ const m = t.mainMenu.items;
1421
+ const startPractice = (stealth) => {
1422
+ if (cfg.defaultDict) {
1423
+ nav.navigate({
1424
+ name: "practice",
1425
+ params: {
1426
+ dictId: cfg.defaultDict,
1427
+ chapterIndex: 0,
1428
+ mode: cfg.defaultMode,
1429
+ stealth
1430
+ }
1431
+ });
1432
+ } else {
1433
+ nav.navigate({ name: "dict", params: { pickerMode: "choose-then-practice" } });
1434
+ }
1435
+ };
831
1436
  const items = [
832
1437
  {
833
1438
  key: "p",
834
- label: "Practice",
835
- hint: cfg.defaultDict ? `start ${cfg.defaultDict}` : "pick a dictionary",
836
- run: () => {
837
- if (cfg.defaultDict) {
838
- nav.navigate({
839
- name: "practice",
840
- params: { dictId: cfg.defaultDict, chapterIndex: 0, mode: cfg.defaultMode }
841
- });
842
- } else {
843
- nav.navigate({ name: "dict", params: { pickerMode: "choose-then-practice" } });
844
- }
845
- }
846
- },
847
- { key: "d", label: "Dictionaries", hint: "browse, pull, set default", run: () => nav.navigate({ name: "dict" }) },
848
- { key: "w", label: "Word lookup", hint: "search local dicts", run: () => nav.navigate({ name: "word" }) },
849
- { key: "s", label: "Stats", hint: "history & trends", run: () => nav.navigate({ name: "stats" }) },
850
- { key: "c", label: "Config", hint: "edit preferences", run: () => nav.navigate({ name: "config" }) },
851
- { key: "q", label: "Quit", hint: "Ctrl+C also exits", run: () => exit() }
1439
+ label: m.practiceLabel,
1440
+ hint: cfg.defaultDict ? m.practiceHintWith(truncateName(defaultDictName, 24)) : m.practiceHintNone,
1441
+ run: () => startPractice(cfg.stealth === "default")
1442
+ }
852
1443
  ];
1444
+ if (cfg.stealth === "menu" || cfg.stealth === "default") {
1445
+ items.push({
1446
+ key: "b",
1447
+ label: m.stealthLabel,
1448
+ hint: m.stealthHint,
1449
+ run: () => startPractice(true)
1450
+ });
1451
+ }
1452
+ items.push(
1453
+ { key: "d", label: m.dictLabel, hint: m.dictHint, run: () => nav.navigate({ name: "dict" }) },
1454
+ { key: "w", label: m.wordLabel, hint: m.wordHint, run: () => nav.navigate({ name: "word" }) },
1455
+ { key: "s", label: m.statsLabel, hint: m.statsHint, run: () => nav.navigate({ name: "stats" }) },
1456
+ { key: "c", label: m.configLabel, hint: m.configHint, run: () => nav.navigate({ name: "config" }) },
1457
+ { key: "q", label: m.quitLabel, hint: m.quitHint, run: () => exit() }
1458
+ );
1459
+ const labelW = Math.max(...items.map((it) => visibleWidth2(it.label))) + 4;
853
1460
  useInput((input, key) => {
1461
+ if (key.escape) {
1462
+ exit();
1463
+ return;
1464
+ }
854
1465
  if (key.upArrow) setSelected((i) => (i - 1 + items.length) % items.length);
855
1466
  if (key.downArrow) setSelected((i) => (i + 1) % items.length);
856
1467
  if (key.return) {
857
1468
  items[selected].run();
858
1469
  return;
859
1470
  }
1471
+ if (input === "?") {
1472
+ nav.navigate({ name: "help" });
1473
+ return;
1474
+ }
860
1475
  for (const it of items) {
861
1476
  if (input === it.key) {
862
1477
  it.run();
@@ -864,41 +1479,44 @@ function MainMenu({ cfg }) {
864
1479
  }
865
1480
  }
866
1481
  });
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" })
1482
+ return /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
1483
+ /* @__PURE__ */ jsxs2(Box3, { children: [
1484
+ /* @__PURE__ */ jsx8(Text2, { bold: true, color: PALETTE.accent, children: t.app.title }),
1485
+ /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
1486
+ " \xB7 ",
1487
+ t.app.subtitle
1488
+ ] })
871
1489
  ] }),
872
- /* @__PURE__ */ jsx6(Box2, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
1490
+ /* @__PURE__ */ jsx8(Box3, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
873
1491
  const active = i === selected;
874
- return /* @__PURE__ */ jsxs2(Box2, { children: [
875
- /* @__PURE__ */ jsx6(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1492
+ const pad = " ".repeat(Math.max(0, labelW - visibleWidth2(it.label)));
1493
+ return /* @__PURE__ */ jsxs2(Box3, { children: [
1494
+ /* @__PURE__ */ jsx8(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
876
1495
  /* @__PURE__ */ jsxs2(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
877
1496
  "[",
878
1497
  it.key,
879
1498
  "]"
880
1499
  ] }),
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 })
1500
+ /* @__PURE__ */ jsx8(Text2, { children: " " }),
1501
+ /* @__PURE__ */ jsxs2(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
1502
+ it.label,
1503
+ pad
1504
+ ] }),
1505
+ /* @__PURE__ */ jsx8(Text2, { color: PALETTE.muted, children: it.hint })
884
1506
  ] }, it.key);
885
1507
  }) }),
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)"
1508
+ /* @__PURE__ */ jsx8(Box3, { marginTop: 2, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
1509
+ t.mainMenu.hint,
1510
+ " \xB7 ",
1511
+ t.mainMenu.helpHint
889
1512
  ] }) }),
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
- ] }) })
1513
+ audio.warning && /* @__PURE__ */ jsx8(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text2, { color: PALETTE.warning, children: t.audio.noPlayer }) })
895
1514
  ] });
896
1515
  }
897
1516
 
898
1517
  // 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";
1518
+ import { useState as useState8, useEffect as useEffect6, useRef as useRef3 } from "react";
1519
+ import { Box as Box5, Text as Text4, useApp as useApp3, useInput as useInput3 } from "ink";
902
1520
 
903
1521
  // src/util/shuffle.ts
904
1522
  function shuffle(arr, rng = Math.random) {
@@ -942,25 +1560,25 @@ function buildPlaylist(chapter, mode, seed) {
942
1560
  function initialState(target) {
943
1561
  return { target, typed: "", errorsThisWord: 0 };
944
1562
  }
945
- function reduce(state, ev) {
1563
+ function reduce(state2, ev) {
946
1564
  switch (ev.type) {
947
1565
  case "reset":
948
- return { state: { ...state, typed: "" }, effect: "none" };
1566
+ return { state: { ...state2, typed: "" }, effect: "none" };
949
1567
  case "backspace": {
950
- if (state.typed.length === 0) return { state, effect: "none" };
951
- return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
1568
+ if (state2.typed.length === 0) return { state: state2, effect: "none" };
1569
+ return { state: { ...state2, typed: state2.typed.slice(0, -1) }, effect: "none" };
952
1570
  }
953
1571
  case "char": {
954
- const candidate = state.typed + ev.ch;
955
- const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
1572
+ const candidate = state2.typed + ev.ch;
1573
+ const targetUpToCandidate = [...state2.target].slice(0, [...candidate].length).join("");
956
1574
  if (candidate === targetUpToCandidate) {
957
- if (candidate.length === state.target.length) {
958
- return { state: { ...state, typed: candidate }, effect: "correct" };
1575
+ if (candidate.length === state2.target.length) {
1576
+ return { state: { ...state2, typed: candidate }, effect: "correct" };
959
1577
  }
960
- return { state: { ...state, typed: candidate }, effect: "progress" };
1578
+ return { state: { ...state2, typed: candidate }, effect: "progress" };
961
1579
  }
962
1580
  return {
963
- state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
1581
+ state: { ...state2, typed: "", errorsThisWord: state2.errorsThisWord + 1 },
964
1582
  effect: "wrong"
965
1583
  };
966
1584
  }
@@ -982,11 +1600,11 @@ function startSession(playlist, now = Date.now()) {
982
1600
  }
983
1601
  function feedSession(session, ev, now = Date.now()) {
984
1602
  if (!session.current) return { session, effect: "none" };
985
- const { state, effect } = reduce(session.current.input, ev);
1603
+ const { state: state2, effect } = reduce(session.current.input, ev);
986
1604
  if (effect === "correct") {
987
1605
  const finished = {
988
- word: state.target,
989
- errors: state.errorsThisWord,
1606
+ word: state2.target,
1607
+ errors: state2.errorsThisWord,
990
1608
  durationMs: now - session.current.wordStartedAt
991
1609
  };
992
1610
  const nextIndex = session.current.wordIndex + 1;
@@ -1013,7 +1631,7 @@ function feedSession(session, ev, now = Date.now()) {
1013
1631
  return {
1014
1632
  session: {
1015
1633
  ...session,
1016
- current: { ...session.current, input: state }
1634
+ current: { ...session.current, input: state2 }
1017
1635
  },
1018
1636
  effect
1019
1637
  };
@@ -1097,24 +1715,24 @@ function topN(book, n) {
1097
1715
  }
1098
1716
 
1099
1717
  // src/ui/hooks/useWordLoop.ts
1100
- import { useEffect as useEffect3, useReducer, useRef, useState as useState5 } from "react";
1718
+ import { useEffect as useEffect4, useReducer, useRef, useState as useState7 } from "react";
1101
1719
  import { useInput as useInput2, useApp as useApp2 } from "ink";
1102
- function reducer(state, action) {
1720
+ function reducer(state2, action) {
1103
1721
  if (action.type === "start") {
1104
1722
  return { session: startSession(action.playlist, action.now), lastEffect: null };
1105
1723
  }
1106
1724
  if (action.type === "skip") {
1107
- const r = skipSession(state.session, action.now);
1725
+ const r = skipSession(state2.session, action.now);
1108
1726
  return { session: r.session, lastEffect: r.effect };
1109
1727
  }
1110
1728
  if (action.type === "event") {
1111
1729
  if (action.key.backspace || action.key.delete) {
1112
- const r = feedSession(state.session, { type: "backspace" }, action.now);
1730
+ const r = feedSession(state2.session, { type: "backspace" }, action.now);
1113
1731
  return { session: r.session, lastEffect: r.effect };
1114
1732
  }
1115
- if (action.input.length === 0) return state;
1116
- let session = state.session;
1117
- let lastEffect = state.lastEffect;
1733
+ if (action.input.length === 0) return state2;
1734
+ let session = state2.session;
1735
+ let lastEffect = state2.lastEffect;
1118
1736
  for (const c of action.input) {
1119
1737
  const r = feedSession(session, { type: "char", ch: c }, action.now);
1120
1738
  session = r.session;
@@ -1123,15 +1741,15 @@ function reducer(state, action) {
1123
1741
  }
1124
1742
  return { session, lastEffect };
1125
1743
  }
1126
- return state;
1744
+ return state2;
1127
1745
  }
1128
1746
  function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled = true }) {
1129
- const [state, dispatch] = useReducer(reducer, void 0, () => ({
1747
+ const [state2, dispatch] = useReducer(reducer, void 0, () => ({
1130
1748
  session: startSession(playlist, Date.now()),
1131
1749
  lastEffect: null
1132
1750
  }));
1133
1751
  const completedRef = useRef(false);
1134
- const [tick, setTick] = useState5(0);
1752
+ const [tick, setTick] = useState7(0);
1135
1753
  const { exit } = useApp2();
1136
1754
  useInput2(
1137
1755
  (input, key) => {
@@ -1163,25 +1781,25 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled =
1163
1781
  },
1164
1782
  { isActive: enabled }
1165
1783
  );
1166
- useEffect3(() => {
1167
- if (state.session.finishedAt !== null && !completedRef.current) {
1784
+ useEffect4(() => {
1785
+ if (state2.session.finishedAt !== null && !completedRef.current) {
1168
1786
  completedRef.current = true;
1169
- onComplete(state.session);
1787
+ onComplete(state2.session);
1170
1788
  }
1171
- }, [state.session, onComplete]);
1172
- useEffect3(() => {
1173
- if (state.session.finishedAt !== null) return;
1789
+ }, [state2.session, onComplete]);
1790
+ useEffect4(() => {
1791
+ if (state2.session.finishedAt !== null) return;
1174
1792
  const id = setInterval(() => setTick((t) => t + 1), 1e3);
1175
1793
  return () => clearInterval(id);
1176
- }, [state.session.finishedAt]);
1177
- return { session: state.session, lastEffect: state.lastEffect, tick };
1794
+ }, [state2.session.finishedAt]);
1795
+ return { session: state2.session, lastEffect: state2.lastEffect, tick };
1178
1796
  }
1179
1797
 
1180
1798
  // src/ui/hooks/useAudio.ts
1181
- import { useEffect as useEffect4, useRef as useRef2 } from "react";
1799
+ import { useEffect as useEffect5, useRef as useRef2 } from "react";
1182
1800
  function useAudio(opts) {
1183
1801
  const initedRef = useRef2(false);
1184
- useEffect4(() => {
1802
+ useEffect5(() => {
1185
1803
  if (initedRef.current) return;
1186
1804
  initedRef.current = true;
1187
1805
  initAudio(!opts.enabled).catch(() => void 0);
@@ -1284,6 +1902,43 @@ function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
1284
1902
  return out;
1285
1903
  }
1286
1904
 
1905
+ // src/infra/session-tracker.ts
1906
+ var state = {
1907
+ startedAt: null,
1908
+ chapters: []
1909
+ };
1910
+ function start(now = Date.now()) {
1911
+ if (state.startedAt === null) state.startedAt = now;
1912
+ }
1913
+ function addChapter(entry) {
1914
+ if (state.startedAt === null) state.startedAt = Date.now();
1915
+ state.chapters.push(entry);
1916
+ }
1917
+ function report(now = Date.now()) {
1918
+ const chapters = state.chapters;
1919
+ const wordCount = chapters.reduce((a, c) => a + c.wordCount, 0);
1920
+ const errors = chapters.reduce((a, c) => a + c.errors, 0);
1921
+ const practiceMs = chapters.reduce((a, c) => a + c.durationMs, 0);
1922
+ const minutes = practiceMs / 6e4;
1923
+ const wpm = minutes > 0 ? Math.round(wordCount / minutes * 10) / 10 : 0;
1924
+ const errorWordSet = /* @__PURE__ */ new Set();
1925
+ for (const c of chapters) {
1926
+ for (const w of Object.keys(c.perWordErrors)) errorWordSet.add(w);
1927
+ }
1928
+ const accuracy2 = wordCount === 0 ? 1 : Math.max(0, (wordCount - errorWordSet.size) / wordCount);
1929
+ return {
1930
+ startedAt: state.startedAt,
1931
+ totalDurationMs: state.startedAt === null ? 0 : now - state.startedAt,
1932
+ chaptersCompleted: chapters.length,
1933
+ wordCount,
1934
+ errors,
1935
+ wpm,
1936
+ accuracy: accuracy2,
1937
+ newMistakeWords: errorWordSet.size,
1938
+ practiceMs
1939
+ };
1940
+ }
1941
+
1287
1942
  // src/ui/hooks/useSessionPersistence.ts
1288
1943
  function useSessionPersistence(meta) {
1289
1944
  return useCallback3(
@@ -1299,6 +1954,15 @@ function useSessionPersistence(meta) {
1299
1954
  perWordErrors: summary.perWordErrors
1300
1955
  };
1301
1956
  await appendSession(rec);
1957
+ addChapter({
1958
+ dictId: meta.dictId,
1959
+ chapterIndex: meta.chapterIndex,
1960
+ mode: meta.mode,
1961
+ wordCount: summary.wordCount,
1962
+ errors: summary.errors,
1963
+ durationMs: summary.durationMs,
1964
+ perWordErrors: summary.perWordErrors
1965
+ });
1302
1966
  const dirty = Object.entries(summary.perWordErrors).filter(([, n]) => n > 0);
1303
1967
  if (dirty.length === 0) return;
1304
1968
  let book = await loadMistakes();
@@ -1309,16 +1973,80 @@ function useSessionPersistence(meta) {
1309
1973
  );
1310
1974
  }
1311
1975
 
1976
+ // src/ui/screens/StealthPracticeLayout.tsx
1977
+ import { Box as Box4, Text as Text3 } from "ink";
1978
+ import { jsx as jsx9, jsxs as jsxs3 } from "react/jsx-runtime";
1979
+ var TYPED = "#d4d4d4";
1980
+ var UNTYPED = "#808080";
1981
+ var DIM = "#6b6b6b";
1982
+ function fmtTime(ms) {
1983
+ const total = Math.floor(ms / 1e3);
1984
+ const m = Math.floor(total / 60);
1985
+ const s = total % 60;
1986
+ return `${m}:${String(s).padStart(2, "0")}`;
1987
+ }
1988
+ function StealthTyping(props) {
1989
+ const t = useStrings();
1990
+ const target = [...props.target];
1991
+ const typed = [...props.typed];
1992
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", width: "100%", height: "100%", paddingX: 4, paddingY: 3, children: [
1993
+ /* @__PURE__ */ jsx9(Box4, { flexGrow: 1 }),
1994
+ /* @__PURE__ */ jsxs3(Box4, { children: [
1995
+ /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: "[" }),
1996
+ target.map((ch, i) => {
1997
+ const isTyped = i < typed.length;
1998
+ const display = props.hideTarget && !isTyped ? "_" : isTyped ? typed[i] : ch;
1999
+ const color = isTyped ? TYPED : UNTYPED;
2000
+ return /* @__PURE__ */ jsx9(Text3, { color, inverse: props.error && isTyped && i === typed.length - 1, children: display }, i);
2001
+ }),
2002
+ /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: "]" })
2003
+ ] }),
2004
+ /* @__PURE__ */ jsxs3(Box4, { children: [
2005
+ props.phonetic && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: props.phonetic }),
2006
+ props.phonetic && props.translation.length > 0 && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: " \xB7 " }),
2007
+ props.translation.length > 0 && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: props.translation.slice(0, 1).join("") })
2008
+ ] }),
2009
+ /* @__PURE__ */ jsx9(Box4, { children: props.info.visible ? /* @__PURE__ */ jsx9(Text3, { color: DIM, children: t.stealth.infoFmt(
2010
+ props.info.dictName,
2011
+ props.info.chapterLabel,
2012
+ props.info.completed,
2013
+ props.info.total,
2014
+ props.info.wpm,
2015
+ props.info.accPct
2016
+ ) }) : /* @__PURE__ */ jsx9(Text3, { children: " " }) }),
2017
+ /* @__PURE__ */ jsx9(Box4, { flexGrow: 3 })
2018
+ ] });
2019
+ }
2020
+ function StealthPaused() {
2021
+ const t = useStrings();
2022
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", width: "100%", height: "100%", paddingX: 4, paddingY: 3, children: [
2023
+ /* @__PURE__ */ jsx9(Box4, { flexGrow: 1 }),
2024
+ /* @__PURE__ */ jsx9(Box4, { children: /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: t.stealth.paused }) }),
2025
+ /* @__PURE__ */ jsx9(Box4, { children: /* @__PURE__ */ jsx9(Text3, { color: DIM, children: t.stealth.resumeHint }) }),
2026
+ /* @__PURE__ */ jsx9(Box4, { flexGrow: 3 })
2027
+ ] });
2028
+ }
2029
+ function StealthSummary(props) {
2030
+ const t = useStrings();
2031
+ const line = `${t.stealth.chapterDone} \xB7 ${props.wordCount}w \xB7 ${props.wpm}wpm \xB7 ${props.accPct}% \xB7 ${fmtTime(props.durationMs)}`;
2032
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", width: "100%", height: "100%", paddingX: 4, paddingY: 3, children: [
2033
+ /* @__PURE__ */ jsx9(Box4, { flexGrow: 1 }),
2034
+ /* @__PURE__ */ jsx9(Box4, { children: /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: line }) }),
2035
+ /* @__PURE__ */ jsx9(Box4, { children: /* @__PURE__ */ jsx9(Text3, { color: DIM, children: t.stealth.nextHint }) }),
2036
+ /* @__PURE__ */ jsx9(Box4, { flexGrow: 3 })
2037
+ ] });
2038
+ }
2039
+
1312
2040
  // src/ui/screens/PracticeScreen.tsx
1313
- import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
2041
+ import { jsx as jsx10, jsxs as jsxs4 } from "react/jsx-runtime";
1314
2042
  function PracticeScreen({ params }) {
1315
2043
  const { dictId, chapterIndex, mode } = params;
1316
2044
  const { cfg } = useAppState();
1317
- const nav = useNav();
1318
- const [phase, setPhase] = useState6("loading");
1319
- const [loaded, setLoaded] = useState6(null);
1320
- const [errorMsg, setErrorMsg] = useState6(null);
1321
- useEffect5(() => {
2045
+ const t = useStrings();
2046
+ const [phase, setPhase] = useState8("loading");
2047
+ const [loaded, setLoaded] = useState8(null);
2048
+ const [errorMsg, setErrorMsg] = useState8(null);
2049
+ useEffect6(() => {
1322
2050
  let cancelled = false;
1323
2051
  setPhase("loading");
1324
2052
  setLoaded(null);
@@ -1332,7 +2060,7 @@ function PracticeScreen({ params }) {
1332
2060
  if (cancelled) return;
1333
2061
  const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
1334
2062
  if (reviewWords.length === 0) {
1335
- setErrorMsg("No mistakes to review yet. Practice some chapters first.");
2063
+ setErrorMsg(t.practice.errors.noMistakes);
1336
2064
  setPhase("error");
1337
2065
  return;
1338
2066
  }
@@ -1342,7 +2070,7 @@ function PracticeScreen({ params }) {
1342
2070
  }
1343
2071
  const chapters = chunkChapters(words, cfg.chapterSize);
1344
2072
  if (chapters.length === 0) {
1345
- setErrorMsg(`Dictionary ${dictId} is empty.`);
2073
+ setErrorMsg(t.practice.errors.dictEmpty(dictId));
1346
2074
  setPhase("error");
1347
2075
  return;
1348
2076
  }
@@ -1359,15 +2087,15 @@ function PracticeScreen({ params }) {
1359
2087
  return () => {
1360
2088
  cancelled = true;
1361
2089
  };
1362
- }, [dictId, chapterIndex, mode, cfg.chapterSize]);
2090
+ }, [dictId, chapterIndex, mode, cfg.chapterSize, t]);
1363
2091
  if (phase === "loading") {
1364
- return /* @__PURE__ */ jsx7(Centered, { text: "loading\u2026", color: PALETTE.muted });
2092
+ return /* @__PURE__ */ jsx10(Centered, { text: t.practice.loading, color: PALETTE.muted });
1365
2093
  }
1366
2094
  if (phase === "error") {
1367
- return /* @__PURE__ */ jsx7(ErrorView, { msg: errorMsg ?? "Unknown error" });
2095
+ return /* @__PURE__ */ jsx10(ErrorView, { msg: errorMsg ?? t.practice.errors.unknown });
1368
2096
  }
1369
2097
  if (!loaded) return null;
1370
- return /* @__PURE__ */ jsx7(
2098
+ return /* @__PURE__ */ jsx10(
1371
2099
  PracticeRunner,
1372
2100
  {
1373
2101
  params,
@@ -1375,7 +2103,7 @@ function PracticeScreen({ params }) {
1375
2103
  phase,
1376
2104
  setPhase
1377
2105
  },
1378
- `${dictId}-${chapterIndex}-${mode}`
2106
+ `${dictId}-${chapterIndex}-${mode}-${params.stealth ? "s" : "n"}`
1379
2107
  );
1380
2108
  }
1381
2109
  function PracticeRunner({
@@ -1385,19 +2113,22 @@ function PracticeRunner({
1385
2113
  setPhase
1386
2114
  }) {
1387
2115
  const { dictId, chapterIndex, mode } = params;
2116
+ const stealth = params.stealth === true;
1388
2117
  const { cfg } = useAppState();
1389
2118
  const nav = useNav();
1390
2119
  const { exit } = useApp3();
1391
2120
  const goBack = () => nav.stack.length > 1 ? nav.back() : exit();
1392
2121
  const persist = useSessionPersistence({ dictId, chapterIndex, mode });
2122
+ const dictName = useDictName(dictId);
1393
2123
  const audio = useAudio({
1394
- enabled: cfg.sounds.master,
2124
+ enabled: !stealth && cfg.sounds.master,
1395
2125
  accent: cfg.accent,
1396
- autoplayPronunciation: cfg.autoplayPronunciation
2126
+ autoplayPronunciation: !stealth && cfg.autoplayPronunciation
1397
2127
  });
1398
2128
  const finishedRef = useRef3(false);
1399
2129
  const lastEffectRef = useRef3(null);
1400
2130
  const lastIndexRef = useRef3(-1);
2131
+ const [infoVisible, setInfoVisible] = useState8(false);
1401
2132
  const { session, lastEffect, tick } = useWordLoop({
1402
2133
  playlist: loaded.playlist,
1403
2134
  enabled: phase === "typing",
@@ -1410,12 +2141,13 @@ function PracticeRunner({
1410
2141
  });
1411
2142
  },
1412
2143
  onEscape: () => setPhase(phase === "paused" ? "typing" : "paused"),
1413
- onTab: () => {
2144
+ onTab: stealth ? void 0 : () => {
1414
2145
  const cur = session.current ? loaded.playlist[session.current.wordIndex] : void 0;
1415
2146
  if (cur) void audio.pronounce(cur.name);
1416
2147
  }
1417
2148
  });
1418
- useEffect5(() => {
2149
+ useEffect6(() => {
2150
+ if (stealth) return;
1419
2151
  if (lastEffect === null) return;
1420
2152
  if (lastEffect === lastEffectRef.current) return;
1421
2153
  lastEffectRef.current = lastEffect;
@@ -1425,8 +2157,9 @@ function PracticeRunner({
1425
2157
  if (cfg.sounds.feedback) audio.correct();
1426
2158
  if (cfg.sounds.keystroke) audio.keystroke();
1427
2159
  }
1428
- }, [lastEffect, audio, cfg.sounds.feedback, cfg.sounds.keystroke]);
1429
- useEffect5(() => {
2160
+ }, [stealth, lastEffect, audio, cfg.sounds.feedback, cfg.sounds.keystroke]);
2161
+ useEffect6(() => {
2162
+ if (stealth) return;
1430
2163
  const idx = session.current?.wordIndex ?? -1;
1431
2164
  if (idx === -1) return;
1432
2165
  if (idx === lastIndexRef.current) return;
@@ -1435,63 +2168,148 @@ function PracticeRunner({
1435
2168
  const next = loaded.playlist[idx + 1];
1436
2169
  if (cur && cfg.autoplayPronunciation) audio.pronounce(cur.name);
1437
2170
  if (next) audio.prefetch(next.name);
1438
- }, [session.current?.wordIndex, audio, cfg.autoplayPronunciation, loaded.playlist]);
2171
+ }, [stealth, session.current?.wordIndex, audio, cfg.autoplayPronunciation, loaded.playlist]);
1439
2172
  void tick;
1440
2173
  useInput3(
1441
- (input) => {
1442
- if (input === "r") setPhase("typing");
1443
- if (input === "q") goBack();
2174
+ (input, key) => {
2175
+ if (key.ctrl && input === "i") {
2176
+ setInfoVisible((v) => !v);
2177
+ return;
2178
+ }
2179
+ },
2180
+ { isActive: stealth && phase === "typing" }
2181
+ );
2182
+ useInput3(
2183
+ (_input, key) => {
2184
+ if (key.return) {
2185
+ setPhase("typing");
2186
+ return;
2187
+ }
2188
+ if (key.escape) {
2189
+ goBack();
2190
+ return;
2191
+ }
1444
2192
  },
1445
2193
  { isActive: phase === "paused" }
1446
2194
  );
1447
2195
  useInput3(
1448
- (input) => {
1449
- if (input === "q") {
2196
+ (input, key) => {
2197
+ if (key.escape) {
1450
2198
  goBack();
1451
2199
  return;
1452
2200
  }
1453
- if (input === "n") {
2201
+ if (key.return) {
1454
2202
  const nextIdx = chapterIndex + 1;
1455
2203
  if (mode === "loop") {
1456
- nav.replace({ name: "practice", params: { dictId, chapterIndex, mode } });
2204
+ nav.replace({
2205
+ name: "practice",
2206
+ params: { dictId, chapterIndex, mode, stealth: params.stealth }
2207
+ });
1457
2208
  } else if (mode === "review" || nextIdx >= loaded.totalChapters) {
1458
2209
  goBack();
1459
2210
  } else {
1460
- nav.replace({ name: "practice", params: { dictId, chapterIndex: nextIdx, mode } });
2211
+ nav.replace({
2212
+ name: "practice",
2213
+ params: { dictId, chapterIndex: nextIdx, mode, stealth: params.stealth }
2214
+ });
1461
2215
  }
1462
2216
  return;
1463
2217
  }
1464
2218
  if (input === "m") {
1465
- nav.replace({ name: "practice", params: { dictId, chapterIndex: 0, mode: "review" } });
2219
+ nav.replace({
2220
+ name: "practice",
2221
+ params: { dictId, chapterIndex: 0, mode: "review", stealth: params.stealth }
2222
+ });
1466
2223
  return;
1467
2224
  }
1468
2225
  },
1469
2226
  { isActive: phase === "summary" }
1470
2227
  );
1471
- if (phase === "paused") return /* @__PURE__ */ jsx7(PausedView, {});
1472
- if (phase === "summary") {
1473
- return /* @__PURE__ */ jsx7(
2228
+ const completed = session.results.length;
2229
+ const errors = session.results.reduce((a, r) => a + r.errors, 0);
2230
+ const elapsedMs = Date.now() - session.startedAt;
2231
+ const minutes = elapsedMs / 6e4;
2232
+ const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
2233
+ const summary = phase === "summary" ? sessionSummary(session) : null;
2234
+ if (stealth) {
2235
+ if (phase === "paused") return /* @__PURE__ */ jsx10(StealthPaused, {});
2236
+ if (phase === "summary" && summary) {
2237
+ const sMinutes = summary.durationMs / 6e4;
2238
+ const sWpm = sMinutes > 0 ? Math.round(summary.wordCount / sMinutes * 10) / 10 : 0;
2239
+ const sErrWords = Object.keys(summary.perWordErrors).length;
2240
+ const sAcc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - sErrWords) / summary.wordCount);
2241
+ const sAccPct = Math.round(sAcc * 1e3) / 10;
2242
+ return /* @__PURE__ */ jsx10(
2243
+ StealthSummary,
2244
+ {
2245
+ wordCount: summary.wordCount,
2246
+ errors: summary.errors,
2247
+ durationMs: summary.durationMs,
2248
+ wpm: sWpm,
2249
+ accPct: sAccPct
2250
+ }
2251
+ );
2252
+ }
2253
+ const currentWord2 = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
2254
+ const inputState2 = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
2255
+ const errWords = new Set(
2256
+ session.results.filter((r) => r.errors > 0).map((r) => r.word)
2257
+ ).size;
2258
+ const accFrac = completed === 0 ? 1 : Math.max(0, (completed - errWords) / completed);
2259
+ const accPct = Math.round(accFrac * 1e3) / 10;
2260
+ const chapterLabel = mode === "review" ? "review" : `ch ${chapterIndex + 1}/${loaded.totalChapters}`;
2261
+ return /* @__PURE__ */ jsx10(
2262
+ StealthTyping,
2263
+ {
2264
+ target: currentWord2?.name ?? "",
2265
+ typed: inputState2.typed,
2266
+ hideTarget: mode === "dictation",
2267
+ phonetic: pickPhonetic(currentWord2, cfg.accent),
2268
+ translation: currentWord2?.trans ?? [],
2269
+ error: lastEffect === "wrong",
2270
+ info: {
2271
+ visible: infoVisible,
2272
+ dictName: truncateName(dictName, 24),
2273
+ chapterLabel,
2274
+ completed,
2275
+ total: loaded.playlist.length,
2276
+ wpm,
2277
+ accPct
2278
+ }
2279
+ }
2280
+ );
2281
+ }
2282
+ if (phase === "paused") {
2283
+ return /* @__PURE__ */ jsx10(
2284
+ PausedView,
2285
+ {
2286
+ dictName,
2287
+ chapterIndex,
2288
+ totalChapters: loaded.totalChapters,
2289
+ mode,
2290
+ completed,
2291
+ total: loaded.playlist.length
2292
+ }
2293
+ );
2294
+ }
2295
+ if (phase === "summary" && summary) {
2296
+ return /* @__PURE__ */ jsx10(
1474
2297
  SummaryView,
1475
2298
  {
1476
- dictId,
2299
+ dictName,
1477
2300
  chapterIndex,
1478
2301
  totalChapters: loaded.totalChapters,
1479
2302
  mode,
1480
- summary: sessionSummary(session)
2303
+ summary
1481
2304
  }
1482
2305
  );
1483
2306
  }
1484
2307
  const currentWord = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
1485
2308
  const inputState = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
1486
- const elapsedMs = Date.now() - session.startedAt;
1487
- const completed = session.results.length;
1488
- const errors = session.results.reduce((a, r) => a + r.errors, 0);
1489
- const minutes = elapsedMs / 6e4;
1490
- const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
1491
- return /* @__PURE__ */ jsx7(
2309
+ return /* @__PURE__ */ jsx10(
1492
2310
  TypingLayout,
1493
2311
  {
1494
- dictId,
2312
+ dictName,
1495
2313
  chapterIndex,
1496
2314
  totalChapters: loaded.totalChapters,
1497
2315
  mode,
@@ -1515,19 +2333,20 @@ function pickPhonetic(word, accent) {
1515
2333
  const p = accent === "us" ? word.usphone : word.ukphone;
1516
2334
  return p ? `/${p}/` : null;
1517
2335
  }
1518
- function fmtTime(ms) {
2336
+ function fmtTime2(ms) {
1519
2337
  const total = Math.floor(ms / 1e3);
1520
2338
  const m = Math.floor(total / 60);
1521
2339
  const s = total % 60;
1522
2340
  return `${m}:${String(s).padStart(2, "0")}`;
1523
2341
  }
1524
2342
  function TypingLayout(props) {
2343
+ const t = useStrings();
1525
2344
  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(
2345
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2346
+ /* @__PURE__ */ jsx10(
1528
2347
  StatusBar,
1529
2348
  {
1530
- dictId: props.dictId,
2349
+ dictName: props.dictName,
1531
2350
  chapterIndex: props.chapterIndex,
1532
2351
  totalChapters: props.totalChapters,
1533
2352
  mode: props.mode,
@@ -1537,8 +2356,8 @@ function TypingLayout(props) {
1537
2356
  elapsedMs: props.elapsedMs
1538
2357
  }
1539
2358
  ),
1540
- /* @__PURE__ */ jsxs3(Box3, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
1541
- /* @__PURE__ */ jsx7(
2359
+ /* @__PURE__ */ jsxs4(Box5, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
2360
+ /* @__PURE__ */ jsx10(
1542
2361
  BigWord,
1543
2362
  {
1544
2363
  target: props.target,
@@ -1547,34 +2366,41 @@ function TypingLayout(props) {
1547
2366
  hideTarget: props.hideTarget
1548
2367
  }
1549
2368
  ),
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)) })
2369
+ props.phonetic && /* @__PURE__ */ jsx10(Box5, { marginTop: 3, children: /* @__PURE__ */ jsx10(Text4, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
2370
+ props.translation.length > 0 && /* @__PURE__ */ jsx10(Box5, { marginTop: 2, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((tr, i) => /* @__PURE__ */ jsx10(Text4, { color: PALETTE.primary, children: tr }, i)) })
1552
2371
  ] }),
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: [
2372
+ /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
2373
+ /* @__PURE__ */ jsx10(ProgressBar, { frac: progressFrac }),
2374
+ /* @__PURE__ */ jsx10(Box5, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
1556
2375
  props.completed,
1557
2376
  "/",
1558
2377
  props.total,
1559
2378
  " \xB7 ",
1560
- fmtTime(props.elapsedMs),
2379
+ fmtTime2(props.elapsedMs),
1561
2380
  " \xB7 ",
1562
2381
  props.wpm,
1563
- " wpm \xB7 ",
2382
+ " ",
2383
+ t.practice.statCards.wpm,
2384
+ " \xB7 ",
1564
2385
  props.errors,
1565
- " errors"
2386
+ " ",
2387
+ t.practice.statCards.errors
1566
2388
  ] }) }),
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" }) })
2389
+ /* @__PURE__ */ jsx10(Box5, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.footers.typing }) })
1568
2390
  ] })
1569
2391
  ] });
1570
2392
  }
1571
2393
  function StatusBar(props) {
1572
- const left = props.mode === "review" ? `${props.dictId} \xB7 review \xB7 ${props.accent}` : `${props.dictId} \xB7 ch ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode} \xB7 ${props.accent}`;
1573
- const right = `${props.completed}/${props.total} \xB7 ${fmtTime(props.elapsedMs)}`;
1574
- return /* @__PURE__ */ jsxs3(Box3, { children: [
1575
- /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: left }),
1576
- /* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
1577
- /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: right })
2394
+ const t = useStrings();
2395
+ const modeName = t.practice.modes[props.mode];
2396
+ const accentName = t.practice.accents[props.accent];
2397
+ const name = truncateName(props.dictName, 20);
2398
+ const left = props.mode === "review" ? `${name} \xB7 ${t.practice.reviewLabel} \xB7 ${accentName}` : `${name} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName} \xB7 ${accentName}`;
2399
+ const right = `${props.completed}/${props.total} \xB7 ${fmtTime2(props.elapsedMs)}`;
2400
+ return /* @__PURE__ */ jsxs4(Box5, { children: [
2401
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: left }),
2402
+ /* @__PURE__ */ jsx10(Box5, { flexGrow: 1 }),
2403
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: right })
1578
2404
  ] });
1579
2405
  }
1580
2406
  function ProgressBar({ frac }) {
@@ -1582,33 +2408,43 @@ function ProgressBar({ frac }) {
1582
2408
  const width = Math.max(20, Math.min(72, cols - 16));
1583
2409
  const filled = Math.round(width * Math.max(0, Math.min(1, frac)));
1584
2410
  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) })
2411
+ return /* @__PURE__ */ jsxs4(Box5, { justifyContent: "center", children: [
2412
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
2413
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
1588
2414
  ] });
1589
2415
  }
1590
- function PausedView() {
1591
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
1592
- /* @__PURE__ */ jsx7(BigText2, { text: "paused", font: "tiny", colors: [PALETTE.warning] }),
1593
- /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[r] resume \xB7 [q] quit to menu" }) })
2416
+ function PausedView(props) {
2417
+ const t = useStrings();
2418
+ const frac = props.total === 0 ? 0 : props.completed / props.total;
2419
+ const subtitle = props.mode === "review" ? `${truncateName(props.dictName, 20)} \xB7 ${t.practice.reviewLabel}` : `${truncateName(props.dictName, 20)} \xB7 ${t.practice.pause.chapter(props.chapterIndex + 1, props.totalChapters)}`;
2420
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2421
+ /* @__PURE__ */ jsx10(Text4, { bold: true, color: PALETTE.warning, children: t.practice.pause.title }),
2422
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: subtitle }) }),
2423
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(ProgressBar, { frac }) }),
2424
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.pause.progress(props.completed, props.total) }) }),
2425
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.pause.hint }) })
1594
2426
  ] });
1595
2427
  }
1596
2428
  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, {})
2429
+ const t = useStrings();
2430
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2431
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.error, children: msg }),
2432
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
2433
+ "Esc ",
2434
+ t.common.back
2435
+ ] }) }),
2436
+ /* @__PURE__ */ jsx10(BackKey, {})
1601
2437
  ] });
1602
2438
  }
1603
2439
  function BackKey() {
1604
2440
  const nav = useNav();
1605
- useInput3((input, key) => {
1606
- if (input === "q" || key.escape) nav.back();
2441
+ useInput3((_input, key) => {
2442
+ if (key.escape) nav.back();
1607
2443
  });
1608
2444
  return null;
1609
2445
  }
1610
2446
  function Centered({ text, color }) {
1611
- return /* @__PURE__ */ jsx7(Box3, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx7(Text3, { color, children: text }) });
2447
+ return /* @__PURE__ */ jsx10(Box5, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx10(Text4, { color, children: text }) });
1612
2448
  }
1613
2449
  function SummaryView(props) {
1614
2450
  const { summary } = props;
@@ -1617,52 +2453,126 @@ function SummaryView(props) {
1617
2453
  const errorWords = Object.keys(summary.perWordErrors).length;
1618
2454
  const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
1619
2455
  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(
2456
+ const t = useStrings();
2457
+ const modeName = t.practice.modes[props.mode];
2458
+ const name = truncateName(props.dictName, 20);
2459
+ const subtitle = props.mode === "review" ? `${name} \xB7 ${t.practice.reviewLabel}` : `${name} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName}`;
2460
+ const nextLabel = props.mode === "loop" ? t.practice.summary.loopAgain : props.mode === "review" || props.chapterIndex + 1 >= props.totalChapters ? t.practice.summary.backMenu : t.practice.summary.nextChapter;
2461
+ const footer = `Enter ${nextLabel} \xB7 m ${t.practice.summary.reviewMistakes} \xB7 Esc ${t.practice.summary.backMenu}`;
2462
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
2463
+ /* @__PURE__ */ jsx10(Text4, { bold: true, color: PALETTE.success, children: t.practice.chapterComplete }),
2464
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: subtitle }) }),
2465
+ /* @__PURE__ */ jsxs4(Box5, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
2466
+ /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.words, value: String(summary.wordCount), color: PALETTE.text }),
2467
+ /* @__PURE__ */ jsx10(
1627
2468
  StatCard,
1628
2469
  {
1629
- label: "errors",
2470
+ label: t.practice.statCards.errors,
1630
2471
  value: String(summary.errors),
1631
2472
  color: summary.errors > 0 ? PALETTE.error : PALETTE.muted
1632
2473
  }
1633
2474
  ),
1634
- /* @__PURE__ */ jsx7(StatCard, { label: "wpm", value: String(wpm), color: PALETTE.accent }),
1635
- /* @__PURE__ */ jsx7(StatCard, { label: "accuracy", value: `${accPct}%`, color: PALETTE.accent })
2475
+ /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.wpm, value: String(wpm), color: PALETTE.accent }),
2476
+ /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.accuracy, value: `${accPct}%`, color: PALETTE.accent })
1636
2477
  ] }),
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" }) })
2478
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.statCards.elapsed(fmtTime2(summary.durationMs)) }) }),
2479
+ /* @__PURE__ */ jsx10(Box5, { flexGrow: 1 }),
2480
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: footer }) })
1643
2481
  ] });
1644
2482
  }
1645
2483
  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 })
2484
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", alignItems: "center", marginX: 3, children: [
2485
+ /* @__PURE__ */ jsx10(Text4, { bold: true, color, children: value }),
2486
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: label })
1649
2487
  ] });
1650
2488
  }
1651
2489
 
1652
2490
  // 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";
2491
+ import { useEffect as useEffect7, useMemo as useMemo2, useState as useState10 } from "react";
2492
+ import { Box as Box7, Text as Text6, useInput as useInput5, useStdout as useStdout3 } from "ink";
2493
+
2494
+ // src/ui/components/ActionPanel.tsx
2495
+ import { useState as useState9 } from "react";
2496
+ import { Box as Box6, Text as Text5, useInput as useInput4 } from "ink";
2497
+ import { jsx as jsx11, jsxs as jsxs5 } from "react/jsx-runtime";
2498
+ function ActionPanel({ title, items, onClose }) {
2499
+ const enabledIndices = items.map((it, i) => it.disabled ? -1 : i).filter((i) => i >= 0);
2500
+ const initial = enabledIndices[0] ?? 0;
2501
+ const [selected, setSelected] = useState9(initial);
2502
+ useInput4((input, key) => {
2503
+ if (key.escape) {
2504
+ onClose();
2505
+ return;
2506
+ }
2507
+ if (key.upArrow) {
2508
+ const cur = enabledIndices.indexOf(selected);
2509
+ const next = enabledIndices[(cur - 1 + enabledIndices.length) % enabledIndices.length];
2510
+ if (next !== void 0) setSelected(next);
2511
+ return;
2512
+ }
2513
+ if (key.downArrow) {
2514
+ const cur = enabledIndices.indexOf(selected);
2515
+ const next = enabledIndices[(cur + 1) % enabledIndices.length];
2516
+ if (next !== void 0) setSelected(next);
2517
+ return;
2518
+ }
2519
+ if (key.return) {
2520
+ const item = items[selected];
2521
+ if (item && !item.disabled) {
2522
+ void item.run();
2523
+ }
2524
+ return;
2525
+ }
2526
+ for (let i = 0; i < items.length; i++) {
2527
+ const it = items[i];
2528
+ if (it.disabled) continue;
2529
+ if (it.key && input === it.key) {
2530
+ void it.run();
2531
+ return;
2532
+ }
2533
+ }
2534
+ });
2535
+ const maxLabel = Math.max(...items.map((it) => it.label.length));
2536
+ const width = Math.max(maxLabel + 8, title.length + 4, 24);
2537
+ return /* @__PURE__ */ jsxs5(
2538
+ Box6,
2539
+ {
2540
+ flexDirection: "column",
2541
+ borderStyle: "round",
2542
+ borderColor: PALETTE.accent,
2543
+ paddingX: 2,
2544
+ paddingY: 1,
2545
+ width,
2546
+ children: [
2547
+ /* @__PURE__ */ jsx11(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx11(Text5, { bold: true, color: PALETTE.accent, children: title }) }),
2548
+ items.map((it, i) => {
2549
+ const active = i === selected;
2550
+ const color = it.disabled ? PALETTE.muted : active ? PALETTE.text : PALETTE.muted;
2551
+ return /* @__PURE__ */ jsxs5(Box6, { children: [
2552
+ /* @__PURE__ */ jsx11(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2553
+ /* @__PURE__ */ jsx11(Text5, { bold: active, color, children: it.label })
2554
+ ] }, i);
2555
+ }),
2556
+ /* @__PURE__ */ jsx11(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text5, { color: PALETTE.muted, children: "\u2191/\u2193 \xB7 Enter \xB7 Esc" }) })
2557
+ ]
2558
+ }
2559
+ );
2560
+ }
2561
+
2562
+ // src/ui/screens/DictBrowser.tsx
2563
+ import { Fragment, jsx as jsx12, jsxs as jsxs6 } from "react/jsx-runtime";
1656
2564
  function DictBrowser({ params }) {
1657
2565
  const nav = useNav();
1658
2566
  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);
2567
+ const t = useStrings();
2568
+ const { stdout } = useStdout3();
2569
+ const [rows, setRows] = useState10([]);
2570
+ const [loading, setLoading] = useState10(true);
2571
+ const [selected, setSelected] = useState10(0);
2572
+ const [filter, setFilter] = useState10("");
2573
+ const [pending, setPending] = useState10(null);
2574
+ const [tick, setTick] = useState10(0);
2575
+ const [panel, setPanel] = useState10(null);
1666
2576
  const refresh = async () => {
1667
2577
  const reg = await loadRegistry();
1668
2578
  const flagged = await Promise.all(
@@ -1671,168 +2581,227 @@ function DictBrowser({ params }) {
1671
2581
  setRows(flagged);
1672
2582
  setLoading(false);
1673
2583
  };
1674
- useEffect6(() => {
2584
+ useEffect7(() => {
1675
2585
  void refresh();
1676
2586
  }, [tick]);
1677
- const filtered = filter ? rows.filter((r) => filterRegistry([r.entry], filter).length > 0) : rows;
2587
+ const filtered = useMemo2(
2588
+ () => filter ? rows.filter((r) => filterRegistry([r.entry], filter).length > 0) : rows,
2589
+ [filter, rows]
2590
+ );
1678
2591
  const safeSelected = Math.max(0, Math.min(filtered.length - 1, selected));
1679
2592
  const current = filtered[safeSelected];
1680
- useInput4((input, key) => {
1681
- if (filterFocus) {
1682
- if (key.escape || key.return) {
1683
- setFilterFocus(false);
1684
- return;
2593
+ const rowsTotal = stdout?.rows ?? 24;
2594
+ const visibleH = Math.max(6, rowsTotal - 8);
2595
+ const half = Math.floor(visibleH / 2);
2596
+ const start2 = Math.max(0, Math.min(filtered.length - visibleH, safeSelected - half));
2597
+ const end = Math.min(filtered.length, start2 + visibleH);
2598
+ const goPractice = (id) => {
2599
+ nav.replace({
2600
+ name: "practice",
2601
+ params: {
2602
+ dictId: id,
2603
+ chapterIndex: 0,
2604
+ mode: cfg.defaultMode,
2605
+ stealth: cfg.stealth === "default"
1685
2606
  }
1686
- if (key.backspace || key.delete) {
1687
- setFilter((f) => f.slice(0, -1));
1688
- return;
2607
+ });
2608
+ };
2609
+ const doSetDefault = async (id, navigate = true) => {
2610
+ await setCfg({ ...cfg, defaultDict: id });
2611
+ setPanel(null);
2612
+ if (navigate) {
2613
+ if (params?.pickerMode === "choose-then-practice") {
2614
+ goPractice(id);
2615
+ } else {
2616
+ nav.back();
2617
+ }
2618
+ }
2619
+ };
2620
+ const doDelete = (id) => {
2621
+ setPanel(null);
2622
+ setPending({ kind: "removing", id });
2623
+ void (async () => {
2624
+ try {
2625
+ await removeDictionary(id);
2626
+ setPending(null);
2627
+ setTick((n) => n + 1);
2628
+ } catch (err) {
2629
+ setPending({ kind: "error", id, msg: err.message });
1689
2630
  }
1690
- if (input && !key.ctrl && !key.meta) {
1691
- setFilter((f) => f + input);
2631
+ })();
2632
+ };
2633
+ const doPull = (id) => {
2634
+ setPanel(null);
2635
+ setPending({ kind: "pulling", id });
2636
+ void (async () => {
2637
+ try {
2638
+ await pullDictionary(id);
2639
+ setPending(null);
2640
+ setTick((n) => n + 1);
2641
+ } catch (err) {
2642
+ setPending({ kind: "error", id, msg: err.message });
1692
2643
  }
2644
+ })();
2645
+ };
2646
+ const doRefreshList = () => {
2647
+ setPanel(null);
2648
+ setPending({ kind: "refreshing" });
2649
+ setTick((n) => n + 1);
2650
+ setPending(null);
2651
+ };
2652
+ useInput5((input, key) => {
2653
+ if (panel !== null) return;
2654
+ if (key.escape) {
2655
+ nav.back();
1693
2656
  return;
1694
2657
  }
1695
- if (key.upArrow) setSelected((i) => Math.max(0, i - 1));
1696
- if (key.downArrow) setSelected((i) => Math.min(filtered.length - 1, i + 1));
1697
- if (input === "/") {
1698
- setFilterFocus(true);
2658
+ if (key.upArrow) {
2659
+ setSelected((i) => Math.max(0, i - 1));
1699
2660
  return;
1700
2661
  }
1701
- if (key.escape || input === "b") {
1702
- nav.back();
2662
+ if (key.downArrow) {
2663
+ setSelected((i) => Math.min(filtered.length - 1, i + 1));
1703
2664
  return;
1704
2665
  }
1705
- if (!current) return;
1706
- if (key.return) {
1707
- void (async () => {
1708
- await setCfg({ ...cfg, defaultDict: current.entry.id });
1709
- if (params?.pickerMode === "choose-then-practice") {
1710
- nav.replace({
1711
- name: "practice",
1712
- params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
1713
- });
1714
- } else {
1715
- nav.back();
1716
- }
1717
- })();
2666
+ if (key.ctrl && input === "k") {
2667
+ setPanel("more");
1718
2668
  return;
1719
2669
  }
1720
- if (input === "p") {
1721
- nav.replace({
1722
- name: "practice",
1723
- params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
1724
- });
2670
+ if (key.return) {
2671
+ if (current) setPanel("item");
1725
2672
  return;
1726
2673
  }
1727
- if (input === "r" && current.local) {
1728
- setPending({ kind: "removing", id: current.entry.id });
1729
- void (async () => {
1730
- try {
1731
- await removeDictionary(current.entry.id);
1732
- setPending(null);
1733
- setTick((t) => t + 1);
1734
- } catch (err) {
1735
- setPending({ kind: "error", id: current.entry.id, msg: err.message });
1736
- }
1737
- })();
2674
+ if (key.backspace || key.delete) {
2675
+ setFilter((f) => f.slice(0, -1));
2676
+ setSelected(0);
1738
2677
  return;
1739
2678
  }
1740
- if (input === "u") {
1741
- setPending({ kind: "pulling", id: current.entry.id });
1742
- void (async () => {
1743
- try {
1744
- await pullDictionary(current.entry.id);
1745
- setPending(null);
1746
- setTick((t) => t + 1);
1747
- } catch (err) {
1748
- setPending({ kind: "error", id: current.entry.id, msg: err.message });
1749
- }
1750
- })();
2679
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
2680
+ setFilter((f) => f + input);
2681
+ setSelected(0);
1751
2682
  }
1752
2683
  });
1753
2684
  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" }) });
2685
+ return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.loading }) });
1755
2686
  }
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` })
2687
+ const itemPanelItems = current ? [
2688
+ {
2689
+ label: t.dict.action.setDefault,
2690
+ run: () => void doSetDefault(current.entry.id, params?.pickerMode !== void 0)
2691
+ },
2692
+ {
2693
+ label: t.dict.action.practice,
2694
+ run: () => goPractice(current.entry.id)
2695
+ },
2696
+ {
2697
+ label: t.dict.action.delete,
2698
+ disabled: !current.local,
2699
+ run: () => doDelete(current.entry.id)
2700
+ },
2701
+ { label: t.common.cancel, run: () => setPanel(null) }
2702
+ ] : [];
2703
+ const morePanelItems = [
2704
+ {
2705
+ label: t.dict.command.pull,
2706
+ disabled: !current,
2707
+ run: () => current && doPull(current.entry.id)
2708
+ },
2709
+ {
2710
+ label: t.dict.command.import,
2711
+ disabled: true,
2712
+ run: () => void 0
2713
+ },
2714
+ { label: t.dict.command.refreshList, run: () => doRefreshList() },
2715
+ { label: t.common.cancel, run: () => setPanel(null) }
2716
+ ];
2717
+ if (panel === "item" && current) {
2718
+ return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(
2719
+ ActionPanel,
2720
+ {
2721
+ title: `${t.dict.action.title} \xB7 ${current.entry.name}`,
2722
+ items: itemPanelItems,
2723
+ onClose: () => setPanel(null)
2724
+ }
2725
+ ) });
2726
+ }
2727
+ if (panel === "more") {
2728
+ return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(
2729
+ ActionPanel,
2730
+ {
2731
+ title: t.dict.command.title,
2732
+ items: morePanelItems,
2733
+ onClose: () => setPanel(null)
2734
+ }
2735
+ ) });
2736
+ }
2737
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2738
+ /* @__PURE__ */ jsxs6(Box7, { children: [
2739
+ /* @__PURE__ */ jsx12(Text6, { bold: true, color: PALETTE.accent, children: t.dict.title }),
2740
+ /* @__PURE__ */ jsx12(Box7, { flexGrow: 1 }),
2741
+ /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: filter ? `${t.dict.filterPlaceholder}: ${filter}_` : `${t.dict.filterPlaceholder}_` }),
2742
+ /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2743
+ " ",
2744
+ t.dict.entries(filtered.length)
2745
+ ] })
1761
2746
  ] }),
1762
- /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexGrow: 1, children: [
1763
- /* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "60%", children: filtered.slice(Math.max(0, safeSelected - 8), safeSelected + 16).map((row, vi) => {
1764
- const i = Math.max(0, safeSelected - 8) + vi;
2747
+ /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, flexGrow: 1, children: [
2748
+ /* @__PURE__ */ jsx12(Box7, { flexDirection: "column", width: "75%", paddingRight: 1, children: filtered.slice(start2, end).map((row, vi) => {
2749
+ const i = start2 + vi;
1765
2750
  const active = i === safeSelected;
1766
2751
  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) })
2752
+ return /* @__PURE__ */ jsxs6(Box7, { children: [
2753
+ /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }) }),
2754
+ /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }) }),
2755
+ /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }) }),
2756
+ /* @__PURE__ */ jsx12(Box7, { flexGrow: 1, children: /* @__PURE__ */ jsx12(Text6, { bold: active, color: active ? PALETTE.text : PALETTE.muted, wrap: "truncate", children: row.entry.name }) }),
2757
+ /* @__PURE__ */ jsx12(Box7, { width: 6, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) }) })
1775
2758
  ] }, row.entry.id);
1776
2759
  }) }),
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: [
2760
+ /* @__PURE__ */ jsx12(Box7, { flexDirection: "column", width: "25%", paddingLeft: 1, children: current && /* @__PURE__ */ jsxs6(Fragment, { children: [
2761
+ /* @__PURE__ */ jsx12(Text6, { bold: true, color: PALETTE.text, wrap: "wrap", children: current.entry.name }),
2762
+ /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: current.entry.id }),
2763
+ /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, wrap: "wrap", children: [
1781
2764
  current.entry.language,
1782
2765
  " \xB7 ",
1783
- current.entry.category,
1784
- " \xB7 ",
1785
- current.entry.length,
1786
- " words"
1787
- ] }) }),
1788
- /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.primary, children: current.entry.description || "(no description)" }) }),
1789
- current.entry.tags.length > 0 && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
1790
- "tags: ",
1791
- current.entry.tags.join(", ")
2766
+ current.entry.category
1792
2767
  ] }) }),
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
- ] })
2768
+ /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.wordsLabel(current.entry.length) }) }),
2769
+ current.entry.description && /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.primary, wrap: "wrap", children: current.entry.description }) }),
2770
+ current.entry.tags.length > 0 && /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, wrap: "wrap", children: t.dict.tagsLabel(current.entry.tags.join(", ")) }) }),
2771
+ /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: current.local ? PALETTE.accent : PALETTE.muted, children: current.local ? t.dict.local : t.dict.notLocal }) }),
2772
+ cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx12(Box7, { children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.success, children: t.dict.defaultMark }) })
1797
2773
  ] }) })
1798
2774
  ] }),
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,
2775
+ pending && /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, children: [
2776
+ pending.kind === "pulling" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.warning, children: t.dict.pulling(pending.id) }),
2777
+ pending.kind === "removing" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.warning, children: t.dict.removing(pending.id) }),
2778
+ pending.kind === "refreshing" && /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.warning, children: [
2779
+ t.dict.command.refreshList,
1808
2780
  "\u2026"
1809
2781
  ] }),
1810
- pending.kind === "error" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.error, children: [
1811
- "error on ",
1812
- pending.id,
1813
- ": ",
1814
- pending.msg
1815
- ] })
2782
+ pending.kind === "error" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.error, children: t.dict.errorOn(pending.id, pending.msg) })
1816
2783
  ] }),
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" }) })
2784
+ /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.footer }) })
1818
2785
  ] });
1819
2786
  }
1820
2787
 
1821
2788
  // 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";
2789
+ import { useState as useState11 } from "react";
2790
+ import { Box as Box8, Text as Text7, useInput as useInput6 } from "ink";
2791
+ import { jsx as jsx13, jsxs as jsxs7 } from "react/jsx-runtime";
1825
2792
  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" }
2793
+ { kind: "dictRef", path: "defaultDict", labelKey: "defaultDict" },
2794
+ { kind: "enum", path: "defaultMode", labelKey: "defaultMode", options: ["order", "dictation", "review", "random", "loop"] },
2795
+ { kind: "enum", path: "accent", labelKey: "accent", options: ["us", "uk"] },
2796
+ { kind: "enum", path: "language", labelKey: "language", options: ["auto", "zh", "en"] },
2797
+ { kind: "enum", path: "mirror", labelKey: "mirror", options: ["jsdelivr", "github"] },
2798
+ { kind: "enum", path: "stealth", labelKey: "stealth", options: ["off", "menu", "default"] },
2799
+ { kind: "int", path: "chapterSize", labelKey: "chapterSize", min: 1, max: 200 },
2800
+ { kind: "bool", path: "autoplayPronunciation", labelKey: "autoplayPronunciation" },
2801
+ { kind: "bool", path: "sounds.master", labelKey: "soundsMaster" },
2802
+ { kind: "bool", path: "sounds.keystroke", labelKey: "soundsKeystroke" },
2803
+ { kind: "bool", path: "sounds.feedback", labelKey: "soundsFeedback" },
2804
+ { kind: "string", path: "sounds.keySoundName", labelKey: "soundsKeySound" }
1836
2805
  ];
1837
2806
  function getByPath2(cfg, path) {
1838
2807
  return path.split(".").reduce((acc, k) => {
@@ -1843,10 +2812,12 @@ function getByPath2(cfg, path) {
1843
2812
  function ConfigEditor() {
1844
2813
  const nav = useNav();
1845
2814
  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);
2815
+ const t = useStrings();
2816
+ const defaultDictName = useDictName(cfg.defaultDict);
2817
+ const [selected, setSelected] = useState11(0);
2818
+ const [editing, setEditing] = useState11(false);
2819
+ const [draft, setDraft] = useState11("");
2820
+ const [error, setError] = useState11(null);
1850
2821
  const field = FIELDS[selected];
1851
2822
  const currentValue = getByPath2(cfg, field.path);
1852
2823
  const commit = async (raw) => {
@@ -1859,7 +2830,7 @@ function ConfigEditor() {
1859
2830
  setError(err.message);
1860
2831
  }
1861
2832
  };
1862
- useInput5((input, key) => {
2833
+ useInput6((input, key) => {
1863
2834
  if (editing && field.kind === "string") {
1864
2835
  if (key.escape) {
1865
2836
  setEditing(false);
@@ -1894,7 +2865,7 @@ function ConfigEditor() {
1894
2865
  if (/^[0-9]$/.test(input)) setDraft((d) => d + input);
1895
2866
  return;
1896
2867
  }
1897
- if (key.escape || input === "b") {
2868
+ if (key.escape) {
1898
2869
  nav.back();
1899
2870
  return;
1900
2871
  }
@@ -1929,73 +2900,99 @@ function ConfigEditor() {
1929
2900
  setError(null);
1930
2901
  }
1931
2902
  });
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) => {
2903
+ const labelW = Math.max(...FIELDS.map((f) => visibleWidth2(t.config.fields[f.labelKey]))) + 4;
2904
+ return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2905
+ /* @__PURE__ */ jsx13(Text7, { bold: true, color: PALETTE.accent, children: t.config.title }),
2906
+ /* @__PURE__ */ jsx13(Box8, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
1935
2907
  const active = i === selected;
1936
2908
  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 })
2909
+ const display = renderValue(
2910
+ f,
2911
+ value,
2912
+ active && editing ? draft : null,
2913
+ t,
2914
+ f.path === "defaultDict" ? defaultDictName : ""
2915
+ );
2916
+ const label = t.config.fields[f.labelKey];
2917
+ const pad = " ".repeat(Math.max(0, labelW - visibleWidth2(label)));
2918
+ return /* @__PURE__ */ jsxs7(Box8, { children: [
2919
+ /* @__PURE__ */ jsx13(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2920
+ /* @__PURE__ */ jsxs7(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
2921
+ label,
2922
+ pad
2923
+ ] }),
2924
+ /* @__PURE__ */ jsx13(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
1942
2925
  ] }, f.path);
1943
2926
  }) }),
1944
- error && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: PALETTE.error, children: [
2927
+ error && /* @__PURE__ */ jsx13(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.error, children: [
1945
2928
  "! ",
1946
2929
  error
1947
2930
  ] }) }),
1948
- /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text5, { color: PALETTE.muted, children: hintFor(field, editing) }) })
2931
+ /* @__PURE__ */ jsx13(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx13(Text7, { color: PALETTE.muted, children: hintFor(field, editing, t) }) })
1949
2932
  ] });
1950
2933
  }
1951
- function renderValue(field, value, draft) {
2934
+ function renderValue(field, value, draft, t, dictDisplayName) {
1952
2935
  if (draft !== null) return `${draft}_`;
1953
- if (field.kind === "bool") return value ? "\u2713 on" : "\u2717 off";
1954
- if (field.kind === "dictRef") return String(value ?? "(none)");
1955
- if (field.kind === "enum") return `< ${value} >`;
2936
+ if (field.kind === "bool") return value ? `\u2713 ${t.common.on}` : `\u2717 ${t.common.off}`;
2937
+ if (field.kind === "dictRef") {
2938
+ if (!value) return "\u2014";
2939
+ return truncateName(dictDisplayName || String(value), 24);
2940
+ }
2941
+ if (field.kind === "enum") {
2942
+ if (field.path === "stealth") {
2943
+ const v = String(value);
2944
+ const label = t.config.enumValues.stealth[v] ?? String(value);
2945
+ return `< ${label} >`;
2946
+ }
2947
+ return `< ${value} >`;
2948
+ }
1956
2949
  return String(value ?? "");
1957
2950
  }
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";
2951
+ function hintFor(field, editing, t) {
2952
+ if (editing) return t.config.hints.editing;
2953
+ if (field.kind === "bool") return t.config.hints.bool;
2954
+ if (field.kind === "enum") return t.config.hints.enum;
2955
+ if (field.kind === "dictRef") return t.config.hints.dictRef;
2956
+ return t.config.hints.stringOrInt;
1964
2957
  }
1965
2958
 
1966
2959
  // 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";
2960
+ import { useEffect as useEffect8, useState as useState12 } from "react";
2961
+ import { Box as Box9, Text as Text8, useInput as useInput7 } from "ink";
2962
+ import { jsx as jsx14, jsxs as jsxs8 } from "react/jsx-runtime";
1970
2963
  var DAY_WINDOWS = [7, 14, 30, 90];
1971
2964
  function StatsViewer() {
1972
2965
  const nav = useNav();
1973
- const [sessions, setSessions] = useState9(null);
1974
- const [book, setBook] = useState9(null);
1975
- const [windowIdx, setWindowIdx] = useState9(1);
1976
- useEffect7(() => {
2966
+ const t = useStrings();
2967
+ const [sessions, setSessions] = useState12(null);
2968
+ const [book, setBook] = useState12(null);
2969
+ const [windowIdx, setWindowIdx] = useState12(1);
2970
+ useEffect8(() => {
1977
2971
  void (async () => {
1978
2972
  const [s, b] = await Promise.all([loadSessions(), loadMistakes()]);
1979
2973
  setSessions(s);
1980
2974
  setBook(b);
1981
2975
  })();
1982
2976
  }, []);
1983
- useInput6((input, key) => {
1984
- if (key.escape || input === "b" || input === "q") {
2977
+ useInput7((_input, key) => {
2978
+ if (key.escape) {
1985
2979
  nav.back();
1986
2980
  return;
1987
2981
  }
1988
- if (input === "n") setWindowIdx((i) => (i + 1) % DAY_WINDOWS.length);
1989
- if (input === "N") setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
2982
+ if (key.rightArrow) setWindowIdx((i) => (i + 1) % DAY_WINDOWS.length);
2983
+ if (key.leftArrow) setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
1990
2984
  });
1991
2985
  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" }) });
2986
+ return /* @__PURE__ */ jsx14(Box9, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.loading }) });
1993
2987
  }
1994
2988
  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" }) })
2989
+ return /* @__PURE__ */ jsxs8(Box9, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2990
+ /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.none }),
2991
+ /* @__PURE__ */ jsx14(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.nonePractice }) }),
2992
+ /* @__PURE__ */ jsx14(Box9, { marginTop: 2, children: /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
2993
+ "Esc ",
2994
+ t.common.back
2995
+ ] }) })
1999
2996
  ] });
2000
2997
  }
2001
2998
  const days = DAY_WINDOWS[windowIdx];
@@ -2012,102 +3009,162 @@ function StatsViewer() {
2012
3009
  const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
2013
3010
  const recent = sessions.slice(-5).reverse();
2014
3011
  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 })
3012
+ const wpms = buckets.map((b) => b.wpm);
3013
+ const accs = buckets.map((b) => b.accuracy * 100);
3014
+ const ses = buckets.map((b) => b.sessions);
3015
+ return /* @__PURE__ */ jsxs8(Box9, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3016
+ /* @__PURE__ */ jsx14(Text8, { bold: true, color: PALETTE.accent, children: t.stats.title }),
3017
+ /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, flexDirection: "column", children: [
3018
+ /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.lifetime }),
3019
+ /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, children: [
3020
+ /* @__PURE__ */ jsx14(Stat, { label: t.stats.sessions, value: String(sessions.length) }),
3021
+ /* @__PURE__ */ jsx14(Stat, { label: t.stats.words, value: String(totalWords) }),
3022
+ /* @__PURE__ */ jsx14(Stat, { label: t.stats.errors, value: String(totalErrors) }),
3023
+ /* @__PURE__ */ jsx14(Stat, { label: t.stats.wpm, value: String(overallWpm), accent: true }),
3024
+ /* @__PURE__ */ jsx14(Stat, { label: t.stats.accuracy, value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
3025
+ /* @__PURE__ */ jsx14(Stat, { label: t.stats.streak, value: `${streak}d`, accent: true })
2026
3026
  ] })
2027
3027
  ] }),
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)) }),
2038
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2039
- " max ",
2040
- Math.round(Math.max(...buckets.map((b) => b.wpm)))
2041
- ] })
2042
- ] }),
2043
- /* @__PURE__ */ jsxs6(Box6, { children: [
2044
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "accuracy".padEnd(10) }),
2045
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.accuracy * 100)) })
2046
- ] }),
2047
- /* @__PURE__ */ jsxs6(Box6, { children: [
2048
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "sessions".padEnd(10) }),
2049
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.sessions)) })
2050
- ] })
3028
+ /* @__PURE__ */ jsxs8(Box9, { marginTop: 2, flexDirection: "column", children: [
3029
+ /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.last(days) }),
3030
+ /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: PALETTE.muted, paddingX: 1, children: [
3031
+ /* @__PURE__ */ jsx14(
3032
+ SparkRow,
3033
+ {
3034
+ label: t.stats.wpm,
3035
+ values: wpms,
3036
+ maxLabel: t.stats.maxLabel
3037
+ }
3038
+ ),
3039
+ /* @__PURE__ */ jsx14(
3040
+ SparkRow,
3041
+ {
3042
+ label: t.stats.accuracy,
3043
+ values: accs,
3044
+ maxLabel: t.stats.maxLabel,
3045
+ suffix: "%"
3046
+ }
3047
+ ),
3048
+ /* @__PURE__ */ jsx14(
3049
+ SparkRow,
3050
+ {
3051
+ label: t.stats.sessions,
3052
+ values: ses,
3053
+ maxLabel: t.stats.maxLabel
3054
+ }
3055
+ )
2051
3056
  ] })
2052
3057
  ] }),
2053
- /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
2054
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "recent sessions" }),
2055
- recent.map((s, i) => /* @__PURE__ */ jsxs6(Box6, { children: [
2056
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2057
- " ",
2058
- s.ts.replace("T", " ").slice(0, 16),
2059
- " "
2060
- ] }),
2061
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
2062
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2063
- " ",
2064
- "ch",
2065
- String(s.chapter + 1).padStart(3),
2066
- " ",
2067
- s.mode.padEnd(9),
2068
- " ",
2069
- String(s.wordCount).padStart(3),
2070
- "w ",
2071
- s.errors,
2072
- "err ",
2073
- computeWPM(s),
2074
- "wpm ",
2075
- Math.round(accuracy(s) * 1e3) / 10,
2076
- "%"
2077
- ] })
2078
- ] }, i))
3058
+ /* @__PURE__ */ jsxs8(Box9, { marginTop: 2, flexDirection: "column", children: [
3059
+ /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.recent }),
3060
+ recent.map((s, i) => /* @__PURE__ */ jsx14(RecentRow, { session: s, units: t.stats.recentUnits }, i))
2079
3061
  ] }),
2080
- top.length > 0 && /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
2081
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "top mistakes" }),
2082
- top.map(([word, entry]) => /* @__PURE__ */ jsxs6(Box6, { children: [
2083
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.error, children: [
2084
- " ",
2085
- String(entry.count).padStart(3),
2086
- " "
2087
- ] }),
2088
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: word.padEnd(20) }),
2089
- /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: entry.dictIds.join(", ") })
2090
- ] }, word))
3062
+ top.length > 0 && /* @__PURE__ */ jsxs8(Box9, { marginTop: 2, flexDirection: "column", children: [
3063
+ /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.topMistakes }),
3064
+ top.map(([word, entry]) => /* @__PURE__ */ jsx14(
3065
+ MistakeRow,
3066
+ {
3067
+ word,
3068
+ count: entry.count,
3069
+ dictIds: entry.dictIds,
3070
+ multiSuffix: t.stats.multiDictSuffix
3071
+ },
3072
+ word
3073
+ ))
3074
+ ] }),
3075
+ /* @__PURE__ */ jsx14(Box9, { flexGrow: 1 }),
3076
+ /* @__PURE__ */ jsx14(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.footer }) })
3077
+ ] });
3078
+ }
3079
+ function SparkRow({
3080
+ label,
3081
+ values,
3082
+ maxLabel,
3083
+ suffix = ""
3084
+ }) {
3085
+ const max = values.length === 0 ? 0 : Math.max(...values);
3086
+ const maxDisplay = `${Math.round(max)}${suffix}`;
3087
+ return /* @__PURE__ */ jsxs8(Box9, { children: [
3088
+ /* @__PURE__ */ jsx14(Box9, { width: 10, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: label }) }),
3089
+ /* @__PURE__ */ jsx14(Box9, { flexGrow: 1, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.accent, children: sparkline(values) }) }),
3090
+ /* @__PURE__ */ jsx14(Box9, { marginLeft: 2, children: /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3091
+ maxLabel,
3092
+ " ",
3093
+ maxDisplay
3094
+ ] }) })
3095
+ ] });
3096
+ }
3097
+ function RecentRow({
3098
+ session,
3099
+ units
3100
+ }) {
3101
+ const name = useDictName(session.dictId);
3102
+ const display = truncateName(name, 14);
3103
+ return /* @__PURE__ */ jsxs8(Box9, { children: [
3104
+ /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3105
+ " ",
3106
+ session.ts.replace("T", " ").slice(0, 16),
3107
+ " "
2091
3108
  ] }),
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" }) })
3109
+ /* @__PURE__ */ jsx14(Text8, { color: PALETTE.text, children: display.padEnd(14) }),
3110
+ /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3111
+ " ",
3112
+ "ch",
3113
+ String(session.chapter + 1).padStart(3),
3114
+ " ",
3115
+ session.mode.padEnd(9),
3116
+ " ",
3117
+ String(session.wordCount).padStart(3),
3118
+ units.words,
3119
+ " ",
3120
+ session.errors,
3121
+ units.errors,
3122
+ " ",
3123
+ computeWPM(session),
3124
+ units.wpm,
3125
+ " ",
3126
+ Math.round(accuracy(session) * 1e3) / 10,
3127
+ "%"
3128
+ ] })
3129
+ ] });
3130
+ }
3131
+ function MistakeRow({
3132
+ word,
3133
+ count,
3134
+ dictIds,
3135
+ multiSuffix
3136
+ }) {
3137
+ const firstId = dictIds[0] ?? "";
3138
+ const firstName = useDictName(firstId);
3139
+ const suffix = dictIds.length > 1 ? multiSuffix(dictIds.length - 1) : "";
3140
+ return /* @__PURE__ */ jsxs8(Box9, { children: [
3141
+ /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.error, children: [
3142
+ " ",
3143
+ String(count).padStart(3),
3144
+ " "
3145
+ ] }),
3146
+ /* @__PURE__ */ jsx14(Text8, { color: PALETTE.text, children: word.padEnd(20) }),
3147
+ /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3148
+ truncateName(firstName, 20),
3149
+ suffix
3150
+ ] })
2094
3151
  ] });
2095
3152
  }
2096
3153
  function Stat({ label, value, accent = false }) {
2097
- return /* @__PURE__ */ jsxs6(Box6, { marginRight: 3, children: [
2098
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
3154
+ return /* @__PURE__ */ jsxs8(Box9, { marginRight: 3, children: [
3155
+ /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
2099
3156
  label,
2100
3157
  " "
2101
3158
  ] }),
2102
- /* @__PURE__ */ jsx10(Text6, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
3159
+ /* @__PURE__ */ jsx14(Text8, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
2103
3160
  ] });
2104
3161
  }
2105
3162
 
2106
3163
  // 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";
3164
+ import { useEffect as useEffect9, useState as useState13 } from "react";
3165
+ import { Box as Box10, Text as Text9, useInput as useInput8 } from "ink";
2109
3166
  import { readdir } from "fs/promises";
2110
- import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
3167
+ import { Fragment as Fragment2, jsx as jsx15, jsxs as jsxs9 } from "react/jsx-runtime";
2111
3168
  async function listLocalDictIds() {
2112
3169
  try {
2113
3170
  const files = await readdir(paths.dictsDir);
@@ -2118,12 +3175,13 @@ async function listLocalDictIds() {
2118
3175
  }
2119
3176
  function WordLookup() {
2120
3177
  const nav = useNav();
2121
- const [query, setQuery] = useState10("");
2122
- const [allWords, setAllWords] = useState10([]);
2123
- const [book, setBook] = useState10({});
2124
- const [loading, setLoading] = useState10(true);
2125
- const [selected, setSelected] = useState10(0);
2126
- useEffect8(() => {
3178
+ const t = useStrings();
3179
+ const [query, setQuery] = useState13("");
3180
+ const [allWords, setAllWords] = useState13([]);
3181
+ const [book, setBook] = useState13({});
3182
+ const [loading, setLoading] = useState13(true);
3183
+ const [selected, setSelected] = useState13(0);
3184
+ useEffect9(() => {
2127
3185
  void (async () => {
2128
3186
  const ids = await listLocalDictIds();
2129
3187
  const collected = [];
@@ -2139,7 +3197,7 @@ function WordLookup() {
2139
3197
  }, []);
2140
3198
  const q = query.toLowerCase().trim();
2141
3199
  const filtered = q ? allWords.filter((h) => h.word.name.toLowerCase().includes(q)).slice(0, 50) : [];
2142
- useInput7((input, key) => {
3200
+ useInput8((input, key) => {
2143
3201
  if (key.escape) {
2144
3202
  nav.back();
2145
3203
  return;
@@ -2163,115 +3221,223 @@ function WordLookup() {
2163
3221
  }
2164
3222
  });
2165
3223
  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" }) });
3224
+ return /* @__PURE__ */ jsx15(Box10, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.indexing }) });
2167
3225
  }
2168
3226
  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" }) })
3227
+ return /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
3228
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.none }),
3229
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.pullFirst }) }),
3230
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 2, children: /* @__PURE__ */ jsxs9(Text9, { color: PALETTE.muted, children: [
3231
+ "[Esc] ",
3232
+ t.common.back
3233
+ ] }) })
2173
3234
  ] });
2174
3235
  }
2175
3236
  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
- ] })
3237
+ return /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3238
+ /* @__PURE__ */ jsxs9(Box10, { children: [
3239
+ /* @__PURE__ */ jsx15(Text9, { bold: true, color: PALETTE.accent, children: t.word.title }),
3240
+ /* @__PURE__ */ jsx15(Box10, { flexGrow: 1 }),
3241
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.countAcross(allWords.length) })
2184
3242
  ] }),
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: "_" })
3243
+ /* @__PURE__ */ jsxs9(Box10, { marginTop: 1, children: [
3244
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: "> " }),
3245
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.text, children: query }),
3246
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.accent, children: "_" })
2189
3247
  ] }),
2190
- /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexGrow: 1, children: [
2191
- /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", width: "40%", children: [
2192
- filtered.map((h, i) => {
2193
- const active = i === selected;
2194
- return /* @__PURE__ */ jsxs7(Box7, { children: [
2195
- /* @__PURE__ */ jsx11(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2196
- /* @__PURE__ */ jsx11(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: h.word.name.padEnd(20) }),
2197
- /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: h.dictId })
2198
- ] }, `${h.dictId}-${h.word.name}-${i}`);
2199
- }),
2200
- filtered.length === 0 && q && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2201
- 'no matches for "',
2202
- query,
2203
- '"'
2204
- ] })
3248
+ /* @__PURE__ */ jsxs9(Box10, { marginTop: 1, flexGrow: 1, children: [
3249
+ /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", width: "40%", children: [
3250
+ filtered.map((h, i) => /* @__PURE__ */ jsx15(HitRow, { hit: h, active: i === selected }, `${h.dictId}-${h.word.name}-${i}`)),
3251
+ filtered.length === 0 && q && /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.noMatches(query) })
2205
3252
  ] }),
2206
- /* @__PURE__ */ jsx11(Box7, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs7(Fragment3, { children: [
2207
- /* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.text, children: current.word.name }),
2208
- /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
2209
- current.word.usphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2210
- "US /",
2211
- current.word.usphone,
2212
- "/ "
2213
- ] }),
2214
- current.word.ukphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2215
- "UK /",
2216
- current.word.ukphone,
2217
- "/"
2218
- ] })
2219
- ] }),
2220
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, flexDirection: "column", children: (current.word.trans ?? []).map((t, i) => /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.primary, children: [
2221
- "\xB7 ",
2222
- t
2223
- ] }, i)) }),
2224
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2225
- "in: ",
2226
- current.dictId
2227
- ] }) }),
2228
- book[current.word.name] && /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
2229
- /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.error, children: [
2230
- "mistakes: ",
2231
- book[current.word.name].count
2232
- ] }),
2233
- /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2234
- " ",
2235
- "(last ",
2236
- book[current.word.name].lastSeen.slice(0, 10),
2237
- ")"
2238
- ] })
2239
- ] })
2240
- ] }) })
3253
+ /* @__PURE__ */ jsx15(Box10, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsx15(Detail, { hit: current, book }) })
3254
+ ] }),
3255
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.footer }) })
3256
+ ] });
3257
+ }
3258
+ function HitRow({ hit, active }) {
3259
+ const name = useDictName(hit.dictId);
3260
+ return /* @__PURE__ */ jsxs9(Box10, { children: [
3261
+ /* @__PURE__ */ jsx15(Text9, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
3262
+ /* @__PURE__ */ jsx15(Text9, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: hit.word.name.padEnd(20) }),
3263
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: truncateName(name, 18) })
3264
+ ] });
3265
+ }
3266
+ function Detail({ hit, book }) {
3267
+ const t = useStrings();
3268
+ const name = useDictName(hit.dictId);
3269
+ return /* @__PURE__ */ jsxs9(Fragment2, { children: [
3270
+ /* @__PURE__ */ jsx15(Text9, { bold: true, color: PALETTE.text, children: hit.word.name }),
3271
+ /* @__PURE__ */ jsxs9(Box10, { marginTop: 1, children: [
3272
+ hit.word.usphone && /* @__PURE__ */ jsxs9(Text9, { color: PALETTE.muted, children: [
3273
+ "US /",
3274
+ hit.word.usphone,
3275
+ "/ "
3276
+ ] }),
3277
+ hit.word.ukphone && /* @__PURE__ */ jsxs9(Text9, { color: PALETTE.muted, children: [
3278
+ "UK /",
3279
+ hit.word.ukphone,
3280
+ "/"
3281
+ ] })
2241
3282
  ] }),
2242
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back" }) })
3283
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 1, flexDirection: "column", children: (hit.word.trans ?? []).map((tr, i) => /* @__PURE__ */ jsxs9(Text9, { color: PALETTE.primary, children: [
3284
+ "\xB7 ",
3285
+ tr
3286
+ ] }, i)) }),
3287
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.inDict(truncateName(name, 22)) }) }),
3288
+ book[hit.word.name] && /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.error, children: t.word.mistakes(book[hit.word.name].count, book[hit.word.name].lastSeen.slice(0, 10)) }) })
3289
+ ] });
3290
+ }
3291
+
3292
+ // src/ui/screens/HelpScreen.tsx
3293
+ import { Box as Box11, Text as Text10, useInput as useInput9 } from "ink";
3294
+ import { jsx as jsx16, jsxs as jsxs10 } from "react/jsx-runtime";
3295
+ function HelpScreen() {
3296
+ const nav = useNav();
3297
+ const t = useStrings();
3298
+ useInput9((_input, key) => {
3299
+ if (key.escape) nav.back();
3300
+ });
3301
+ const k = t.help.keys;
3302
+ const sections = [
3303
+ { title: t.help.sections.global, keys: [k.helpScreen, k.quit] },
3304
+ { title: t.help.sections.main, keys: [k.navigate, k.select, k.letterJump, k.helpScreen] },
3305
+ {
3306
+ title: t.help.sections.practice,
3307
+ keys: [k.pause, k.skip, k.replay, k.resume, k.nextChapter, k.reviewMistakes, k.stealthToggle, k.backMenu]
3308
+ },
3309
+ {
3310
+ title: t.help.sections.dict,
3311
+ keys: [k.navigate, k.filter, k.itemActions, k.moreActions, k.backScreen]
3312
+ },
3313
+ { title: t.help.sections.config, keys: [k.navigate, k.select, k.backMenu] },
3314
+ { title: t.help.sections.stats, keys: [k.cycleWindow, k.backMenu] },
3315
+ { title: t.help.sections.word, keys: [k.filter, k.navigate, k.backMenu] }
3316
+ ];
3317
+ return /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3318
+ /* @__PURE__ */ jsxs10(Box11, { children: [
3319
+ /* @__PURE__ */ jsx16(Text10, { bold: true, color: PALETTE.accent, children: t.help.title }),
3320
+ /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: " \xB7 " }),
3321
+ /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.help.subtitle })
3322
+ ] }),
3323
+ /* @__PURE__ */ jsx16(Box11, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: sections.map((sec) => /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", marginTop: 1, children: [
3324
+ /* @__PURE__ */ jsx16(Text10, { bold: true, color: PALETTE.text, children: sec.title }),
3325
+ sec.keys.map((line, i) => /* @__PURE__ */ jsx16(Box11, { children: /* @__PURE__ */ jsxs10(Text10, { color: PALETTE.muted, children: [
3326
+ " \xB7 ",
3327
+ line
3328
+ ] }) }, i))
3329
+ ] }, sec.title)) }),
3330
+ /* @__PURE__ */ jsx16(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.help.footer }) })
2243
3331
  ] });
2244
3332
  }
2245
3333
 
2246
3334
  // src/ui/App.tsx
2247
- import { jsx as jsx12 } from "react/jsx-runtime";
3335
+ import { jsx as jsx17 } from "react/jsx-runtime";
2248
3336
  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, {}) }) }) }) });
3337
+ return /* @__PURE__ */ jsx17(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx17(LangBridge, { children: /* @__PURE__ */ jsx17(RegistryProvider, { children: /* @__PURE__ */ jsx17(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx17(NavProvider, { initial, children: /* @__PURE__ */ jsx17(Fullscreen, { children: /* @__PURE__ */ jsx17(Router, {}) }) }) }) }) }) });
3338
+ }
3339
+ function LangBridge({ children }) {
3340
+ const { cfg } = useAppState();
3341
+ return /* @__PURE__ */ jsx17(StringsProvider, { pref: cfg.language, children });
3342
+ }
3343
+ function screenKey(frame) {
3344
+ if (frame.name === "practice") {
3345
+ const p = frame.params;
3346
+ return `practice:${p.dictId}:${p.chapterIndex}:${p.mode}:${p.stealth ? "s" : "n"}`;
3347
+ }
3348
+ return frame.name;
2250
3349
  }
2251
3350
  function Router() {
2252
3351
  const nav = useNav();
2253
3352
  const { cfg } = useAppState();
2254
3353
  const { exit } = useApp4();
2255
- useInput8((input, key) => {
2256
- if (key.ctrl && input === "c") exit();
3354
+ const lastKeyRef = useRef4(null);
3355
+ useInput10((input, key2) => {
3356
+ if (key2.ctrl && input === "c") exit();
2257
3357
  });
2258
3358
  const frame = nav.current;
3359
+ const key = screenKey(frame);
3360
+ if (lastKeyRef.current !== key) {
3361
+ if (process.stdout.isTTY) process.stdout.write("\x1B[2J\x1B[H");
3362
+ lastKeyRef.current = key;
3363
+ }
2259
3364
  switch (frame.name) {
2260
3365
  case "main":
2261
- return /* @__PURE__ */ jsx12(MainMenu, { cfg });
3366
+ return /* @__PURE__ */ jsx17(MainMenu, { cfg });
2262
3367
  case "practice":
2263
- return /* @__PURE__ */ jsx12(PracticeScreen, { params: frame.params });
3368
+ return /* @__PURE__ */ jsx17(PracticeScreen, { params: frame.params });
2264
3369
  case "dict":
2265
- return /* @__PURE__ */ jsx12(DictBrowser, { params: frame.params });
3370
+ return /* @__PURE__ */ jsx17(DictBrowser, { params: frame.params });
2266
3371
  case "config":
2267
- return /* @__PURE__ */ jsx12(ConfigEditor, {});
3372
+ return /* @__PURE__ */ jsx17(ConfigEditor, {});
2268
3373
  case "stats":
2269
- return /* @__PURE__ */ jsx12(StatsViewer, {});
3374
+ return /* @__PURE__ */ jsx17(StatsViewer, {});
2270
3375
  case "word":
2271
- return /* @__PURE__ */ jsx12(WordLookup, {});
3376
+ return /* @__PURE__ */ jsx17(WordLookup, {});
3377
+ case "help":
3378
+ return /* @__PURE__ */ jsx17(HelpScreen, {});
2272
3379
  }
2273
3380
  }
2274
3381
 
3382
+ // src/util/report.ts
3383
+ import chalk3 from "chalk";
3384
+ var LEAVE_ALTSCREEN = "\x1B[?25h\x1B[?1049l";
3385
+ function ensureMainScreen() {
3386
+ if (process.stdout.isTTY) process.stdout.write(LEAVE_ALTSCREEN);
3387
+ }
3388
+ function fmtDuration(ms, lang) {
3389
+ const total = Math.floor(ms / 1e3);
3390
+ const m = Math.floor(total / 60);
3391
+ const s = total % 60;
3392
+ if (lang === "zh") {
3393
+ if (m === 0) return `${s} \u79D2`;
3394
+ return `${m} \u5206 ${s} \u79D2`;
3395
+ }
3396
+ if (m === 0) return `${s}s`;
3397
+ return `${m}m ${s}s`;
3398
+ }
3399
+ function printSessionReport(r, t, lang) {
3400
+ if (r.startedAt === null && r.chaptersCompleted === 0) return;
3401
+ if (r.chaptersCompleted === 0) {
3402
+ console.log();
3403
+ console.log(chalk3.bold.cyan(t.report.title));
3404
+ const labelW2 = Math.max(visibleWidth2(t.report.duration), visibleWidth2(t.report.notPracticed)) + 2;
3405
+ const pad2 = (label) => label + " ".repeat(Math.max(0, labelW2 - visibleWidth2(label)));
3406
+ console.log(` ${chalk3.dim(pad2(t.report.duration))} ${fmtDuration(r.totalDurationMs, lang)}`);
3407
+ console.log(` ${chalk3.dim(t.report.notPracticed)}`);
3408
+ console.log();
3409
+ console.log(chalk3.dim(` ${t.report.farewell}`));
3410
+ console.log();
3411
+ return;
3412
+ }
3413
+ const accPct = Math.round(r.accuracy * 1e3) / 10;
3414
+ const labels = [
3415
+ t.report.duration,
3416
+ t.report.practiced,
3417
+ t.report.chapters,
3418
+ t.report.words,
3419
+ t.report.accuracy,
3420
+ t.report.wpm
3421
+ ];
3422
+ if (r.newMistakeWords > 0) labels.push(t.report.newMistakes);
3423
+ const labelW = Math.max(...labels.map(visibleWidth2)) + 2;
3424
+ const pad = (label) => label + " ".repeat(Math.max(0, labelW - visibleWidth2(label)));
3425
+ console.log();
3426
+ console.log(chalk3.bold.cyan(t.report.title));
3427
+ console.log(` ${chalk3.dim(pad(t.report.duration))} ${fmtDuration(r.totalDurationMs, lang)}`);
3428
+ console.log(` ${chalk3.dim(pad(t.report.practiced))} ${fmtDuration(r.practiceMs, lang)}`);
3429
+ console.log(` ${chalk3.dim(pad(t.report.chapters))} ${r.chaptersCompleted}`);
3430
+ console.log(` ${chalk3.dim(pad(t.report.words))} ${r.wordCount}`);
3431
+ console.log(` ${chalk3.dim(pad(t.report.accuracy))} ${accPct}%`);
3432
+ console.log(` ${chalk3.dim(pad(t.report.wpm))} ${r.wpm}`);
3433
+ if (r.newMistakeWords > 0) {
3434
+ console.log(` ${chalk3.dim(pad(t.report.newMistakes))} ${r.newMistakeWords}`);
3435
+ }
3436
+ console.log();
3437
+ console.log(chalk3.dim(` ${t.report.farewell}`));
3438
+ console.log();
3439
+ }
3440
+
2275
3441
  // src/commands/practice.ts
2276
3442
  var MODES = ["order", "dictation", "review", "random", "loop"];
2277
3443
  function isMode(v) {
@@ -2279,42 +3445,49 @@ function isMode(v) {
2279
3445
  }
2280
3446
  async function runPractice(dictIdArg, options) {
2281
3447
  if (!process.stdout.isTTY) {
2282
- console.error(chalk3.red("Practice requires an interactive TTY."));
3448
+ console.error(chalk4.red("Practice requires an interactive TTY."));
2283
3449
  process.exitCode = 1;
2284
3450
  return;
2285
3451
  }
2286
3452
  const cfg = await loadConfig();
2287
3453
  const dictId = dictIdArg ?? cfg.defaultDict;
2288
3454
  if (!dictId) {
2289
- console.error(chalk3.red("No dictionary specified. Pass an id or set config.defaultDict."));
3455
+ console.error(chalk4.red("No dictionary specified. Pass an id or set config.defaultDict."));
2290
3456
  process.exitCode = 1;
2291
3457
  return;
2292
3458
  }
2293
3459
  const mode = options.mode ?? cfg.defaultMode;
2294
3460
  if (!isMode(mode)) {
2295
- console.error(chalk3.red(`Invalid mode "${mode}". Valid: ${MODES.join(", ")}`));
3461
+ console.error(chalk4.red(`Invalid mode "${mode}". Valid: ${MODES.join(", ")}`));
2296
3462
  process.exitCode = 1;
2297
3463
  return;
2298
3464
  }
2299
3465
  const chapterIndex = Math.max(0, Number(options.chapter ?? 1) - 1);
3466
+ const stealth = options.stealth === true || cfg.stealth === "default";
3467
+ start();
2300
3468
  const { waitUntilExit } = render(
2301
3469
  createElement(App, {
2302
- initial: { name: "practice", params: { dictId, chapterIndex, mode } },
3470
+ initial: { name: "practice", params: { dictId, chapterIndex, mode, stealth } },
2303
3471
  initialCfg: cfg
2304
3472
  }),
2305
3473
  { patchConsole: false, exitOnCtrlC: false }
2306
3474
  );
2307
3475
  await waitUntilExit();
3476
+ ensureMainScreen();
3477
+ const { lang, t } = pickStrings(cfg.language);
3478
+ printSessionReport(report(), t, lang);
2308
3479
  }
2309
3480
  function buildPracticeCommand() {
2310
- return new Command3("practice").argument("[dictId]", "dictionary id; falls back to config.defaultDict").description("Start a typing practice session").option("-c, --chapter <n>", "chapter number (1-based)", "1").option("-m, --mode <mode>", "order | dictation | review | random | loop").action(async (dictIdArg, options) => {
2311
- await runPractice(dictIdArg, options);
2312
- });
3481
+ 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").option("--stealth", "enter stealth mode (minimal UI, no sound)").action(
3482
+ async (dictIdArg, options) => {
3483
+ await runPractice(dictIdArg, options);
3484
+ }
3485
+ );
2313
3486
  }
2314
3487
 
2315
3488
  // src/commands/stats.ts
2316
3489
  import { Command as Command4 } from "commander";
2317
- import chalk4 from "chalk";
3490
+ import chalk5 from "chalk";
2318
3491
  function buildStatsCommand() {
2319
3492
  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
3493
  const days = Math.max(1, Number(opts.days) || 14);
@@ -2322,7 +3495,7 @@ function buildStatsCommand() {
2322
3495
  const sessions = await loadSessions();
2323
3496
  const book = await loadMistakes();
2324
3497
  if (sessions.length === 0) {
2325
- console.log(chalk4.yellow("No practice history yet. Run `qwerty practice <dict>` to get started."));
3498
+ console.log(chalk5.yellow("No practice history yet. Run `qwerty practice <dict>` to get started."));
2326
3499
  return;
2327
3500
  }
2328
3501
  const buckets = dailyBuckets(sessions, days);
@@ -2336,33 +3509,33 @@ function buildStatsCommand() {
2336
3509
  );
2337
3510
  const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
2338
3511
  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(`
3512
+ console.log(chalk5.bold("\nLifetime"));
3513
+ console.log(` ${chalk5.dim("sessions")} ${sessions.length} ${chalk5.dim("words")} ${totalWords} ${chalk5.dim("errors")} ${totalErrors}`);
3514
+ console.log(` ${chalk5.dim("avg wpm")} ${overallWpm} ${chalk5.dim("avg accuracy")} ${Math.round(overallAcc * 1e3) / 10}% ${chalk5.dim("streak")} ${chalk5.bold(streak)}d`);
3515
+ console.log(chalk5.bold(`
2343
3516
  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))}`);
3517
+ console.log(` ${chalk5.dim("wpm ")} ${sparkline(buckets.map((b) => b.wpm))} ${chalk5.dim("max")} ${Math.round(Math.max(...buckets.map((b) => b.wpm)))}`);
3518
+ 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)))}%`);
3519
+ console.log(` ${chalk5.dim("sessions")} ${sparkline(buckets.map((b) => b.sessions))}`);
2347
3520
  const recent = sessions.slice(-5).reverse();
2348
- console.log(chalk4.bold("\nLast 5 sessions"));
3521
+ console.log(chalk5.bold("\nLast 5 sessions"));
2349
3522
  for (const s of recent) {
2350
3523
  const wpm = computeWPM(s);
2351
3524
  const acc = Math.round(accuracy(s) * 1e3) / 10;
2352
3525
  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}%`
3526
+ ` ${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
3527
  );
2355
3528
  }
2356
3529
  const top = topN(book, topCount);
2357
3530
  if (top.length > 0) {
2358
- console.log(chalk4.bold(`
3531
+ console.log(chalk5.bold(`
2359
3532
  Top ${top.length} mistakes`));
2360
3533
  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(", "))}`);
3534
+ console.log(` ${chalk5.red(String(entry.count).padStart(3))} ${chalk5.bold(word.padEnd(20))} ${chalk5.dim(entry.dictIds.join(", "))}`);
2362
3535
  }
2363
3536
  } else {
2364
- console.log(chalk4.bold("\nTop mistakes"));
2365
- console.log(chalk4.dim(" none \u2014 keep going"));
3537
+ console.log(chalk5.bold("\nTop mistakes"));
3538
+ console.log(chalk5.dim(" none \u2014 keep going"));
2366
3539
  }
2367
3540
  console.log();
2368
3541
  });
@@ -2370,7 +3543,7 @@ Top ${top.length} mistakes`));
2370
3543
 
2371
3544
  // src/commands/word.ts
2372
3545
  import { Command as Command5 } from "commander";
2373
- import chalk5 from "chalk";
3546
+ import chalk6 from "chalk";
2374
3547
  import { readdir as readdir2 } from "fs/promises";
2375
3548
  async function listLocalDictIds2() {
2376
3549
  try {
@@ -2385,7 +3558,7 @@ function buildWordCommand() {
2385
3558
  const q = keyword.toLowerCase();
2386
3559
  const ids = await listLocalDictIds2();
2387
3560
  if (ids.length === 0) {
2388
- console.log(chalk5.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
3561
+ console.log(chalk6.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
2389
3562
  return;
2390
3563
  }
2391
3564
  const hits = [];
@@ -2399,7 +3572,7 @@ function buildWordCommand() {
2399
3572
  }
2400
3573
  }
2401
3574
  if (hits.length === 0) {
2402
- console.log(chalk5.yellow(`No matches for "${keyword}" in ${ids.length} local dictionaries`));
3575
+ console.log(chalk6.yellow(`No matches for "${keyword}" in ${ids.length} local dictionaries`));
2403
3576
  return;
2404
3577
  }
2405
3578
  const byName = /* @__PURE__ */ new Map();
@@ -2412,27 +3585,37 @@ function buildWordCommand() {
2412
3585
  for (const [name, group] of byName) {
2413
3586
  const first = group[0].word;
2414
3587
  console.log();
2415
- console.log(chalk5.bold.white(name));
3588
+ console.log(chalk6.bold.white(name));
2416
3589
  const us = first.usphone ? `US /${first.usphone}/` : "";
2417
3590
  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}`));
3591
+ if (us || uk) console.log(chalk6.dim(` ${[us, uk].filter(Boolean).join(" ")}`));
3592
+ for (const t of first.trans ?? []) console.log(chalk6.cyan(` \xB7 ${t}`));
2420
3593
  const sources = await Promise.all(
2421
3594
  group.map(async (h) => {
2422
3595
  const reg = await findEntry(h.dictId);
2423
3596
  return reg?.name ?? h.dictId;
2424
3597
  })
2425
3598
  );
2426
- console.log(chalk5.dim(` in: ${sources.join(", ")}`));
3599
+ console.log(chalk6.dim(` in: ${sources.join(", ")}`));
2427
3600
  const mistake = book[name];
2428
3601
  if (mistake) {
2429
- console.log(chalk5.dim(` mistakes: ${mistake.count} (last ${mistake.lastSeen.slice(0, 10)})`));
3602
+ console.log(chalk6.dim(` mistakes: ${mistake.count} (last ${mistake.lastSeen.slice(0, 10)})`));
2430
3603
  }
2431
3604
  }
2432
3605
  console.log();
2433
3606
  });
2434
3607
  }
2435
3608
 
3609
+ // src/commands/stealth.ts
3610
+ import { Command as Command6 } from "commander";
3611
+ function buildStealthCommand() {
3612
+ return new Command6("boss").alias("stealth").description("Start practice in stealth mode (minimal UI, looks like plain terminal output)").argument("[dictId]", "dictionary id; falls back to config.defaultDict").option("-c, --chapter <n>", "chapter number (1-based)", "1").option("-m, --mode <mode>", "order | dictation | review | random | loop").action(
3613
+ async (dictIdArg, options) => {
3614
+ await runPractice(dictIdArg, { ...options, stealth: true });
3615
+ }
3616
+ );
3617
+ }
3618
+
2436
3619
  // src/commands/menu.ts
2437
3620
  import { render as render2 } from "ink";
2438
3621
  import { createElement as createElement2 } from "react";
@@ -2442,17 +3625,22 @@ async function runMainMenu() {
2442
3625
  return;
2443
3626
  }
2444
3627
  const cfg = await loadConfig();
3628
+ start();
2445
3629
  const { waitUntilExit } = render2(
2446
3630
  createElement2(App, { initial: { name: "main" }, initialCfg: cfg }),
2447
3631
  { patchConsole: false, exitOnCtrlC: false }
2448
3632
  );
2449
3633
  await waitUntilExit();
3634
+ ensureMainScreen();
3635
+ const { lang, t } = pickStrings(cfg.language);
3636
+ printSessionReport(report(), t, lang);
2450
3637
  }
2451
3638
 
2452
3639
  // src/cli.ts
2453
- var program = new Command6();
3640
+ var program = new Command7();
2454
3641
  program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version(package_default.version);
2455
3642
  program.addCommand(buildPracticeCommand());
3643
+ program.addCommand(buildStealthCommand());
2456
3644
  program.addCommand(buildDictCommand());
2457
3645
  program.addCommand(buildWordCommand());
2458
3646
  program.addCommand(buildStatsCommand());