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

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 +1258 -472
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +1 -1
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.6",
7
+ version: "0.0.1-alpha.8",
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: {
@@ -150,7 +150,8 @@ var ConfigSchema = z.object({
150
150
  autoplayPronunciation: z.boolean().default(true),
151
151
  defaultMode: z.enum(["order", "dictation", "review", "random", "loop"]).default("order"),
152
152
  defaultDict: z.string().optional(),
153
- language: z.enum(["auto", "zh", "en"]).default("auto")
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() {
@@ -512,7 +513,7 @@ import { createElement } from "react";
512
513
 
513
514
  // src/ui/App.tsx
514
515
  import { useRef as useRef4 } from "react";
515
- import { useApp as useApp4, useInput as useInput8 } from "ink";
516
+ import { useApp as useApp4, useInput as useInput10 } from "ink";
516
517
 
517
518
  // src/ui/nav.tsx
518
519
  import { createContext, useContext, useState, useCallback } from "react";
@@ -804,12 +805,13 @@ var en = {
804
805
  back: "back",
805
806
  quit: "quit",
806
807
  on: "on",
807
- off: "off"
808
+ off: "off",
809
+ cancel: "cancel"
808
810
  },
809
811
  mainMenu: {
810
812
  items: {
811
813
  practiceLabel: "Practice",
812
- practiceHintWith: (id) => `start ${id}`,
814
+ practiceHintWith: (name) => `start ${name}`,
813
815
  practiceHintNone: "pick a dictionary",
814
816
  dictLabel: "Dictionaries",
815
817
  dictHint: "browse, pull, set default",
@@ -819,18 +821,19 @@ var en = {
819
821
  statsHint: "history & trends",
820
822
  configLabel: "Config",
821
823
  configHint: "edit preferences",
824
+ stealthLabel: "Stealth",
825
+ stealthHint: "quiet practice mode",
822
826
  quitLabel: "Quit",
823
- quitHint: "Ctrl+C also exits"
827
+ quitHint: "Esc or Ctrl+C also exits"
824
828
  },
825
- defaultDict: (id) => `default dict: ${id}`,
826
- noDefault: "default dict: (none \u2014 pick one in Dictionaries)",
827
- hint: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump"
829
+ hint: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump",
830
+ helpHint: "? help"
828
831
  },
829
832
  dict: {
830
833
  title: "Dictionaries",
831
834
  loading: "loading dictionaries\u2026",
832
835
  entries: (n) => `${n} entries`,
833
- filterPrompt: (q) => `/ ${q}`,
836
+ filterPlaceholder: "type to filter",
834
837
  local: "local \u2713",
835
838
  notLocal: "not local",
836
839
  defaultMark: "default \u2605",
@@ -839,7 +842,19 @@ var en = {
839
842
  pulling: (id) => `pulling ${id}\u2026`,
840
843
  removing: (id) => `removing ${id}\u2026`,
841
844
  errorOn: (id, msg) => `error on ${id}: ${msg}`,
842
- footer: "\u2191/\u2193 select \xB7 Enter set default \xB7 p practice \xB7 u pull \xB7 r remove \xB7 / filter \xB7 Esc back"
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
+ }
843
858
  },
844
859
  config: {
845
860
  title: "Config",
@@ -847,14 +862,18 @@ var en = {
847
862
  defaultDict: "default dict",
848
863
  defaultMode: "default mode",
849
864
  accent: "accent",
850
- mirror: "mirror",
865
+ mirror: "dict mirror",
851
866
  chapterSize: "chapter size",
852
867
  autoplayPronunciation: "autoplay pronunciation",
853
868
  soundsMaster: "sounds master",
854
869
  soundsKeystroke: "sounds keystroke",
855
870
  soundsFeedback: "sounds feedback",
856
871
  soundsKeySound: "sounds key sound",
857
- language: "language"
872
+ language: "language",
873
+ stealth: "stealth mode"
874
+ },
875
+ enumValues: {
876
+ stealth: { off: "off", menu: "show in menu", default: "default practice" }
858
877
  },
859
878
  hints: {
860
879
  editing: "type to edit \xB7 Enter save \xB7 Esc cancel",
@@ -865,7 +884,7 @@ var en = {
865
884
  }
866
885
  },
867
886
  stats: {
868
- title: "Stats",
887
+ title: "Stats \xB7 overview",
869
888
  loading: "loading stats\u2026",
870
889
  none: "No practice history yet.",
871
890
  nonePractice: "Run a practice session first.",
@@ -876,11 +895,14 @@ var en = {
876
895
  wpm: "wpm",
877
896
  accuracy: "accuracy",
878
897
  streak: "streak",
879
- last: (n) => `last ${n} days (n / N to cycle window)`,
880
- cycleWindow: "n / N cycle window \xB7 q back",
898
+ last: (n) => `last ${n} days (\u2190/\u2192 cycle window)`,
899
+ cycleWindow: "\u2190/\u2192 cycle window \xB7 Esc back",
881
900
  recent: "recent sessions",
882
901
  topMistakes: "top mistakes",
883
- footer: "n / N cycle window \xB7 q back"
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`
884
906
  },
885
907
  word: {
886
908
  title: "Word lookup",
@@ -889,7 +911,7 @@ var en = {
889
911
  pullFirst: "Pull one in Dictionaries first.",
890
912
  countAcross: (n) => `${n} words across local dicts`,
891
913
  noMatches: (q) => `no matches for "${q}"`,
892
- inDict: (id) => `in: ${id}`,
914
+ inDict: (name) => `in: ${name}`,
893
915
  mistakes: (n, date) => `mistakes: ${n} (last ${date})`,
894
916
  footer: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back"
895
917
  },
@@ -921,10 +943,20 @@ var en = {
921
943
  accuracy: "accuracy",
922
944
  elapsed: (t) => `elapsed ${t}`
923
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
+ },
924
958
  footers: {
925
- typing: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay \xB7 Ctrl+C quit",
926
- paused: "[r] resume \xB7 [q] quit to menu",
927
- summary: "[n] next chapter \xB7 [m] review mistakes \xB7 [q] back to menu"
959
+ typing: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay"
928
960
  },
929
961
  errors: {
930
962
  noMistakes: "No mistakes to review yet. Practice some chapters first.",
@@ -944,7 +976,52 @@ var en = {
944
976
  accuracy: "accuracy",
945
977
  wpm: "wpm",
946
978
  newMistakes: "new mistakes",
947
- farewell: "see you next time."
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
+ pausedHintRight: "Enter resume",
1022
+ nextHintRight: "Enter next",
1023
+ infoChipLabel: "info",
1024
+ infoFmt: (dict, chapter, completed, total, wpm, accPct) => `${dict} \xB7 ${chapter} \xB7 ${completed}/${total} \xB7 ${wpm} wpm \xB7 ${accPct}%`
948
1025
  }
949
1026
  };
950
1027
  var zh = {
@@ -956,12 +1033,13 @@ var zh = {
956
1033
  back: "\u8FD4\u56DE",
957
1034
  quit: "\u9000\u51FA",
958
1035
  on: "\u5F00",
959
- off: "\u5173"
1036
+ off: "\u5173",
1037
+ cancel: "\u53D6\u6D88"
960
1038
  },
961
1039
  mainMenu: {
962
1040
  items: {
963
1041
  practiceLabel: "\u7EC3\u4E60",
964
- practiceHintWith: (id) => `\u5F00\u59CB ${id}`,
1042
+ practiceHintWith: (name) => `\u5F00\u59CB ${name}`,
965
1043
  practiceHintNone: "\u8BF7\u5148\u9009\u8BCD\u5178",
966
1044
  dictLabel: "\u8BCD\u5178",
967
1045
  dictHint: "\u6D4F\u89C8\u3001\u4E0B\u8F7D\u3001\u8BBE\u4E3A\u9ED8\u8BA4",
@@ -971,18 +1049,19 @@ var zh = {
971
1049
  statsHint: "\u5386\u53F2\u4E0E\u8D8B\u52BF",
972
1050
  configLabel: "\u8BBE\u7F6E",
973
1051
  configHint: "\u4FEE\u6539\u504F\u597D",
1052
+ stealthLabel: "\u6478\u9C7C",
1053
+ stealthHint: "\u5B89\u9759\u7EC3\u4E60\u6A21\u5F0F",
974
1054
  quitLabel: "\u9000\u51FA",
975
- quitHint: "Ctrl+C \u4EA6\u53EF\u9000\u51FA"
1055
+ quitHint: "Esc \u6216 Ctrl+C \u9000\u51FA"
976
1056
  },
977
- defaultDict: (id) => `\u9ED8\u8BA4\u8BCD\u5178:${id}`,
978
- noDefault: "\u9ED8\u8BA4\u8BCD\u5178:(\u672A\u8BBE\u7F6E \u2014 \u5728\u300C\u8BCD\u5178\u300D\u4E2D\u9009\u62E9)",
979
- hint: "\u2191/\u2193 \u79FB\u52A8 \xB7 Enter \u786E\u8BA4 \xB7 \u5B57\u6BCD\u76F4\u8FBE"
1057
+ hint: "\u2191/\u2193 \u79FB\u52A8 \xB7 Enter \u786E\u8BA4 \xB7 \u5B57\u6BCD\u76F4\u8FBE",
1058
+ helpHint: "? \u5E2E\u52A9"
980
1059
  },
981
1060
  dict: {
982
1061
  title: "\u8BCD\u5178",
983
1062
  loading: "\u52A0\u8F7D\u8BCD\u5178\u4E2D\u2026",
984
1063
  entries: (n) => `${n} \u90E8\u8BCD\u5178`,
985
- filterPrompt: (q) => `/ ${q}`,
1064
+ filterPlaceholder: "\u8F93\u5165\u8FC7\u6EE4",
986
1065
  local: "\u5DF2\u4E0B\u8F7D \u2713",
987
1066
  notLocal: "\u672A\u4E0B\u8F7D",
988
1067
  defaultMark: "\u9ED8\u8BA4 \u2605",
@@ -991,7 +1070,19 @@ var zh = {
991
1070
  pulling: (id) => `\u62C9\u53D6 ${id} \u4E2D\u2026`,
992
1071
  removing: (id) => `\u5220\u9664 ${id} \u4E2D\u2026`,
993
1072
  errorOn: (id, msg) => `${id} \u51FA\u9519:${msg}`,
994
- footer: "\u2191/\u2193 \u9009\u62E9 \xB7 Enter \u8BBE\u4E3A\u9ED8\u8BA4 \xB7 p \u5F00\u59CB\u7EC3\u4E60 \xB7 u \u62C9\u53D6 \xB7 r \u5220\u9664 \xB7 / \u8FC7\u6EE4 \xB7 Esc \u8FD4\u56DE"
1073
+ footer: "\u2191/\u2193 \u9009\u62E9 \xB7 Enter \u64CD\u4F5C \xB7 Ctrl+K \u66F4\u591A \xB7 Esc \u8FD4\u56DE",
1074
+ action: {
1075
+ title: "\u5F53\u524D\u8BCD\u5178",
1076
+ setDefault: "\u8BBE\u4E3A\u9ED8\u8BA4",
1077
+ practice: "\u7ACB\u5373\u7EC3\u4E60",
1078
+ delete: "\u5220\u9664\u672C\u5730"
1079
+ },
1080
+ command: {
1081
+ title: "\u66F4\u591A\u529F\u80FD",
1082
+ pull: "\u62C9\u53D6\u9009\u4E2D",
1083
+ import: "\u5BFC\u5165 .json",
1084
+ refreshList: "\u66F4\u65B0\u8BCD\u5178\u5217\u8868"
1085
+ }
995
1086
  },
996
1087
  config: {
997
1088
  title: "\u8BBE\u7F6E",
@@ -999,14 +1090,18 @@ var zh = {
999
1090
  defaultDict: "\u9ED8\u8BA4\u8BCD\u5178",
1000
1091
  defaultMode: "\u9ED8\u8BA4\u6A21\u5F0F",
1001
1092
  accent: "\u53D1\u97F3",
1002
- mirror: "\u955C\u50CF\u6E90",
1093
+ mirror: "\u8BCD\u5178\u955C\u50CF\u6E90",
1003
1094
  chapterSize: "\u7AE0\u8282\u5355\u8BCD\u6570",
1004
1095
  autoplayPronunciation: "\u81EA\u52A8\u64AD\u653E\u53D1\u97F3",
1005
1096
  soundsMaster: "\u97F3\u6548\u603B\u5F00\u5173",
1006
1097
  soundsKeystroke: "\u6309\u952E\u97F3",
1007
1098
  soundsFeedback: "\u53CD\u9988\u97F3",
1008
1099
  soundsKeySound: "\u6309\u952E\u97F3\u8272",
1009
- language: "\u8BED\u8A00"
1100
+ language: "\u8BED\u8A00",
1101
+ stealth: "\u6478\u9C7C\u6A21\u5F0F"
1102
+ },
1103
+ enumValues: {
1104
+ stealth: { off: "\u5173\u95ED", menu: "\u4E3B\u83DC\u5355\u663E\u793A", default: "\u9ED8\u8BA4\u7EC3\u4E60\u6A21\u5F0F" }
1010
1105
  },
1011
1106
  hints: {
1012
1107
  editing: "\u8F93\u5165\u4FEE\u6539 \xB7 Enter \u4FDD\u5B58 \xB7 Esc \u53D6\u6D88",
@@ -1017,7 +1112,7 @@ var zh = {
1017
1112
  }
1018
1113
  },
1019
1114
  stats: {
1020
- title: "\u7EDF\u8BA1",
1115
+ title: "\u7EDF\u8BA1 \xB7 \u6982\u89C8",
1021
1116
  loading: "\u52A0\u8F7D\u7EDF\u8BA1\u4E2D\u2026",
1022
1117
  none: "\u8FD8\u6CA1\u6709\u7EC3\u4E60\u8BB0\u5F55\u3002",
1023
1118
  nonePractice: "\u5148\u6765\u4E00\u6B21\u7EC3\u4E60\u5427\u3002",
@@ -1028,11 +1123,14 @@ var zh = {
1028
1123
  wpm: "\u901F\u5EA6",
1029
1124
  accuracy: "\u51C6\u786E\u7387",
1030
1125
  streak: "\u8FDE\u7EED\u5929\u6570",
1031
- last: (n) => `\u6700\u8FD1 ${n} \u5929 (n / N \u5207\u6362\u7A97\u53E3)`,
1032
- cycleWindow: "n / N \u5207\u6362\u7A97\u53E3 \xB7 q \u8FD4\u56DE",
1126
+ last: (n) => `\u6700\u8FD1 ${n} \u5929 (\u2190/\u2192 \u5207\u6362\u7A97\u53E3)`,
1127
+ cycleWindow: "\u2190/\u2192 \u5207\u6362\u7A97\u53E3 \xB7 Esc \u8FD4\u56DE",
1033
1128
  recent: "\u6700\u8FD1\u4F1A\u8BDD",
1034
1129
  topMistakes: "\u9AD8\u9891\u9519\u8BCD",
1035
- footer: "n / N \u5207\u6362\u7A97\u53E3 \xB7 q \u8FD4\u56DE"
1130
+ footer: "\u2190/\u2192 \u5207\u6362\u7A97\u53E3 \xB7 Esc \u8FD4\u56DE",
1131
+ maxLabel: "\u6700\u5927",
1132
+ recentUnits: { words: "\u8BCD", errors: "\u9519", wpm: "\u901F" },
1133
+ multiDictSuffix: (n) => ` \u7B49 ${n} \u90E8`
1036
1134
  },
1037
1135
  word: {
1038
1136
  title: "\u67E5\u8BCD",
@@ -1041,7 +1139,7 @@ var zh = {
1041
1139
  pullFirst: "\u5148\u5728\u300C\u8BCD\u5178\u300D\u4E2D\u62C9\u53D6\u4E00\u90E8\u3002",
1042
1140
  countAcross: (n) => `\u672C\u5730\u8BCD\u5178\u5171 ${n} \u8BCD`,
1043
1141
  noMatches: (q) => `\u6CA1\u6709\u5339\u914D\u300C${q}\u300D\u7684\u8BCD`,
1044
- inDict: (id) => `\u6765\u6E90:${id}`,
1142
+ inDict: (name) => `\u6765\u6E90:${name}`,
1045
1143
  mistakes: (n, date) => `\u9519\u8FC7 ${n} \u6B21 (\u6700\u8FD1 ${date})`,
1046
1144
  footer: "\u8F93\u5165\u8FC7\u6EE4 \xB7 \u2191/\u2193 \u9009\u62E9 \xB7 Esc \u8FD4\u56DE"
1047
1145
  },
@@ -1073,10 +1171,20 @@ var zh = {
1073
1171
  accuracy: "\u51C6\u786E\u7387",
1074
1172
  elapsed: (t) => `\u8017\u65F6 ${t}`
1075
1173
  },
1174
+ pause: {
1175
+ title: "\u5DF2\u6682\u505C",
1176
+ chapter: (c, t) => `\u7B2C ${c}/${t} \u7AE0`,
1177
+ progress: (completed, total) => `${completed}/${total}`,
1178
+ hint: "Enter \u7EE7\u7EED \xB7 Esc \u8FD4\u56DE\u83DC\u5355"
1179
+ },
1180
+ summary: {
1181
+ loopAgain: "\u518D\u6765\u4E00\u904D",
1182
+ nextChapter: "\u4E0B\u4E00\u7AE0",
1183
+ reviewMistakes: "\u590D\u4E60\u9519\u8BCD",
1184
+ backMenu: "\u8FD4\u56DE\u83DC\u5355"
1185
+ },
1076
1186
  footers: {
1077
- typing: "Ctrl+N \u8DF3\u8FC7 \xB7 Esc \u6682\u505C \xB7 Tab \u91CD\u64AD \xB7 Ctrl+C \u9000\u51FA",
1078
- paused: "[r] \u7EE7\u7EED \xB7 [q] \u8FD4\u56DE\u83DC\u5355",
1079
- summary: "[n] \u4E0B\u4E00\u7AE0 \xB7 [m] \u590D\u4E60\u9519\u8BCD \xB7 [q] \u8FD4\u56DE\u83DC\u5355"
1187
+ typing: "Ctrl+N \u8DF3\u8FC7 \xB7 Esc \u6682\u505C \xB7 Tab \u91CD\u64AD"
1080
1188
  },
1081
1189
  errors: {
1082
1190
  noMistakes: "\u9519\u8BCD\u672C\u662F\u7A7A\u7684\u3002\u5148\u7EC3\u4E60\u51E0\u7AE0\u5427\u3002",
@@ -1096,7 +1204,52 @@ var zh = {
1096
1204
  accuracy: "\u51C6\u786E\u7387",
1097
1205
  wpm: "\u901F\u5EA6",
1098
1206
  newMistakes: "\u65B0\u9519\u8BCD",
1099
- farewell: "\u4E0B\u6B21\u89C1\u3002"
1207
+ farewell: "\u4E0B\u6B21\u89C1\u3002",
1208
+ notPracticed: "\u672C\u6B21\u672A\u7EC3\u4E60"
1209
+ },
1210
+ help: {
1211
+ title: "\u5E2E\u52A9",
1212
+ subtitle: "\u5168\u90E8\u5FEB\u6377\u952E",
1213
+ sections: {
1214
+ main: "\u4E3B\u83DC\u5355",
1215
+ practice: "\u7EC3\u4E60",
1216
+ dict: "\u8BCD\u5178",
1217
+ config: "\u8BBE\u7F6E",
1218
+ stats: "\u7EDF\u8BA1",
1219
+ word: "\u67E5\u8BCD",
1220
+ global: "\u5168\u5C40"
1221
+ },
1222
+ keys: {
1223
+ navigate: "\u2191/\u2193 \u79FB\u52A8\u9009\u9879",
1224
+ select: "Enter \u786E\u8BA4 / \u7EE7\u7EED",
1225
+ letterJump: "\u5B57\u6BCD\u952E \u76F4\u8FBE\u83DC\u5355\u9879",
1226
+ pause: "Esc \u6682\u505C\u7EC3\u4E60",
1227
+ skip: "Ctrl+N \u8DF3\u8FC7\u5F53\u524D\u8BCD(\u4E0D\u8BA1\u9519)",
1228
+ replay: "Tab \u91CD\u64AD\u53D1\u97F3",
1229
+ resume: "Enter \u7EE7\u7EED\u7EC3\u4E60",
1230
+ backMenu: "Esc \u8FD4\u56DE\u4E0A\u4E00\u5C4F",
1231
+ backScreen: "Esc \u5173\u95ED\u9762\u677F / \u8FD4\u56DE",
1232
+ nextChapter: "Enter \u4E0B\u4E00\u7AE0",
1233
+ reviewMistakes: "m \u590D\u4E60\u9519\u8BCD",
1234
+ filter: "\u8F93\u5165 \u8FC7\u6EE4\u5217\u8868",
1235
+ itemActions: "Enter \u5F39\u51FA\u52A8\u4F5C\u9762\u677F",
1236
+ moreActions: "Ctrl+K \u5F39\u51FA\u66F4\u591A\u529F\u80FD",
1237
+ cycleWindow: "\u2190/\u2192 \u5207\u6362\u65E5\u7A97\u53E3",
1238
+ stealthToggle: "Ctrl+I \u5207\u6362\u6478\u9C7C\u4FE1\u606F\u884C",
1239
+ helpScreen: "? \u6253\u5F00\u672C\u5E2E\u52A9\u9875",
1240
+ quit: "Ctrl+C \u7ACB\u5373\u9000\u51FA"
1241
+ },
1242
+ footer: "Esc \u8FD4\u56DE"
1243
+ },
1244
+ stealth: {
1245
+ paused: "paused",
1246
+ chapterDone: "chapter done",
1247
+ resumeHint: "Enter resume \xB7 Esc menu",
1248
+ nextHint: "Enter next \xB7 Esc menu",
1249
+ pausedHintRight: "Enter \u7EE7\u7EED",
1250
+ nextHintRight: "Enter \u4E0B\u4E00\u7AE0",
1251
+ infoChipLabel: "\u4FE1\u606F",
1252
+ infoFmt: (dict, chapter, completed, total, wpm, accPct) => `${dict} \xB7 ${chapter} \xB7 ${completed}/${total} \xB7 ${wpm} wpm \xB7 ${accPct}%`
1100
1253
  }
1101
1254
  };
1102
1255
 
@@ -1145,13 +1298,55 @@ function pickStrings(pref) {
1145
1298
  return { lang, t: lang === "zh" ? zh : en };
1146
1299
  }
1147
1300
 
1301
+ // src/ui/registry-context.tsx
1302
+ import { createContext as createContext5, useContext as useContext5, useEffect as useEffect3, useState as useState5 } from "react";
1303
+ import { jsx as jsx6 } from "react/jsx-runtime";
1304
+ var RegistryContext = createContext5(null);
1305
+ function RegistryProvider({ children }) {
1306
+ const [registry, setRegistry] = useState5(null);
1307
+ const [byId, setById] = useState5(/* @__PURE__ */ new Map());
1308
+ useEffect3(() => {
1309
+ let cancelled = false;
1310
+ (async () => {
1311
+ try {
1312
+ const reg = await loadRegistry();
1313
+ if (cancelled) return;
1314
+ const map = /* @__PURE__ */ new Map();
1315
+ for (const e of reg) map.set(e.id, e);
1316
+ setRegistry(reg);
1317
+ setById(map);
1318
+ } catch {
1319
+ if (!cancelled) {
1320
+ setRegistry([]);
1321
+ setById(/* @__PURE__ */ new Map());
1322
+ }
1323
+ }
1324
+ })();
1325
+ return () => {
1326
+ cancelled = true;
1327
+ };
1328
+ }, []);
1329
+ return /* @__PURE__ */ jsx6(RegistryContext.Provider, { value: { registry, byId }, children });
1330
+ }
1331
+ function useRegistry() {
1332
+ const ctx = useContext5(RegistryContext);
1333
+ if (!ctx) throw new Error("useRegistry must be used inside RegistryProvider");
1334
+ return ctx;
1335
+ }
1336
+ function useDictName(id) {
1337
+ const { byId } = useRegistry();
1338
+ if (!id) return "";
1339
+ const entry = byId.get(id);
1340
+ return entry?.name ?? id;
1341
+ }
1342
+
1148
1343
  // src/ui/screens/MainMenu.tsx
1149
- import { useState as useState5 } from "react";
1344
+ import { useState as useState6 } from "react";
1150
1345
  import { Box as Box3, Text as Text2, useApp, useInput } from "ink";
1151
1346
 
1152
1347
  // src/ui/components/BigWord.tsx
1153
- import { Box as Box2, Text, useStdout as useStdout2 } from "ink";
1154
- import { jsx as jsx6, jsxs } from "react/jsx-runtime";
1348
+ import { Box as Box2, Text } from "ink";
1349
+ import { jsx as jsx7 } from "react/jsx-runtime";
1155
1350
  var PALETTE = {
1156
1351
  accent: "#5eead4",
1157
1352
  muted: "#6b7280",
@@ -1162,28 +1357,15 @@ var PALETTE = {
1162
1357
  error: "#f87171"
1163
1358
  };
1164
1359
  function BigWord({ target, typed, error = false, hideTarget = false }) {
1165
- const { stdout } = useStdout2();
1166
- const cols = stdout?.columns ?? 80;
1167
1360
  const chars = [...target];
1168
1361
  const typedChars = [...typed];
1169
- const sep = cols < 60 ? " " : " ";
1170
- return /* @__PURE__ */ jsx6(Box2, { justifyContent: "center", children: chars.map((ch, i) => {
1362
+ return /* @__PURE__ */ jsx7(Box2, { paddingY: 4, justifyContent: "center", children: chars.map((ch, i) => {
1171
1363
  const isTyped = i < typedChars.length;
1172
1364
  const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
1173
- const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
1174
- return /* @__PURE__ */ jsxs(Text, { bold: true, color, underline: !isTyped && error, children: [
1175
- display,
1176
- i < chars.length - 1 ? sep : ""
1177
- ] }, i);
1365
+ const color = error ? PALETTE.error : isTyped ? PALETTE.accent : PALETTE.muted;
1366
+ return /* @__PURE__ */ jsx7(Text, { bold: true, color, children: display }, i);
1178
1367
  }) });
1179
1368
  }
1180
- function BoldSpaced({ text, color = PALETTE.text }) {
1181
- const chars = [...text];
1182
- return /* @__PURE__ */ jsx6(Box2, { children: chars.map((ch, i) => /* @__PURE__ */ jsxs(Text, { bold: true, color, children: [
1183
- ch,
1184
- i < chars.length - 1 ? " " : ""
1185
- ] }, i)) });
1186
- }
1187
1369
 
1188
1370
  // src/util/text.ts
1189
1371
  var ANSI_RE = /\x1b\[[0-9;]*m/g;
@@ -1200,45 +1382,85 @@ function visibleWidth2(s) {
1200
1382
  return w;
1201
1383
  }
1202
1384
 
1385
+ // src/util/dict-name.ts
1386
+ function truncateName(name, max) {
1387
+ if (visibleWidth2(name) <= max) return name;
1388
+ let out = "";
1389
+ let w = 0;
1390
+ for (const ch of name) {
1391
+ const code = ch.codePointAt(0);
1392
+ const cw = code > 11904 && code < 64256 ? 2 : 1;
1393
+ if (w + cw > max - 1) break;
1394
+ out += ch;
1395
+ w += cw;
1396
+ }
1397
+ return out + "\u2026";
1398
+ }
1399
+
1203
1400
  // src/ui/screens/MainMenu.tsx
1204
- import { jsx as jsx7, jsxs as jsxs2 } from "react/jsx-runtime";
1401
+ import { jsx as jsx8, jsxs } from "react/jsx-runtime";
1205
1402
  function MainMenu({ cfg }) {
1206
- const [selected, setSelected] = useState5(0);
1403
+ const [selected, setSelected] = useState6(0);
1207
1404
  const { exit } = useApp();
1208
1405
  const nav = useNav();
1209
1406
  const audio = useAudioStatus();
1210
1407
  const t = useStrings();
1408
+ const defaultDictName = useDictName(cfg.defaultDict);
1211
1409
  const m = t.mainMenu.items;
1410
+ const startPractice = (stealth) => {
1411
+ if (cfg.defaultDict) {
1412
+ nav.navigate({
1413
+ name: "practice",
1414
+ params: {
1415
+ dictId: cfg.defaultDict,
1416
+ chapterIndex: 0,
1417
+ mode: cfg.defaultMode,
1418
+ stealth
1419
+ }
1420
+ });
1421
+ } else {
1422
+ nav.navigate({ name: "dict", params: { pickerMode: "choose-then-practice" } });
1423
+ }
1424
+ };
1212
1425
  const items = [
1213
1426
  {
1214
1427
  key: "p",
1215
1428
  label: m.practiceLabel,
1216
- hint: cfg.defaultDict ? m.practiceHintWith(cfg.defaultDict) : m.practiceHintNone,
1217
- run: () => {
1218
- if (cfg.defaultDict) {
1219
- nav.navigate({
1220
- name: "practice",
1221
- params: { dictId: cfg.defaultDict, chapterIndex: 0, mode: cfg.defaultMode }
1222
- });
1223
- } else {
1224
- nav.navigate({ name: "dict", params: { pickerMode: "choose-then-practice" } });
1225
- }
1226
- }
1227
- },
1429
+ hint: cfg.defaultDict ? m.practiceHintWith(truncateName(defaultDictName, 24)) : m.practiceHintNone,
1430
+ run: () => startPractice(cfg.stealth === "default")
1431
+ }
1432
+ ];
1433
+ if (cfg.stealth === "menu" || cfg.stealth === "default") {
1434
+ items.push({
1435
+ key: "b",
1436
+ label: m.stealthLabel,
1437
+ hint: m.stealthHint,
1438
+ run: () => startPractice(true)
1439
+ });
1440
+ }
1441
+ items.push(
1228
1442
  { key: "d", label: m.dictLabel, hint: m.dictHint, run: () => nav.navigate({ name: "dict" }) },
1229
1443
  { key: "w", label: m.wordLabel, hint: m.wordHint, run: () => nav.navigate({ name: "word" }) },
1230
1444
  { key: "s", label: m.statsLabel, hint: m.statsHint, run: () => nav.navigate({ name: "stats" }) },
1231
1445
  { key: "c", label: m.configLabel, hint: m.configHint, run: () => nav.navigate({ name: "config" }) },
1232
1446
  { key: "q", label: m.quitLabel, hint: m.quitHint, run: () => exit() }
1233
- ];
1447
+ );
1234
1448
  const labelW = Math.max(...items.map((it) => visibleWidth2(it.label))) + 4;
1235
1449
  useInput((input, key) => {
1450
+ if (key.escape) {
1451
+ exit();
1452
+ return;
1453
+ }
1236
1454
  if (key.upArrow) setSelected((i) => (i - 1 + items.length) % items.length);
1237
1455
  if (key.downArrow) setSelected((i) => (i + 1) % items.length);
1238
1456
  if (key.return) {
1239
1457
  items[selected].run();
1240
1458
  return;
1241
1459
  }
1460
+ if (input === "?") {
1461
+ nav.navigate({ name: "help" });
1462
+ return;
1463
+ }
1242
1464
  for (const it of items) {
1243
1465
  if (input === it.key) {
1244
1466
  it.run();
@@ -1246,41 +1468,44 @@ function MainMenu({ cfg }) {
1246
1468
  }
1247
1469
  }
1248
1470
  });
1249
- return /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
1250
- /* @__PURE__ */ jsxs2(Box3, { children: [
1251
- /* @__PURE__ */ jsx7(Text2, { bold: true, color: PALETTE.accent, children: t.app.title }),
1252
- /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
1471
+ return /* @__PURE__ */ jsxs(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
1472
+ /* @__PURE__ */ jsxs(Box3, { children: [
1473
+ /* @__PURE__ */ jsx8(Text2, { bold: true, color: PALETTE.accent, children: t.app.title }),
1474
+ /* @__PURE__ */ jsxs(Text2, { color: PALETTE.muted, children: [
1253
1475
  " \xB7 ",
1254
1476
  t.app.subtitle
1255
1477
  ] })
1256
1478
  ] }),
1257
- /* @__PURE__ */ jsx7(Box3, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
1479
+ /* @__PURE__ */ jsx8(Box3, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
1258
1480
  const active = i === selected;
1259
1481
  const pad = " ".repeat(Math.max(0, labelW - visibleWidth2(it.label)));
1260
- return /* @__PURE__ */ jsxs2(Box3, { children: [
1261
- /* @__PURE__ */ jsx7(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1262
- /* @__PURE__ */ jsxs2(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
1482
+ return /* @__PURE__ */ jsxs(Box3, { children: [
1483
+ /* @__PURE__ */ jsx8(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1484
+ /* @__PURE__ */ jsxs(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
1263
1485
  "[",
1264
1486
  it.key,
1265
1487
  "]"
1266
1488
  ] }),
1267
- /* @__PURE__ */ jsx7(Text2, { children: " " }),
1268
- /* @__PURE__ */ jsxs2(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
1489
+ /* @__PURE__ */ jsx8(Text2, { children: " " }),
1490
+ /* @__PURE__ */ jsxs(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
1269
1491
  it.label,
1270
1492
  pad
1271
1493
  ] }),
1272
- /* @__PURE__ */ jsx7(Text2, { color: PALETTE.muted, children: it.hint })
1494
+ /* @__PURE__ */ jsx8(Text2, { color: PALETTE.muted, children: it.hint })
1273
1495
  ] }, it.key);
1274
1496
  }) }),
1275
- /* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text2, { color: PALETTE.muted, children: cfg.defaultDict ? t.mainMenu.defaultDict(cfg.defaultDict) : t.mainMenu.noDefault }) }),
1276
- /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text2, { color: PALETTE.muted, children: t.mainMenu.hint }) }),
1277
- audio.warning && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text2, { color: PALETTE.warning, children: t.audio.noPlayer }) })
1497
+ /* @__PURE__ */ jsx8(Box3, { marginTop: 2, children: /* @__PURE__ */ jsxs(Text2, { color: PALETTE.muted, children: [
1498
+ t.mainMenu.hint,
1499
+ " \xB7 ",
1500
+ t.mainMenu.helpHint
1501
+ ] }) }),
1502
+ audio.warning && /* @__PURE__ */ jsx8(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text2, { color: PALETTE.warning, children: t.audio.noPlayer }) })
1278
1503
  ] });
1279
1504
  }
1280
1505
 
1281
1506
  // src/ui/screens/PracticeScreen.tsx
1282
- import { useState as useState7, useEffect as useEffect5, useRef as useRef3 } from "react";
1283
- import { Box as Box4, Text as Text3, useApp as useApp3, useInput as useInput3 } from "ink";
1507
+ import { useState as useState8, useEffect as useEffect6, useRef as useRef3 } from "react";
1508
+ import { Box as Box5, Text as Text4, useApp as useApp3, useInput as useInput3 } from "ink";
1284
1509
 
1285
1510
  // src/util/shuffle.ts
1286
1511
  function shuffle(arr, rng = Math.random) {
@@ -1479,7 +1704,7 @@ function topN(book, n) {
1479
1704
  }
1480
1705
 
1481
1706
  // src/ui/hooks/useWordLoop.ts
1482
- import { useEffect as useEffect3, useReducer, useRef, useState as useState6 } from "react";
1707
+ import { useEffect as useEffect4, useReducer, useRef, useState as useState7 } from "react";
1483
1708
  import { useInput as useInput2, useApp as useApp2 } from "ink";
1484
1709
  function reducer(state2, action) {
1485
1710
  if (action.type === "start") {
@@ -1513,7 +1738,7 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled =
1513
1738
  lastEffect: null
1514
1739
  }));
1515
1740
  const completedRef = useRef(false);
1516
- const [tick, setTick] = useState6(0);
1741
+ const [tick, setTick] = useState7(0);
1517
1742
  const { exit } = useApp2();
1518
1743
  useInput2(
1519
1744
  (input, key) => {
@@ -1545,13 +1770,13 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled =
1545
1770
  },
1546
1771
  { isActive: enabled }
1547
1772
  );
1548
- useEffect3(() => {
1773
+ useEffect4(() => {
1549
1774
  if (state2.session.finishedAt !== null && !completedRef.current) {
1550
1775
  completedRef.current = true;
1551
1776
  onComplete(state2.session);
1552
1777
  }
1553
1778
  }, [state2.session, onComplete]);
1554
- useEffect3(() => {
1779
+ useEffect4(() => {
1555
1780
  if (state2.session.finishedAt !== null) return;
1556
1781
  const id = setInterval(() => setTick((t) => t + 1), 1e3);
1557
1782
  return () => clearInterval(id);
@@ -1560,10 +1785,10 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled =
1560
1785
  }
1561
1786
 
1562
1787
  // src/ui/hooks/useAudio.ts
1563
- import { useEffect as useEffect4, useRef as useRef2 } from "react";
1788
+ import { useEffect as useEffect5, useRef as useRef2 } from "react";
1564
1789
  function useAudio(opts) {
1565
1790
  const initedRef = useRef2(false);
1566
- useEffect4(() => {
1791
+ useEffect5(() => {
1567
1792
  if (initedRef.current) return;
1568
1793
  initedRef.current = true;
1569
1794
  initAudio(!opts.enabled).catch(() => void 0);
@@ -1640,6 +1865,12 @@ function sparkline(values) {
1640
1865
  return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
1641
1866
  }).join("");
1642
1867
  }
1868
+ function dailyValues(sessions, days, metric, now = /* @__PURE__ */ new Date()) {
1869
+ const buckets = dailyBuckets(sessions, days, now);
1870
+ if (metric === "wpm") return buckets.map((b) => b.wpm);
1871
+ if (metric === "accuracy") return buckets.map((b) => b.accuracy * 100);
1872
+ return buckets.map((b) => b.sessions);
1873
+ }
1643
1874
  function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
1644
1875
  const out = [];
1645
1876
  const byDay = /* @__PURE__ */ new Map();
@@ -1737,16 +1968,114 @@ function useSessionPersistence(meta) {
1737
1968
  );
1738
1969
  }
1739
1970
 
1971
+ // src/ui/screens/StealthPracticeLayout.tsx
1972
+ import { Box as Box4, Text as Text3, useStdout as useStdout2 } from "ink";
1973
+ import { jsx as jsx9, jsxs as jsxs2 } from "react/jsx-runtime";
1974
+ var TYPED = "#d4d4d4";
1975
+ var UNTYPED = "#808080";
1976
+ var DIM = "#6b6b6b";
1977
+ var RIGHT_WIDTH = 28;
1978
+ function fmtTime(ms) {
1979
+ const total = Math.floor(ms / 1e3);
1980
+ const m = Math.floor(total / 60);
1981
+ const s = total % 60;
1982
+ return `${m}:${String(s).padStart(2, "0")}`;
1983
+ }
1984
+ function useLeftWidth() {
1985
+ const { stdout } = useStdout2();
1986
+ const cols = stdout?.columns ?? 80;
1987
+ return Math.max(20, cols - RIGHT_WIDTH);
1988
+ }
1989
+ function Row({ left, right }) {
1990
+ const leftWidth = useLeftWidth();
1991
+ return /* @__PURE__ */ jsxs2(Box4, { children: [
1992
+ /* @__PURE__ */ jsx9(Box4, { width: leftWidth, children: left }),
1993
+ /* @__PURE__ */ jsx9(Box4, { width: RIGHT_WIDTH, justifyContent: "flex-end", children: right })
1994
+ ] });
1995
+ }
1996
+ function StealthTyping(props) {
1997
+ const t = useStrings();
1998
+ const target = [...props.target];
1999
+ const typed = [...props.typed];
2000
+ const wordCell = /* @__PURE__ */ jsxs2(Box4, { children: [
2001
+ /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: "[" }),
2002
+ target.map((ch, i) => {
2003
+ const isTyped = i < typed.length;
2004
+ const display = props.hideTarget && !isTyped ? "_" : isTyped ? typed[i] : ch;
2005
+ const color = isTyped ? TYPED : UNTYPED;
2006
+ return /* @__PURE__ */ jsx9(Text3, { color, inverse: props.error && isTyped && i === typed.length - 1, children: display }, i);
2007
+ }),
2008
+ /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: "]" })
2009
+ ] });
2010
+ const phoneticTransCell = /* @__PURE__ */ jsxs2(Box4, { children: [
2011
+ props.phonetic && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: props.phonetic }),
2012
+ props.phonetic && props.translation.length > 0 && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: " \xB7 " }),
2013
+ props.translation.length > 0 && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: props.translation.slice(0, 1).join("") })
2014
+ ] });
2015
+ const info = props.info;
2016
+ const accFmt = Number.isInteger(info.accPct) ? `${info.accPct}` : info.accPct.toFixed(1);
2017
+ const right1 = info.visible ? /* @__PURE__ */ jsx9(Text3, { color: DIM, children: `${info.dictName} \xB7 ${info.chapterLabel}` }) : /* @__PURE__ */ jsxs2(Text3, { color: DIM, children: [
2018
+ "Ctrl+I ",
2019
+ t.stealth.infoChipLabel
2020
+ ] });
2021
+ const right2 = info.visible ? /* @__PURE__ */ jsx9(Text3, { color: DIM, children: `${info.completed}/${info.total} \xB7 ${info.wpm}wpm \xB7 ${accFmt}%` }) : /* @__PURE__ */ jsxs2(Text3, { color: DIM, children: [
2022
+ "Esc ",
2023
+ t.common.back
2024
+ ] });
2025
+ const right3 = info.visible ? /* @__PURE__ */ jsx9(Text3, { color: DIM, children: fmtTime(info.elapsedMs) }) : /* @__PURE__ */ jsx9(Text3, { children: " " });
2026
+ return /* @__PURE__ */ jsxs2(Box4, { flexDirection: "column", children: [
2027
+ /* @__PURE__ */ jsx9(Row, { left: wordCell, right: right1 }),
2028
+ /* @__PURE__ */ jsx9(Row, { left: phoneticTransCell, right: right2 }),
2029
+ /* @__PURE__ */ jsx9(Row, { left: /* @__PURE__ */ jsx9(Text3, { children: " " }), right: right3 })
2030
+ ] });
2031
+ }
2032
+ function StealthPaused() {
2033
+ const t = useStrings();
2034
+ return /* @__PURE__ */ jsxs2(Box4, { flexDirection: "column", children: [
2035
+ /* @__PURE__ */ jsx9(
2036
+ Row,
2037
+ {
2038
+ left: /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: t.stealth.paused }),
2039
+ right: /* @__PURE__ */ jsx9(Text3, { color: DIM, children: t.stealth.pausedHintRight })
2040
+ }
2041
+ ),
2042
+ /* @__PURE__ */ jsx9(Row, { left: /* @__PURE__ */ jsx9(Text3, { children: " " }), right: /* @__PURE__ */ jsxs2(Text3, { color: DIM, children: [
2043
+ "Esc ",
2044
+ t.common.back
2045
+ ] }) }),
2046
+ /* @__PURE__ */ jsx9(Row, { left: /* @__PURE__ */ jsx9(Text3, { children: " " }), right: /* @__PURE__ */ jsx9(Text3, { children: " " }) })
2047
+ ] });
2048
+ }
2049
+ function StealthSummary(props) {
2050
+ const t = useStrings();
2051
+ const accFmt = Number.isInteger(props.accPct) ? `${props.accPct}` : props.accPct.toFixed(1);
2052
+ const line = `${t.stealth.chapterDone} \xB7 ${props.wordCount}w \xB7 ${props.wpm}wpm \xB7 ${accFmt}% \xB7 ${fmtTime(props.durationMs)}`;
2053
+ return /* @__PURE__ */ jsxs2(Box4, { flexDirection: "column", children: [
2054
+ /* @__PURE__ */ jsx9(
2055
+ Row,
2056
+ {
2057
+ left: /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: line }),
2058
+ right: /* @__PURE__ */ jsx9(Text3, { color: DIM, children: t.stealth.nextHintRight })
2059
+ }
2060
+ ),
2061
+ /* @__PURE__ */ jsx9(Row, { left: /* @__PURE__ */ jsx9(Text3, { children: " " }), right: /* @__PURE__ */ jsxs2(Text3, { color: DIM, children: [
2062
+ "Esc ",
2063
+ t.common.back
2064
+ ] }) }),
2065
+ /* @__PURE__ */ jsx9(Row, { left: /* @__PURE__ */ jsx9(Text3, { children: " " }), right: /* @__PURE__ */ jsx9(Text3, { children: " " }) })
2066
+ ] });
2067
+ }
2068
+
1740
2069
  // src/ui/screens/PracticeScreen.tsx
1741
- import { jsx as jsx8, jsxs as jsxs3 } from "react/jsx-runtime";
2070
+ import { jsx as jsx10, jsxs as jsxs3 } from "react/jsx-runtime";
1742
2071
  function PracticeScreen({ params }) {
1743
2072
  const { dictId, chapterIndex, mode } = params;
1744
2073
  const { cfg } = useAppState();
1745
2074
  const t = useStrings();
1746
- const [phase, setPhase] = useState7("loading");
1747
- const [loaded, setLoaded] = useState7(null);
1748
- const [errorMsg, setErrorMsg] = useState7(null);
1749
- useEffect5(() => {
2075
+ const [phase, setPhase] = useState8("loading");
2076
+ const [loaded, setLoaded] = useState8(null);
2077
+ const [errorMsg, setErrorMsg] = useState8(null);
2078
+ useEffect6(() => {
1750
2079
  let cancelled = false;
1751
2080
  setPhase("loading");
1752
2081
  setLoaded(null);
@@ -1789,13 +2118,13 @@ function PracticeScreen({ params }) {
1789
2118
  };
1790
2119
  }, [dictId, chapterIndex, mode, cfg.chapterSize, t]);
1791
2120
  if (phase === "loading") {
1792
- return /* @__PURE__ */ jsx8(Centered, { text: t.practice.loading, color: PALETTE.muted });
2121
+ return /* @__PURE__ */ jsx10(Centered, { text: t.practice.loading, color: PALETTE.muted });
1793
2122
  }
1794
2123
  if (phase === "error") {
1795
- return /* @__PURE__ */ jsx8(ErrorView, { msg: errorMsg ?? t.practice.errors.unknown });
2124
+ return /* @__PURE__ */ jsx10(ErrorView, { msg: errorMsg ?? t.practice.errors.unknown });
1796
2125
  }
1797
2126
  if (!loaded) return null;
1798
- return /* @__PURE__ */ jsx8(
2127
+ return /* @__PURE__ */ jsx10(
1799
2128
  PracticeRunner,
1800
2129
  {
1801
2130
  params,
@@ -1803,7 +2132,7 @@ function PracticeScreen({ params }) {
1803
2132
  phase,
1804
2133
  setPhase
1805
2134
  },
1806
- `${dictId}-${chapterIndex}-${mode}`
2135
+ `${dictId}-${chapterIndex}-${mode}-${params.stealth ? "s" : "n"}`
1807
2136
  );
1808
2137
  }
1809
2138
  function PracticeRunner({
@@ -1813,19 +2142,28 @@ function PracticeRunner({
1813
2142
  setPhase
1814
2143
  }) {
1815
2144
  const { dictId, chapterIndex, mode } = params;
2145
+ const stealth = params.stealth === true;
1816
2146
  const { cfg } = useAppState();
1817
2147
  const nav = useNav();
1818
2148
  const { exit } = useApp3();
1819
2149
  const goBack = () => nav.stack.length > 1 ? nav.back() : exit();
1820
2150
  const persist = useSessionPersistence({ dictId, chapterIndex, mode });
2151
+ const dictName = useDictName(dictId);
1821
2152
  const audio = useAudio({
1822
- enabled: cfg.sounds.master,
2153
+ enabled: !stealth && cfg.sounds.master,
1823
2154
  accent: cfg.accent,
1824
- autoplayPronunciation: cfg.autoplayPronunciation
2155
+ autoplayPronunciation: !stealth && cfg.autoplayPronunciation
1825
2156
  });
1826
2157
  const finishedRef = useRef3(false);
1827
2158
  const lastEffectRef = useRef3(null);
1828
2159
  const lastIndexRef = useRef3(-1);
2160
+ const [infoVisible, setInfoVisible] = useState8(false);
2161
+ const [infoShownAt, setInfoShownAt] = useState8(null);
2162
+ useEffect6(() => {
2163
+ if (infoShownAt === null) return;
2164
+ const id = setTimeout(() => setInfoVisible(false), 2e3);
2165
+ return () => clearTimeout(id);
2166
+ }, [infoShownAt]);
1829
2167
  const { session, lastEffect, tick } = useWordLoop({
1830
2168
  playlist: loaded.playlist,
1831
2169
  enabled: phase === "typing",
@@ -1838,12 +2176,13 @@ function PracticeRunner({
1838
2176
  });
1839
2177
  },
1840
2178
  onEscape: () => setPhase(phase === "paused" ? "typing" : "paused"),
1841
- onTab: () => {
2179
+ onTab: stealth ? void 0 : () => {
1842
2180
  const cur = session.current ? loaded.playlist[session.current.wordIndex] : void 0;
1843
2181
  if (cur) void audio.pronounce(cur.name);
1844
2182
  }
1845
2183
  });
1846
- useEffect5(() => {
2184
+ useEffect6(() => {
2185
+ if (stealth) return;
1847
2186
  if (lastEffect === null) return;
1848
2187
  if (lastEffect === lastEffectRef.current) return;
1849
2188
  lastEffectRef.current = lastEffect;
@@ -1853,8 +2192,9 @@ function PracticeRunner({
1853
2192
  if (cfg.sounds.feedback) audio.correct();
1854
2193
  if (cfg.sounds.keystroke) audio.keystroke();
1855
2194
  }
1856
- }, [lastEffect, audio, cfg.sounds.feedback, cfg.sounds.keystroke]);
1857
- useEffect5(() => {
2195
+ }, [stealth, lastEffect, audio, cfg.sounds.feedback, cfg.sounds.keystroke]);
2196
+ useEffect6(() => {
2197
+ if (stealth) return;
1858
2198
  const idx = session.current?.wordIndex ?? -1;
1859
2199
  if (idx === -1) return;
1860
2200
  if (idx === lastIndexRef.current) return;
@@ -1863,63 +2203,150 @@ function PracticeRunner({
1863
2203
  const next = loaded.playlist[idx + 1];
1864
2204
  if (cur && cfg.autoplayPronunciation) audio.pronounce(cur.name);
1865
2205
  if (next) audio.prefetch(next.name);
1866
- }, [session.current?.wordIndex, audio, cfg.autoplayPronunciation, loaded.playlist]);
2206
+ }, [stealth, session.current?.wordIndex, audio, cfg.autoplayPronunciation, loaded.playlist]);
1867
2207
  void tick;
1868
2208
  useInput3(
1869
- (input) => {
1870
- if (input === "r") setPhase("typing");
1871
- if (input === "q") goBack();
2209
+ (input, key) => {
2210
+ if (key.ctrl && input === "i") {
2211
+ setInfoVisible(true);
2212
+ setInfoShownAt(Date.now());
2213
+ return;
2214
+ }
2215
+ },
2216
+ { isActive: stealth && phase === "typing" }
2217
+ );
2218
+ useInput3(
2219
+ (_input, key) => {
2220
+ if (key.return) {
2221
+ setPhase("typing");
2222
+ return;
2223
+ }
2224
+ if (key.escape) {
2225
+ goBack();
2226
+ return;
2227
+ }
1872
2228
  },
1873
2229
  { isActive: phase === "paused" }
1874
2230
  );
1875
2231
  useInput3(
1876
- (input) => {
1877
- if (input === "q") {
2232
+ (input, key) => {
2233
+ if (key.escape) {
1878
2234
  goBack();
1879
2235
  return;
1880
2236
  }
1881
- if (input === "n") {
2237
+ if (key.return) {
1882
2238
  const nextIdx = chapterIndex + 1;
1883
2239
  if (mode === "loop") {
1884
- nav.replace({ name: "practice", params: { dictId, chapterIndex, mode } });
2240
+ nav.replace({
2241
+ name: "practice",
2242
+ params: { dictId, chapterIndex, mode, stealth: params.stealth }
2243
+ });
1885
2244
  } else if (mode === "review" || nextIdx >= loaded.totalChapters) {
1886
2245
  goBack();
1887
2246
  } else {
1888
- nav.replace({ name: "practice", params: { dictId, chapterIndex: nextIdx, mode } });
2247
+ nav.replace({
2248
+ name: "practice",
2249
+ params: { dictId, chapterIndex: nextIdx, mode, stealth: params.stealth }
2250
+ });
1889
2251
  }
1890
2252
  return;
1891
2253
  }
1892
2254
  if (input === "m") {
1893
- nav.replace({ name: "practice", params: { dictId, chapterIndex: 0, mode: "review" } });
2255
+ nav.replace({
2256
+ name: "practice",
2257
+ params: { dictId, chapterIndex: 0, mode: "review", stealth: params.stealth }
2258
+ });
1894
2259
  return;
1895
2260
  }
1896
2261
  },
1897
2262
  { isActive: phase === "summary" }
1898
2263
  );
1899
- if (phase === "paused") return /* @__PURE__ */ jsx8(PausedView, {});
1900
- if (phase === "summary") {
1901
- return /* @__PURE__ */ jsx8(
2264
+ const completed = session.results.length;
2265
+ const errors = session.results.reduce((a, r) => a + r.errors, 0);
2266
+ const elapsedMs = Date.now() - session.startedAt;
2267
+ const minutes = elapsedMs / 6e4;
2268
+ const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
2269
+ const summary = phase === "summary" ? sessionSummary(session) : null;
2270
+ if (stealth) {
2271
+ if (phase === "paused") return /* @__PURE__ */ jsx10(StealthPaused, {});
2272
+ if (phase === "summary" && summary) {
2273
+ const sMinutes = summary.durationMs / 6e4;
2274
+ const sWpm = sMinutes > 0 ? Math.round(summary.wordCount / sMinutes * 10) / 10 : 0;
2275
+ const sErrWords = Object.keys(summary.perWordErrors).length;
2276
+ const sAcc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - sErrWords) / summary.wordCount);
2277
+ const sAccPct = Math.round(sAcc * 1e3) / 10;
2278
+ return /* @__PURE__ */ jsx10(
2279
+ StealthSummary,
2280
+ {
2281
+ wordCount: summary.wordCount,
2282
+ errors: summary.errors,
2283
+ durationMs: summary.durationMs,
2284
+ wpm: sWpm,
2285
+ accPct: sAccPct
2286
+ }
2287
+ );
2288
+ }
2289
+ const currentWord2 = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
2290
+ const inputState2 = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
2291
+ const errWords = new Set(
2292
+ session.results.filter((r) => r.errors > 0).map((r) => r.word)
2293
+ ).size;
2294
+ const accFrac = completed === 0 ? 1 : Math.max(0, (completed - errWords) / completed);
2295
+ const accPct = Math.round(accFrac * 1e3) / 10;
2296
+ const chapterLabel = mode === "review" ? "review" : `ch ${chapterIndex + 1}/${loaded.totalChapters}`;
2297
+ return /* @__PURE__ */ jsx10(
2298
+ StealthTyping,
2299
+ {
2300
+ target: currentWord2?.name ?? "",
2301
+ typed: inputState2.typed,
2302
+ hideTarget: mode === "dictation",
2303
+ phonetic: pickPhonetic(currentWord2, cfg.accent),
2304
+ translation: currentWord2?.trans ?? [],
2305
+ error: lastEffect === "wrong",
2306
+ info: {
2307
+ visible: infoVisible,
2308
+ dictName: truncateName(dictName, 24),
2309
+ chapterLabel,
2310
+ completed,
2311
+ total: loaded.playlist.length,
2312
+ wpm,
2313
+ accPct,
2314
+ elapsedMs
2315
+ }
2316
+ }
2317
+ );
2318
+ }
2319
+ if (phase === "paused") {
2320
+ return /* @__PURE__ */ jsx10(
2321
+ PausedView,
2322
+ {
2323
+ dictName,
2324
+ chapterIndex,
2325
+ totalChapters: loaded.totalChapters,
2326
+ mode,
2327
+ completed,
2328
+ total: loaded.playlist.length
2329
+ }
2330
+ );
2331
+ }
2332
+ if (phase === "summary" && summary) {
2333
+ return /* @__PURE__ */ jsx10(
1902
2334
  SummaryView,
1903
2335
  {
1904
- dictId,
2336
+ dictName,
1905
2337
  chapterIndex,
1906
2338
  totalChapters: loaded.totalChapters,
1907
2339
  mode,
1908
- summary: sessionSummary(session)
2340
+ summary
1909
2341
  }
1910
2342
  );
1911
2343
  }
1912
2344
  const currentWord = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
1913
2345
  const inputState = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
1914
- const elapsedMs = Date.now() - session.startedAt;
1915
- const completed = session.results.length;
1916
- const errors = session.results.reduce((a, r) => a + r.errors, 0);
1917
- const minutes = elapsedMs / 6e4;
1918
- const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
1919
- return /* @__PURE__ */ jsx8(
2346
+ return /* @__PURE__ */ jsx10(
1920
2347
  TypingLayout,
1921
2348
  {
1922
- dictId,
2349
+ dictName,
1923
2350
  chapterIndex,
1924
2351
  totalChapters: loaded.totalChapters,
1925
2352
  mode,
@@ -1943,7 +2370,7 @@ function pickPhonetic(word, accent) {
1943
2370
  const p = accent === "us" ? word.usphone : word.ukphone;
1944
2371
  return p ? `/${p}/` : null;
1945
2372
  }
1946
- function fmtTime(ms) {
2373
+ function fmtTime2(ms) {
1947
2374
  const total = Math.floor(ms / 1e3);
1948
2375
  const m = Math.floor(total / 60);
1949
2376
  const s = total % 60;
@@ -1952,11 +2379,11 @@ function fmtTime(ms) {
1952
2379
  function TypingLayout(props) {
1953
2380
  const t = useStrings();
1954
2381
  const progressFrac = props.total === 0 ? 0 : props.completed / props.total;
1955
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
1956
- /* @__PURE__ */ jsx8(
2382
+ return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2383
+ /* @__PURE__ */ jsx10(
1957
2384
  StatusBar,
1958
2385
  {
1959
- dictId: props.dictId,
2386
+ dictName: props.dictName,
1960
2387
  chapterIndex: props.chapterIndex,
1961
2388
  totalChapters: props.totalChapters,
1962
2389
  mode: props.mode,
@@ -1966,8 +2393,8 @@ function TypingLayout(props) {
1966
2393
  elapsedMs: props.elapsedMs
1967
2394
  }
1968
2395
  ),
1969
- /* @__PURE__ */ jsxs3(Box4, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
1970
- /* @__PURE__ */ jsx8(
2396
+ /* @__PURE__ */ jsxs3(Box5, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
2397
+ /* @__PURE__ */ jsx10(
1971
2398
  BigWord,
1972
2399
  {
1973
2400
  target: props.target,
@@ -1976,17 +2403,17 @@ function TypingLayout(props) {
1976
2403
  hideTarget: props.hideTarget
1977
2404
  }
1978
2405
  ),
1979
- props.phonetic && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text3, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
1980
- props.translation.length > 0 && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((tr, i) => /* @__PURE__ */ jsx8(Text3, { color: PALETTE.primary, children: tr }, i)) })
2406
+ props.phonetic && /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
2407
+ props.translation.length > 0 && /* @__PURE__ */ jsx10(Box5, { marginTop: 1, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((tr, i) => /* @__PURE__ */ jsx10(Text4, { color: PALETTE.primary, children: tr }, i)) })
1981
2408
  ] }),
1982
- /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
1983
- /* @__PURE__ */ jsx8(ProgressBar, { frac: progressFrac }),
1984
- /* @__PURE__ */ jsx8(Box4, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
2409
+ /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", children: [
2410
+ /* @__PURE__ */ jsx10(ProgressBar, { frac: progressFrac }),
2411
+ /* @__PURE__ */ jsx10(Box5, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs3(Text4, { color: PALETTE.muted, children: [
1985
2412
  props.completed,
1986
2413
  "/",
1987
2414
  props.total,
1988
2415
  " \xB7 ",
1989
- fmtTime(props.elapsedMs),
2416
+ fmtTime2(props.elapsedMs),
1990
2417
  " \xB7 ",
1991
2418
  props.wpm,
1992
2419
  " ",
@@ -1996,7 +2423,7 @@ function TypingLayout(props) {
1996
2423
  " ",
1997
2424
  t.practice.statCards.errors
1998
2425
  ] }) }),
1999
- /* @__PURE__ */ jsx8(Box4, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.footers.typing }) })
2426
+ /* @__PURE__ */ jsx10(Box5, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.footers.typing }) })
2000
2427
  ] })
2001
2428
  ] });
2002
2429
  }
@@ -2004,12 +2431,13 @@ function StatusBar(props) {
2004
2431
  const t = useStrings();
2005
2432
  const modeName = t.practice.modes[props.mode];
2006
2433
  const accentName = t.practice.accents[props.accent];
2007
- const left = props.mode === "review" ? `${props.dictId} \xB7 ${t.practice.reviewLabel} \xB7 ${accentName}` : `${props.dictId} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName} \xB7 ${accentName}`;
2008
- const right = `${props.completed}/${props.total} \xB7 ${fmtTime(props.elapsedMs)}`;
2009
- return /* @__PURE__ */ jsxs3(Box4, { children: [
2010
- /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: left }),
2011
- /* @__PURE__ */ jsx8(Box4, { flexGrow: 1 }),
2012
- /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: right })
2434
+ const name = truncateName(props.dictName, 20);
2435
+ 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}`;
2436
+ const right = `${props.completed}/${props.total} \xB7 ${fmtTime2(props.elapsedMs)}`;
2437
+ return /* @__PURE__ */ jsxs3(Box5, { children: [
2438
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: left }),
2439
+ /* @__PURE__ */ jsx10(Box5, { flexGrow: 1 }),
2440
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: right })
2013
2441
  ] });
2014
2442
  }
2015
2443
  function ProgressBar({ frac }) {
@@ -2017,38 +2445,43 @@ function ProgressBar({ frac }) {
2017
2445
  const width = Math.max(20, Math.min(72, cols - 16));
2018
2446
  const filled = Math.round(width * Math.max(0, Math.min(1, frac)));
2019
2447
  const empty = width - filled;
2020
- return /* @__PURE__ */ jsxs3(Box4, { justifyContent: "center", children: [
2021
- /* @__PURE__ */ jsx8(Text3, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
2022
- /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
2448
+ return /* @__PURE__ */ jsxs3(Box5, { justifyContent: "center", children: [
2449
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
2450
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
2023
2451
  ] });
2024
2452
  }
2025
- function PausedView() {
2453
+ function PausedView(props) {
2026
2454
  const t = useStrings();
2027
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2028
- /* @__PURE__ */ jsx8(BoldSpaced, { text: t.practice.paused, color: PALETTE.warning }),
2029
- /* @__PURE__ */ jsx8(Box4, { marginTop: 2, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.footers.paused }) })
2455
+ const frac = props.total === 0 ? 0 : props.completed / props.total;
2456
+ 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)}`;
2457
+ return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2458
+ /* @__PURE__ */ jsx10(Text4, { bold: true, color: PALETTE.warning, children: t.practice.pause.title }),
2459
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: subtitle }) }),
2460
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(ProgressBar, { frac }) }),
2461
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.pause.progress(props.completed, props.total) }) }),
2462
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.pause.hint }) })
2030
2463
  ] });
2031
2464
  }
2032
2465
  function ErrorView({ msg }) {
2033
2466
  const t = useStrings();
2034
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2035
- /* @__PURE__ */ jsx8(Text3, { color: PALETTE.error, children: msg }),
2036
- /* @__PURE__ */ jsx8(Box4, { marginTop: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
2037
- "[q] ",
2467
+ return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2468
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.error, children: msg }),
2469
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsxs3(Text4, { color: PALETTE.muted, children: [
2470
+ "Esc ",
2038
2471
  t.common.back
2039
2472
  ] }) }),
2040
- /* @__PURE__ */ jsx8(BackKey, {})
2473
+ /* @__PURE__ */ jsx10(BackKey, {})
2041
2474
  ] });
2042
2475
  }
2043
2476
  function BackKey() {
2044
2477
  const nav = useNav();
2045
- useInput3((input, key) => {
2046
- if (input === "q" || key.escape) nav.back();
2478
+ useInput3((_input, key) => {
2479
+ if (key.escape) nav.back();
2047
2480
  });
2048
2481
  return null;
2049
2482
  }
2050
2483
  function Centered({ text, color }) {
2051
- return /* @__PURE__ */ jsx8(Box4, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx8(Text3, { color, children: text }) });
2484
+ return /* @__PURE__ */ jsx10(Box5, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx10(Text4, { color, children: text }) });
2052
2485
  }
2053
2486
  function SummaryView(props) {
2054
2487
  const { summary } = props;
@@ -2059,13 +2492,16 @@ function SummaryView(props) {
2059
2492
  const accPct = Math.round(acc * 1e3) / 10;
2060
2493
  const t = useStrings();
2061
2494
  const modeName = t.practice.modes[props.mode];
2062
- const subtitle = props.mode === "review" ? `${props.dictId} \xB7 ${t.practice.reviewLabel}` : `${props.dictId} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName}`;
2063
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
2064
- /* @__PURE__ */ jsx8(BoldSpaced, { text: t.practice.chapterComplete, color: PALETTE.success }),
2065
- /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: subtitle }) }),
2066
- /* @__PURE__ */ jsxs3(Box4, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
2067
- /* @__PURE__ */ jsx8(StatCard, { label: t.practice.statCards.words, value: String(summary.wordCount), color: PALETTE.text }),
2068
- /* @__PURE__ */ jsx8(
2495
+ const name = truncateName(props.dictName, 20);
2496
+ const subtitle = props.mode === "review" ? `${name} \xB7 ${t.practice.reviewLabel}` : `${name} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName}`;
2497
+ 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;
2498
+ const footer = `Enter ${nextLabel} \xB7 m ${t.practice.summary.reviewMistakes} \xB7 Esc ${t.practice.summary.backMenu}`;
2499
+ return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
2500
+ /* @__PURE__ */ jsx10(Text4, { bold: true, color: PALETTE.success, children: t.practice.chapterComplete }),
2501
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: subtitle }) }),
2502
+ /* @__PURE__ */ jsxs3(Box5, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
2503
+ /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.words, value: String(summary.wordCount), color: PALETTE.text }),
2504
+ /* @__PURE__ */ jsx10(
2069
2505
  StatCard,
2070
2506
  {
2071
2507
  label: t.practice.statCards.errors,
@@ -2073,36 +2509,107 @@ function SummaryView(props) {
2073
2509
  color: summary.errors > 0 ? PALETTE.error : PALETTE.muted
2074
2510
  }
2075
2511
  ),
2076
- /* @__PURE__ */ jsx8(StatCard, { label: t.practice.statCards.wpm, value: String(wpm), color: PALETTE.accent }),
2077
- /* @__PURE__ */ jsx8(StatCard, { label: t.practice.statCards.accuracy, value: `${accPct}%`, color: PALETTE.accent })
2512
+ /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.wpm, value: String(wpm), color: PALETTE.accent }),
2513
+ /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.accuracy, value: `${accPct}%`, color: PALETTE.accent })
2078
2514
  ] }),
2079
- /* @__PURE__ */ jsx8(Box4, { marginTop: 2, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.statCards.elapsed(fmtTime(summary.durationMs)) }) }),
2080
- /* @__PURE__ */ jsx8(Box4, { flexGrow: 1 }),
2081
- /* @__PURE__ */ jsx8(Box4, { marginTop: 2, children: /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: t.practice.footers.summary }) })
2515
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.statCards.elapsed(fmtTime2(summary.durationMs)) }) }),
2516
+ /* @__PURE__ */ jsx10(Box5, { flexGrow: 1 }),
2517
+ /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: footer }) })
2082
2518
  ] });
2083
2519
  }
2084
2520
  function StatCard({ label, value, color }) {
2085
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", alignItems: "center", marginX: 3, children: [
2086
- /* @__PURE__ */ jsx8(Text3, { bold: true, color, children: value }),
2087
- /* @__PURE__ */ jsx8(Text3, { color: PALETTE.muted, children: label })
2521
+ return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", alignItems: "center", marginX: 3, children: [
2522
+ /* @__PURE__ */ jsx10(Text4, { bold: true, color, children: value }),
2523
+ /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: label })
2088
2524
  ] });
2089
2525
  }
2090
2526
 
2091
2527
  // src/ui/screens/DictBrowser.tsx
2092
- import { useEffect as useEffect6, useState as useState8 } from "react";
2093
- import { Box as Box5, Text as Text4, useInput as useInput4 } from "ink";
2094
- import { Fragment, jsx as jsx9, jsxs as jsxs4 } from "react/jsx-runtime";
2528
+ import { useEffect as useEffect7, useMemo as useMemo2, useState as useState10 } from "react";
2529
+ import { Box as Box7, Text as Text6, useInput as useInput5, useStdout as useStdout3 } from "ink";
2530
+
2531
+ // src/ui/components/ActionPanel.tsx
2532
+ import { useState as useState9 } from "react";
2533
+ import { Box as Box6, Text as Text5, useInput as useInput4 } from "ink";
2534
+ import { jsx as jsx11, jsxs as jsxs4 } from "react/jsx-runtime";
2535
+ function ActionPanel({ title, items, onClose }) {
2536
+ const enabledIndices = items.map((it, i) => it.disabled ? -1 : i).filter((i) => i >= 0);
2537
+ const initial = enabledIndices[0] ?? 0;
2538
+ const [selected, setSelected] = useState9(initial);
2539
+ useInput4((input, key) => {
2540
+ if (key.escape) {
2541
+ onClose();
2542
+ return;
2543
+ }
2544
+ if (key.upArrow) {
2545
+ const cur = enabledIndices.indexOf(selected);
2546
+ const next = enabledIndices[(cur - 1 + enabledIndices.length) % enabledIndices.length];
2547
+ if (next !== void 0) setSelected(next);
2548
+ return;
2549
+ }
2550
+ if (key.downArrow) {
2551
+ const cur = enabledIndices.indexOf(selected);
2552
+ const next = enabledIndices[(cur + 1) % enabledIndices.length];
2553
+ if (next !== void 0) setSelected(next);
2554
+ return;
2555
+ }
2556
+ if (key.return) {
2557
+ const item = items[selected];
2558
+ if (item && !item.disabled) {
2559
+ void item.run();
2560
+ }
2561
+ return;
2562
+ }
2563
+ for (let i = 0; i < items.length; i++) {
2564
+ const it = items[i];
2565
+ if (it.disabled) continue;
2566
+ if (it.key && input === it.key) {
2567
+ void it.run();
2568
+ return;
2569
+ }
2570
+ }
2571
+ });
2572
+ const maxLabel = Math.max(...items.map((it) => it.label.length));
2573
+ const width = Math.max(maxLabel + 8, title.length + 4, 24);
2574
+ return /* @__PURE__ */ jsxs4(
2575
+ Box6,
2576
+ {
2577
+ flexDirection: "column",
2578
+ borderStyle: "round",
2579
+ borderColor: PALETTE.accent,
2580
+ paddingX: 2,
2581
+ paddingY: 1,
2582
+ width,
2583
+ children: [
2584
+ /* @__PURE__ */ jsx11(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx11(Text5, { bold: true, color: PALETTE.accent, children: title }) }),
2585
+ items.map((it, i) => {
2586
+ const active = i === selected;
2587
+ const color = it.disabled ? PALETTE.muted : active ? PALETTE.text : PALETTE.muted;
2588
+ return /* @__PURE__ */ jsxs4(Box6, { children: [
2589
+ /* @__PURE__ */ jsx11(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2590
+ /* @__PURE__ */ jsx11(Text5, { bold: active, color, children: it.label })
2591
+ ] }, i);
2592
+ }),
2593
+ /* @__PURE__ */ jsx11(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text5, { color: PALETTE.muted, children: "\u2191/\u2193 \xB7 Enter \xB7 Esc" }) })
2594
+ ]
2595
+ }
2596
+ );
2597
+ }
2598
+
2599
+ // src/ui/screens/DictBrowser.tsx
2600
+ import { Fragment, jsx as jsx12, jsxs as jsxs5 } from "react/jsx-runtime";
2095
2601
  function DictBrowser({ params }) {
2096
2602
  const nav = useNav();
2097
2603
  const { cfg, setCfg } = useAppState();
2098
2604
  const t = useStrings();
2099
- const [rows, setRows] = useState8([]);
2100
- const [loading, setLoading] = useState8(true);
2101
- const [selected, setSelected] = useState8(0);
2102
- const [filter, setFilter] = useState8("");
2103
- const [filterFocus, setFilterFocus] = useState8(false);
2104
- const [pending, setPending] = useState8(null);
2105
- const [tick, setTick] = useState8(0);
2605
+ const { stdout } = useStdout3();
2606
+ const [rows, setRows] = useState10([]);
2607
+ const [loading, setLoading] = useState10(true);
2608
+ const [selected, setSelected] = useState10(0);
2609
+ const [filter, setFilter] = useState10("");
2610
+ const [pending, setPending] = useState10(null);
2611
+ const [tick, setTick] = useState10(0);
2612
+ const [panel, setPanel] = useState10(null);
2106
2613
  const refresh = async () => {
2107
2614
  const reg = await loadRegistry();
2108
2615
  const flagged = await Promise.all(
@@ -2111,141 +2618,221 @@ function DictBrowser({ params }) {
2111
2618
  setRows(flagged);
2112
2619
  setLoading(false);
2113
2620
  };
2114
- useEffect6(() => {
2621
+ useEffect7(() => {
2115
2622
  void refresh();
2116
2623
  }, [tick]);
2117
- const filtered = filter ? rows.filter((r) => filterRegistry([r.entry], filter).length > 0) : rows;
2624
+ const filtered = useMemo2(
2625
+ () => filter ? rows.filter((r) => filterRegistry([r.entry], filter).length > 0) : rows,
2626
+ [filter, rows]
2627
+ );
2118
2628
  const safeSelected = Math.max(0, Math.min(filtered.length - 1, selected));
2119
2629
  const current = filtered[safeSelected];
2120
- useInput4((input, key) => {
2121
- if (filterFocus) {
2122
- if (key.escape || key.return) {
2123
- setFilterFocus(false);
2124
- return;
2630
+ const rowsTotal = stdout?.rows ?? 24;
2631
+ const visibleH = Math.max(6, rowsTotal - 8);
2632
+ const half = Math.floor(visibleH / 2);
2633
+ const start2 = Math.max(0, Math.min(filtered.length - visibleH, safeSelected - half));
2634
+ const end = Math.min(filtered.length, start2 + visibleH);
2635
+ const goPractice = (id) => {
2636
+ nav.replace({
2637
+ name: "practice",
2638
+ params: {
2639
+ dictId: id,
2640
+ chapterIndex: 0,
2641
+ mode: cfg.defaultMode,
2642
+ stealth: cfg.stealth === "default"
2125
2643
  }
2126
- if (key.backspace || key.delete) {
2127
- setFilter((f) => f.slice(0, -1));
2128
- return;
2644
+ });
2645
+ };
2646
+ const doSetDefault = async (id, navigate = true) => {
2647
+ await setCfg({ ...cfg, defaultDict: id });
2648
+ setPanel(null);
2649
+ if (navigate) {
2650
+ if (params?.pickerMode === "choose-then-practice") {
2651
+ goPractice(id);
2652
+ } else {
2653
+ nav.back();
2129
2654
  }
2130
- if (input && !key.ctrl && !key.meta) {
2131
- setFilter((f) => f + input);
2655
+ }
2656
+ };
2657
+ const doDelete = (id) => {
2658
+ setPanel(null);
2659
+ setPending({ kind: "removing", id });
2660
+ void (async () => {
2661
+ try {
2662
+ await removeDictionary(id);
2663
+ setPending(null);
2664
+ setTick((n) => n + 1);
2665
+ } catch (err) {
2666
+ setPending({ kind: "error", id, msg: err.message });
2667
+ }
2668
+ })();
2669
+ };
2670
+ const doPull = (id) => {
2671
+ setPanel(null);
2672
+ setPending({ kind: "pulling", id });
2673
+ void (async () => {
2674
+ try {
2675
+ await pullDictionary(id);
2676
+ setPending(null);
2677
+ setTick((n) => n + 1);
2678
+ } catch (err) {
2679
+ setPending({ kind: "error", id, msg: err.message });
2132
2680
  }
2681
+ })();
2682
+ };
2683
+ const doRefreshList = () => {
2684
+ setPanel(null);
2685
+ setPending({ kind: "refreshing" });
2686
+ setTick((n) => n + 1);
2687
+ setPending(null);
2688
+ };
2689
+ useInput5((input, key) => {
2690
+ if (panel !== null) return;
2691
+ if (key.escape) {
2692
+ nav.back();
2133
2693
  return;
2134
2694
  }
2135
- if (key.upArrow) setSelected((i) => Math.max(0, i - 1));
2136
- if (key.downArrow) setSelected((i) => Math.min(filtered.length - 1, i + 1));
2137
- if (input === "/") {
2138
- setFilterFocus(true);
2695
+ if (key.upArrow) {
2696
+ setSelected((i) => Math.max(0, i - 1));
2139
2697
  return;
2140
2698
  }
2141
- if (key.escape || input === "b") {
2142
- nav.back();
2699
+ if (key.downArrow) {
2700
+ setSelected((i) => Math.min(filtered.length - 1, i + 1));
2143
2701
  return;
2144
2702
  }
2145
- if (!current) return;
2146
- if (key.return) {
2147
- void (async () => {
2148
- await setCfg({ ...cfg, defaultDict: current.entry.id });
2149
- if (params?.pickerMode === "choose-then-practice") {
2150
- nav.replace({
2151
- name: "practice",
2152
- params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
2153
- });
2154
- } else {
2155
- nav.back();
2156
- }
2157
- })();
2703
+ if (key.ctrl && input === "k") {
2704
+ setPanel("more");
2158
2705
  return;
2159
2706
  }
2160
- if (input === "p") {
2161
- nav.replace({
2162
- name: "practice",
2163
- params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
2164
- });
2707
+ if (key.return) {
2708
+ if (current) setPanel("item");
2165
2709
  return;
2166
2710
  }
2167
- if (input === "r" && current.local) {
2168
- setPending({ kind: "removing", id: current.entry.id });
2169
- void (async () => {
2170
- try {
2171
- await removeDictionary(current.entry.id);
2172
- setPending(null);
2173
- setTick((n) => n + 1);
2174
- } catch (err) {
2175
- setPending({ kind: "error", id: current.entry.id, msg: err.message });
2176
- }
2177
- })();
2711
+ if (key.backspace || key.delete) {
2712
+ setFilter((f) => f.slice(0, -1));
2713
+ setSelected(0);
2178
2714
  return;
2179
2715
  }
2180
- if (input === "u") {
2181
- setPending({ kind: "pulling", id: current.entry.id });
2182
- void (async () => {
2183
- try {
2184
- await pullDictionary(current.entry.id);
2185
- setPending(null);
2186
- setTick((n) => n + 1);
2187
- } catch (err) {
2188
- setPending({ kind: "error", id: current.entry.id, msg: err.message });
2189
- }
2190
- })();
2716
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
2717
+ setFilter((f) => f + input);
2718
+ setSelected(0);
2191
2719
  }
2192
2720
  });
2193
2721
  if (loading) {
2194
- return /* @__PURE__ */ jsx9(Box5, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: t.dict.loading }) });
2722
+ return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.loading }) });
2195
2723
  }
2196
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2197
- /* @__PURE__ */ jsxs4(Box5, { children: [
2198
- /* @__PURE__ */ jsx9(Text4, { bold: true, color: PALETTE.accent, children: t.dict.title }),
2199
- /* @__PURE__ */ jsx9(Box5, { flexGrow: 1 }),
2200
- /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: filterFocus ? `/ ${filter}_` : filter ? t.dict.filterPrompt(filter) : t.dict.entries(filtered.length) })
2724
+ const itemPanelItems = current ? [
2725
+ {
2726
+ label: t.dict.action.setDefault,
2727
+ run: () => void doSetDefault(current.entry.id, params?.pickerMode !== void 0)
2728
+ },
2729
+ {
2730
+ label: t.dict.action.practice,
2731
+ run: () => goPractice(current.entry.id)
2732
+ },
2733
+ {
2734
+ label: t.dict.action.delete,
2735
+ disabled: !current.local,
2736
+ run: () => doDelete(current.entry.id)
2737
+ },
2738
+ { label: t.common.cancel, run: () => setPanel(null) }
2739
+ ] : [];
2740
+ const morePanelItems = [
2741
+ {
2742
+ label: t.dict.command.pull,
2743
+ disabled: !current,
2744
+ run: () => current && doPull(current.entry.id)
2745
+ },
2746
+ {
2747
+ label: t.dict.command.import,
2748
+ disabled: true,
2749
+ run: () => void 0
2750
+ },
2751
+ { label: t.dict.command.refreshList, run: () => doRefreshList() },
2752
+ { label: t.common.cancel, run: () => setPanel(null) }
2753
+ ];
2754
+ if (panel === "item" && current) {
2755
+ return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(
2756
+ ActionPanel,
2757
+ {
2758
+ title: `${t.dict.action.title} \xB7 ${current.entry.name}`,
2759
+ items: itemPanelItems,
2760
+ onClose: () => setPanel(null)
2761
+ }
2762
+ ) });
2763
+ }
2764
+ if (panel === "more") {
2765
+ return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(
2766
+ ActionPanel,
2767
+ {
2768
+ title: t.dict.command.title,
2769
+ items: morePanelItems,
2770
+ onClose: () => setPanel(null)
2771
+ }
2772
+ ) });
2773
+ }
2774
+ return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2775
+ /* @__PURE__ */ jsxs5(Box7, { children: [
2776
+ /* @__PURE__ */ jsx12(Text6, { bold: true, color: PALETTE.accent, children: t.dict.title }),
2777
+ /* @__PURE__ */ jsx12(Box7, { flexGrow: 1 }),
2778
+ /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: filter ? `${t.dict.filterPlaceholder}: ${filter}_` : `${t.dict.filterPlaceholder}_` }),
2779
+ /* @__PURE__ */ jsxs5(Text6, { color: PALETTE.muted, children: [
2780
+ " ",
2781
+ t.dict.entries(filtered.length)
2782
+ ] })
2201
2783
  ] }),
2202
- /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexGrow: 1, children: [
2203
- /* @__PURE__ */ jsx9(Box5, { flexDirection: "column", width: "75%", paddingRight: 1, children: filtered.slice(Math.max(0, safeSelected - 8), safeSelected + 16).map((row, vi) => {
2204
- const i = Math.max(0, safeSelected - 8) + vi;
2784
+ /* @__PURE__ */ jsxs5(Box7, { marginTop: 1, flexGrow: 1, children: [
2785
+ /* @__PURE__ */ jsx12(Box7, { flexDirection: "column", width: "75%", paddingRight: 1, children: filtered.slice(start2, end).map((row, vi) => {
2786
+ const i = start2 + vi;
2205
2787
  const active = i === safeSelected;
2206
2788
  const isDefault = cfg.defaultDict === row.entry.id;
2207
- return /* @__PURE__ */ jsxs4(Box5, { children: [
2208
- /* @__PURE__ */ jsx9(Box5, { width: 2, children: /* @__PURE__ */ jsx9(Text4, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }) }),
2209
- /* @__PURE__ */ jsx9(Box5, { width: 2, children: /* @__PURE__ */ jsx9(Text4, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }) }),
2210
- /* @__PURE__ */ jsx9(Box5, { width: 2, children: /* @__PURE__ */ jsx9(Text4, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }) }),
2211
- /* @__PURE__ */ jsx9(Box5, { flexGrow: 1, children: /* @__PURE__ */ jsx9(Text4, { bold: active, color: active ? PALETTE.text : PALETTE.muted, wrap: "wrap", children: row.entry.name }) }),
2212
- /* @__PURE__ */ jsx9(Box5, { width: 6, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) }) })
2789
+ return /* @__PURE__ */ jsxs5(Box7, { children: [
2790
+ /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }) }),
2791
+ /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }) }),
2792
+ /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }) }),
2793
+ /* @__PURE__ */ jsx12(Box7, { flexGrow: 1, children: /* @__PURE__ */ jsx12(Text6, { bold: active, color: active ? PALETTE.text : PALETTE.muted, wrap: "truncate", children: row.entry.name }) }),
2794
+ /* @__PURE__ */ jsx12(Box7, { width: 6, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) }) })
2213
2795
  ] }, row.entry.id);
2214
2796
  }) }),
2215
- /* @__PURE__ */ jsx9(Box5, { flexDirection: "column", width: "25%", paddingLeft: 1, children: current && /* @__PURE__ */ jsxs4(Fragment, { children: [
2216
- /* @__PURE__ */ jsx9(Text4, { bold: true, color: PALETTE.text, wrap: "wrap", children: current.entry.name }),
2217
- /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: current.entry.id }),
2218
- /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, wrap: "wrap", children: [
2797
+ /* @__PURE__ */ jsx12(Box7, { flexDirection: "column", width: "25%", paddingLeft: 1, children: current && /* @__PURE__ */ jsxs5(Fragment, { children: [
2798
+ /* @__PURE__ */ jsx12(Text6, { bold: true, color: PALETTE.text, wrap: "wrap", children: current.entry.name }),
2799
+ /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: current.entry.id }),
2800
+ /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text6, { color: PALETTE.muted, wrap: "wrap", children: [
2219
2801
  current.entry.language,
2220
2802
  " \xB7 ",
2221
2803
  current.entry.category
2222
2804
  ] }) }),
2223
- /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: t.dict.wordsLabel(current.entry.length) }) }),
2224
- current.entry.description && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.primary, wrap: "wrap", children: current.entry.description }) }),
2225
- current.entry.tags.length > 0 && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, wrap: "wrap", children: t.dict.tagsLabel(current.entry.tags.join(", ")) }) }),
2226
- /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: current.local ? PALETTE.accent : PALETTE.muted, children: current.local ? t.dict.local : t.dict.notLocal }) }),
2227
- cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx9(Box5, { children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.success, children: t.dict.defaultMark }) })
2805
+ /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.wordsLabel(current.entry.length) }) }),
2806
+ current.entry.description && /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.primary, wrap: "wrap", children: current.entry.description }) }),
2807
+ 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(", ")) }) }),
2808
+ /* @__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 }) }),
2809
+ cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx12(Box7, { children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.success, children: t.dict.defaultMark }) })
2228
2810
  ] }) })
2229
2811
  ] }),
2230
- pending && /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, children: [
2231
- pending.kind === "pulling" && /* @__PURE__ */ jsx9(Text4, { color: PALETTE.warning, children: t.dict.pulling(pending.id) }),
2232
- pending.kind === "removing" && /* @__PURE__ */ jsx9(Text4, { color: PALETTE.warning, children: t.dict.removing(pending.id) }),
2233
- pending.kind === "error" && /* @__PURE__ */ jsx9(Text4, { color: PALETTE.error, children: t.dict.errorOn(pending.id, pending.msg) })
2812
+ pending && /* @__PURE__ */ jsxs5(Box7, { marginTop: 1, children: [
2813
+ pending.kind === "pulling" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.warning, children: t.dict.pulling(pending.id) }),
2814
+ pending.kind === "removing" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.warning, children: t.dict.removing(pending.id) }),
2815
+ pending.kind === "refreshing" && /* @__PURE__ */ jsxs5(Text6, { color: PALETTE.warning, children: [
2816
+ t.dict.command.refreshList,
2817
+ "\u2026"
2818
+ ] }),
2819
+ pending.kind === "error" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.error, children: t.dict.errorOn(pending.id, pending.msg) })
2234
2820
  ] }),
2235
- /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text4, { color: PALETTE.muted, children: t.dict.footer }) })
2821
+ /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.footer }) })
2236
2822
  ] });
2237
2823
  }
2238
2824
 
2239
2825
  // src/ui/screens/ConfigEditor.tsx
2240
- import { useState as useState9 } from "react";
2241
- import { Box as Box6, Text as Text5, useInput as useInput5 } from "ink";
2242
- import { jsx as jsx10, jsxs as jsxs5 } from "react/jsx-runtime";
2826
+ import { useState as useState11 } from "react";
2827
+ import { Box as Box8, Text as Text7, useInput as useInput6 } from "ink";
2828
+ import { jsx as jsx13, jsxs as jsxs6 } from "react/jsx-runtime";
2243
2829
  var FIELDS = [
2244
2830
  { kind: "dictRef", path: "defaultDict", labelKey: "defaultDict" },
2245
2831
  { kind: "enum", path: "defaultMode", labelKey: "defaultMode", options: ["order", "dictation", "review", "random", "loop"] },
2246
2832
  { kind: "enum", path: "accent", labelKey: "accent", options: ["us", "uk"] },
2247
2833
  { kind: "enum", path: "language", labelKey: "language", options: ["auto", "zh", "en"] },
2248
2834
  { kind: "enum", path: "mirror", labelKey: "mirror", options: ["jsdelivr", "github"] },
2835
+ { kind: "enum", path: "stealth", labelKey: "stealth", options: ["off", "menu", "default"] },
2249
2836
  { kind: "int", path: "chapterSize", labelKey: "chapterSize", min: 1, max: 200 },
2250
2837
  { kind: "bool", path: "autoplayPronunciation", labelKey: "autoplayPronunciation" },
2251
2838
  { kind: "bool", path: "sounds.master", labelKey: "soundsMaster" },
@@ -2263,10 +2850,11 @@ function ConfigEditor() {
2263
2850
  const nav = useNav();
2264
2851
  const { cfg, setCfg } = useAppState();
2265
2852
  const t = useStrings();
2266
- const [selected, setSelected] = useState9(0);
2267
- const [editing, setEditing] = useState9(false);
2268
- const [draft, setDraft] = useState9("");
2269
- const [error, setError] = useState9(null);
2853
+ const defaultDictName = useDictName(cfg.defaultDict);
2854
+ const [selected, setSelected] = useState11(0);
2855
+ const [editing, setEditing] = useState11(false);
2856
+ const [draft, setDraft] = useState11("");
2857
+ const [error, setError] = useState11(null);
2270
2858
  const field = FIELDS[selected];
2271
2859
  const currentValue = getByPath2(cfg, field.path);
2272
2860
  const commit = async (raw) => {
@@ -2279,7 +2867,7 @@ function ConfigEditor() {
2279
2867
  setError(err.message);
2280
2868
  }
2281
2869
  };
2282
- useInput5((input, key) => {
2870
+ useInput6((input, key) => {
2283
2871
  if (editing && field.kind === "string") {
2284
2872
  if (key.escape) {
2285
2873
  setEditing(false);
@@ -2314,7 +2902,7 @@ function ConfigEditor() {
2314
2902
  if (/^[0-9]$/.test(input)) setDraft((d) => d + input);
2315
2903
  return;
2316
2904
  }
2317
- if (key.escape || input === "b") {
2905
+ if (key.escape) {
2318
2906
  nav.back();
2319
2907
  return;
2320
2908
  }
@@ -2350,35 +2938,51 @@ function ConfigEditor() {
2350
2938
  }
2351
2939
  });
2352
2940
  const labelW = Math.max(...FIELDS.map((f) => visibleWidth2(t.config.fields[f.labelKey]))) + 4;
2353
- return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2354
- /* @__PURE__ */ jsx10(Text5, { bold: true, color: PALETTE.accent, children: t.config.title }),
2355
- /* @__PURE__ */ jsx10(Box6, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
2941
+ return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2942
+ /* @__PURE__ */ jsx13(Text7, { bold: true, color: PALETTE.accent, children: t.config.title }),
2943
+ /* @__PURE__ */ jsx13(Box8, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
2356
2944
  const active = i === selected;
2357
2945
  const value = getByPath2(cfg, f.path);
2358
- const display = renderValue(f, value, active && editing ? draft : null, t);
2946
+ const display = renderValue(
2947
+ f,
2948
+ value,
2949
+ active && editing ? draft : null,
2950
+ t,
2951
+ f.path === "defaultDict" ? defaultDictName : ""
2952
+ );
2359
2953
  const label = t.config.fields[f.labelKey];
2360
2954
  const pad = " ".repeat(Math.max(0, labelW - visibleWidth2(label)));
2361
- return /* @__PURE__ */ jsxs5(Box6, { children: [
2362
- /* @__PURE__ */ jsx10(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2363
- /* @__PURE__ */ jsxs5(Text5, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
2955
+ return /* @__PURE__ */ jsxs6(Box8, { children: [
2956
+ /* @__PURE__ */ jsx13(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2957
+ /* @__PURE__ */ jsxs6(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
2364
2958
  label,
2365
2959
  pad
2366
2960
  ] }),
2367
- /* @__PURE__ */ jsx10(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
2961
+ /* @__PURE__ */ jsx13(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
2368
2962
  ] }, f.path);
2369
2963
  }) }),
2370
- error && /* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: PALETTE.error, children: [
2964
+ error && /* @__PURE__ */ jsx13(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs6(Text7, { color: PALETTE.error, children: [
2371
2965
  "! ",
2372
2966
  error
2373
2967
  ] }) }),
2374
- /* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text5, { color: PALETTE.muted, children: hintFor(field, editing, t) }) })
2968
+ /* @__PURE__ */ jsx13(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx13(Text7, { color: PALETTE.muted, children: hintFor(field, editing, t) }) })
2375
2969
  ] });
2376
2970
  }
2377
- function renderValue(field, value, draft, t) {
2971
+ function renderValue(field, value, draft, t, dictDisplayName) {
2378
2972
  if (draft !== null) return `${draft}_`;
2379
2973
  if (field.kind === "bool") return value ? `\u2713 ${t.common.on}` : `\u2717 ${t.common.off}`;
2380
- if (field.kind === "dictRef") return String(value ?? "\u2014");
2381
- if (field.kind === "enum") return `< ${value} >`;
2974
+ if (field.kind === "dictRef") {
2975
+ if (!value) return "\u2014";
2976
+ return truncateName(dictDisplayName || String(value), 24);
2977
+ }
2978
+ if (field.kind === "enum") {
2979
+ if (field.path === "stealth") {
2980
+ const v = String(value);
2981
+ const label = t.config.enumValues.stealth[v] ?? String(value);
2982
+ return `< ${label} >`;
2983
+ }
2984
+ return `< ${value} >`;
2985
+ }
2382
2986
  return String(value ?? "");
2383
2987
  }
2384
2988
  function hintFor(field, editing, t) {
@@ -2390,46 +2994,119 @@ function hintFor(field, editing, t) {
2390
2994
  }
2391
2995
 
2392
2996
  // src/ui/screens/StatsViewer.tsx
2393
- import { useEffect as useEffect7, useState as useState10 } from "react";
2394
- import { Box as Box7, Text as Text6, useInput as useInput6 } from "ink";
2395
- import { jsx as jsx11, jsxs as jsxs6 } from "react/jsx-runtime";
2997
+ import { useEffect as useEffect8, useState as useState12 } from "react";
2998
+ import { Box as Box10, Text as Text9, useInput as useInput7 } from "ink";
2999
+
3000
+ // src/ui/components/Heatmap.tsx
3001
+ import { Box as Box9, Text as Text8 } from "ink";
3002
+
3003
+ // src/util/heatmap.ts
3004
+ function bucketDays(values, days, todayWeekday) {
3005
+ if (days <= 0 || values.length !== days) {
3006
+ return Array.from({ length: 7 }, () => []);
3007
+ }
3008
+ const cols = Math.ceil((days - todayWeekday - 1) / 7) + 1;
3009
+ const grid = Array.from({ length: 7 }, () => Array(cols).fill(null));
3010
+ let col = cols - 1;
3011
+ let row = todayWeekday;
3012
+ for (let i = days - 1; i >= 0; i--) {
3013
+ grid[row][col] = values[i];
3014
+ if (row === 0) {
3015
+ row = 6;
3016
+ col -= 1;
3017
+ } else {
3018
+ row -= 1;
3019
+ }
3020
+ }
3021
+ return grid;
3022
+ }
3023
+ function intensity(value, max) {
3024
+ if (max <= 0 || value <= 0) return 0;
3025
+ const frac = value / max;
3026
+ if (frac <= 0.25) return 1;
3027
+ if (frac <= 0.5) return 2;
3028
+ if (frac <= 0.75) return 3;
3029
+ return 4;
3030
+ }
3031
+ function todayWeekdayMon0(now = /* @__PURE__ */ new Date()) {
3032
+ const d = now.getUTCDay();
3033
+ return d === 0 ? 6 : d - 1;
3034
+ }
3035
+
3036
+ // src/ui/components/Heatmap.tsx
3037
+ import { jsx as jsx14, jsxs as jsxs7 } from "react/jsx-runtime";
3038
+ var COLORS = ["#1b1f23", "#0e4429", "#006d32", "#26a641", "#39d353"];
3039
+ var ROW_LABELS = ["M", "T", "W", "T", "F", "S", "S"];
3040
+ function Heatmap({ label, values, days, suffix = "" }) {
3041
+ const todayDow = todayWeekdayMon0();
3042
+ const grid = bucketDays(values, days, todayDow);
3043
+ const max = values.length === 0 ? 0 : Math.max(...values);
3044
+ const cols = grid[0]?.length ?? 0;
3045
+ const maxLabel = max > 0 ? `max ${Math.round(max)}${suffix}` : "";
3046
+ return /* @__PURE__ */ jsxs7(Box9, { flexDirection: "column", children: [
3047
+ /* @__PURE__ */ jsxs7(Box9, { children: [
3048
+ /* @__PURE__ */ jsx14(Box9, { width: 12, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: label }) }),
3049
+ /* @__PURE__ */ jsx14(Box9, { flexGrow: 1 }),
3050
+ maxLabel && /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: maxLabel })
3051
+ ] }),
3052
+ grid.map((row, r) => /* @__PURE__ */ jsxs7(Box9, { children: [
3053
+ /* @__PURE__ */ jsx14(Box9, { width: 3, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: ROW_LABELS[r] }) }),
3054
+ row.map((cell, c) => {
3055
+ if (cell === null) {
3056
+ return /* @__PURE__ */ jsxs7(Text8, { children: [
3057
+ " ",
3058
+ c < cols - 1 ? " " : ""
3059
+ ] }, c);
3060
+ }
3061
+ const lvl = intensity(cell, max);
3062
+ const sym = lvl === 0 ? "\xB7" : "\u25A0";
3063
+ return /* @__PURE__ */ jsxs7(Text8, { color: COLORS[lvl], children: [
3064
+ sym,
3065
+ c < cols - 1 ? " " : ""
3066
+ ] }, c);
3067
+ })
3068
+ ] }, r))
3069
+ ] });
3070
+ }
3071
+
3072
+ // src/ui/screens/StatsViewer.tsx
3073
+ import { jsx as jsx15, jsxs as jsxs8 } from "react/jsx-runtime";
2396
3074
  var DAY_WINDOWS = [7, 14, 30, 90];
2397
3075
  function StatsViewer() {
2398
3076
  const nav = useNav();
2399
3077
  const t = useStrings();
2400
- const [sessions, setSessions] = useState10(null);
2401
- const [book, setBook] = useState10(null);
2402
- const [windowIdx, setWindowIdx] = useState10(1);
2403
- useEffect7(() => {
3078
+ const [sessions, setSessions] = useState12(null);
3079
+ const [book, setBook] = useState12(null);
3080
+ const [windowIdx, setWindowIdx] = useState12(1);
3081
+ useEffect8(() => {
2404
3082
  void (async () => {
2405
3083
  const [s, b] = await Promise.all([loadSessions(), loadMistakes()]);
2406
3084
  setSessions(s);
2407
3085
  setBook(b);
2408
3086
  })();
2409
3087
  }, []);
2410
- useInput6((input, key) => {
2411
- if (key.escape || input === "b" || input === "q") {
3088
+ useInput7((_input, key) => {
3089
+ if (key.escape) {
2412
3090
  nav.back();
2413
3091
  return;
2414
3092
  }
2415
- if (input === "n") setWindowIdx((i) => (i + 1) % DAY_WINDOWS.length);
2416
- if (input === "N") setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
3093
+ if (key.rightArrow) setWindowIdx((i) => (i + 1) % DAY_WINDOWS.length);
3094
+ if (key.leftArrow) setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
2417
3095
  });
2418
3096
  if (!sessions || !book) {
2419
- return /* @__PURE__ */ jsx11(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.loading }) });
3097
+ return /* @__PURE__ */ jsx15(Box10, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.stats.loading }) });
2420
3098
  }
2421
3099
  if (sessions.length === 0) {
2422
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2423
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.none }),
2424
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.nonePractice }) }),
2425
- /* @__PURE__ */ jsx11(Box7, { marginTop: 2, children: /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2426
- "[q] ",
3100
+ return /* @__PURE__ */ jsxs8(Box10, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
3101
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.stats.none }),
3102
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.stats.nonePractice }) }),
3103
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 2, children: /* @__PURE__ */ jsxs8(Text9, { color: PALETTE.muted, children: [
3104
+ "Esc ",
2427
3105
  t.common.back
2428
3106
  ] }) })
2429
3107
  ] });
2430
3108
  }
2431
3109
  const days = DAY_WINDOWS[windowIdx];
2432
- const buckets = dailyBuckets(sessions, days);
2433
3110
  const streak = dailyStreak(sessions);
2434
3111
  const totalWords = sessions.reduce((a, s) => a + s.wordCount, 0);
2435
3112
  const totalErrors = sessions.reduce((a, s) => a + s.errors, 0);
@@ -2442,98 +3119,122 @@ function StatsViewer() {
2442
3119
  const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
2443
3120
  const recent = sessions.slice(-5).reverse();
2444
3121
  const top = topN(book, 8);
2445
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2446
- /* @__PURE__ */ jsx11(Text6, { bold: true, color: PALETTE.accent, children: t.stats.title }),
2447
- /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, flexDirection: "column", children: [
2448
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.lifetime }),
2449
- /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, children: [
2450
- /* @__PURE__ */ jsx11(Stat, { label: t.stats.sessions, value: String(sessions.length) }),
2451
- /* @__PURE__ */ jsx11(Stat, { label: t.stats.words, value: String(totalWords) }),
2452
- /* @__PURE__ */ jsx11(Stat, { label: t.stats.errors, value: String(totalErrors) }),
2453
- /* @__PURE__ */ jsx11(Stat, { label: t.stats.wpm, value: String(overallWpm), accent: true }),
2454
- /* @__PURE__ */ jsx11(Stat, { label: t.stats.accuracy, value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
2455
- /* @__PURE__ */ jsx11(Stat, { label: t.stats.streak, value: `${streak}d`, accent: true })
3122
+ const wpms = dailyValues(sessions, days, "wpm");
3123
+ const accs = dailyValues(sessions, days, "accuracy");
3124
+ const ses = dailyValues(sessions, days, "sessions");
3125
+ return /* @__PURE__ */ jsxs8(Box10, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3126
+ /* @__PURE__ */ jsx15(Text9, { bold: true, color: PALETTE.accent, children: t.stats.title }),
3127
+ /* @__PURE__ */ jsxs8(Box10, { marginTop: 1, flexDirection: "column", children: [
3128
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.stats.lifetime }),
3129
+ /* @__PURE__ */ jsxs8(Box10, { marginTop: 1, children: [
3130
+ /* @__PURE__ */ jsx15(Stat, { label: t.stats.sessions, value: String(sessions.length) }),
3131
+ /* @__PURE__ */ jsx15(Stat, { label: t.stats.words, value: String(totalWords) }),
3132
+ /* @__PURE__ */ jsx15(Stat, { label: t.stats.errors, value: String(totalErrors) }),
3133
+ /* @__PURE__ */ jsx15(Stat, { label: t.stats.wpm, value: String(overallWpm), accent: true }),
3134
+ /* @__PURE__ */ jsx15(Stat, { label: t.stats.accuracy, value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
3135
+ /* @__PURE__ */ jsx15(Stat, { label: t.stats.streak, value: `${streak}d`, accent: true })
2456
3136
  ] })
2457
3137
  ] }),
2458
- /* @__PURE__ */ jsxs6(Box7, { marginTop: 2, flexDirection: "column", children: [
2459
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.last(days) }),
2460
- /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, flexDirection: "column", children: [
2461
- /* @__PURE__ */ jsxs6(Box7, { children: [
2462
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.wpm.padEnd(10) }),
2463
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.wpm)) }),
2464
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2465
- " max ",
2466
- Math.round(Math.max(...buckets.map((b) => b.wpm)))
2467
- ] })
2468
- ] }),
2469
- /* @__PURE__ */ jsxs6(Box7, { children: [
2470
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.accuracy.padEnd(10) }),
2471
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.accuracy * 100)) })
2472
- ] }),
2473
- /* @__PURE__ */ jsxs6(Box7, { children: [
2474
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.sessions.padEnd(10) }),
2475
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.sessions)) })
2476
- ] })
3138
+ /* @__PURE__ */ jsxs8(Box10, { marginTop: 2, flexDirection: "column", children: [
3139
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.stats.last(days) }),
3140
+ /* @__PURE__ */ jsxs8(Box10, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: PALETTE.muted, paddingX: 1, paddingY: 0, children: [
3141
+ /* @__PURE__ */ jsx15(Heatmap, { label: t.stats.wpm, values: wpms, days }),
3142
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Heatmap, { label: t.stats.accuracy, values: accs, days, suffix: "%" }) }),
3143
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Heatmap, { label: t.stats.sessions, values: ses, days }) })
2477
3144
  ] })
2478
3145
  ] }),
2479
- /* @__PURE__ */ jsxs6(Box7, { marginTop: 2, flexDirection: "column", children: [
2480
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.recent }),
2481
- recent.map((s, i) => /* @__PURE__ */ jsxs6(Box7, { children: [
2482
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2483
- " ",
2484
- s.ts.replace("T", " ").slice(0, 16),
2485
- " "
2486
- ] }),
2487
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
2488
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2489
- " ",
2490
- "ch",
2491
- String(s.chapter + 1).padStart(3),
2492
- " ",
2493
- s.mode.padEnd(9),
2494
- " ",
2495
- String(s.wordCount).padStart(3),
2496
- "w ",
2497
- s.errors,
2498
- "err ",
2499
- computeWPM(s),
2500
- "wpm ",
2501
- Math.round(accuracy(s) * 1e3) / 10,
2502
- "%"
2503
- ] })
2504
- ] }, i))
3146
+ /* @__PURE__ */ jsxs8(Box10, { marginTop: 2, flexDirection: "column", children: [
3147
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.stats.recent }),
3148
+ recent.map((s, i) => /* @__PURE__ */ jsx15(RecentRow, { session: s, units: t.stats.recentUnits }, i))
2505
3149
  ] }),
2506
- top.length > 0 && /* @__PURE__ */ jsxs6(Box7, { marginTop: 2, flexDirection: "column", children: [
2507
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.topMistakes }),
2508
- top.map(([word, entry]) => /* @__PURE__ */ jsxs6(Box7, { children: [
2509
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.error, children: [
2510
- " ",
2511
- String(entry.count).padStart(3),
2512
- " "
2513
- ] }),
2514
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.text, children: word.padEnd(20) }),
2515
- /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: entry.dictIds.join(", ") })
2516
- ] }, word))
3150
+ top.length > 0 && /* @__PURE__ */ jsxs8(Box10, { marginTop: 2, flexDirection: "column", children: [
3151
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.stats.topMistakes }),
3152
+ top.map(([word, entry]) => /* @__PURE__ */ jsx15(
3153
+ MistakeRow,
3154
+ {
3155
+ word,
3156
+ count: entry.count,
3157
+ dictIds: entry.dictIds,
3158
+ multiSuffix: t.stats.multiDictSuffix
3159
+ },
3160
+ word
3161
+ ))
2517
3162
  ] }),
2518
- /* @__PURE__ */ jsx11(Box7, { flexGrow: 1 }),
2519
- /* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text6, { color: PALETTE.muted, children: t.stats.footer }) })
3163
+ /* @__PURE__ */ jsx15(Box10, { flexGrow: 1 }),
3164
+ /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.stats.footer }) })
3165
+ ] });
3166
+ }
3167
+ function RecentRow({
3168
+ session,
3169
+ units
3170
+ }) {
3171
+ const name = useDictName(session.dictId);
3172
+ const display = truncateName(name, 14);
3173
+ return /* @__PURE__ */ jsxs8(Box10, { children: [
3174
+ /* @__PURE__ */ jsxs8(Text9, { color: PALETTE.muted, children: [
3175
+ " ",
3176
+ session.ts.replace("T", " ").slice(0, 16),
3177
+ " "
3178
+ ] }),
3179
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.text, children: display.padEnd(14) }),
3180
+ /* @__PURE__ */ jsxs8(Text9, { color: PALETTE.muted, children: [
3181
+ " ",
3182
+ "ch",
3183
+ String(session.chapter + 1).padStart(3),
3184
+ " ",
3185
+ session.mode.padEnd(9),
3186
+ " ",
3187
+ String(session.wordCount).padStart(3),
3188
+ units.words,
3189
+ " ",
3190
+ session.errors,
3191
+ units.errors,
3192
+ " ",
3193
+ computeWPM(session),
3194
+ units.wpm,
3195
+ " ",
3196
+ Math.round(accuracy(session) * 1e3) / 10,
3197
+ "%"
3198
+ ] })
3199
+ ] });
3200
+ }
3201
+ function MistakeRow({
3202
+ word,
3203
+ count,
3204
+ dictIds,
3205
+ multiSuffix
3206
+ }) {
3207
+ const firstId = dictIds[0] ?? "";
3208
+ const firstName = useDictName(firstId);
3209
+ const suffix = dictIds.length > 1 ? multiSuffix(dictIds.length - 1) : "";
3210
+ return /* @__PURE__ */ jsxs8(Box10, { children: [
3211
+ /* @__PURE__ */ jsxs8(Text9, { color: PALETTE.error, children: [
3212
+ " ",
3213
+ String(count).padStart(3),
3214
+ " "
3215
+ ] }),
3216
+ /* @__PURE__ */ jsx15(Text9, { color: PALETTE.text, children: word.padEnd(20) }),
3217
+ /* @__PURE__ */ jsxs8(Text9, { color: PALETTE.muted, children: [
3218
+ truncateName(firstName, 20),
3219
+ suffix
3220
+ ] })
2520
3221
  ] });
2521
3222
  }
2522
3223
  function Stat({ label, value, accent = false }) {
2523
- return /* @__PURE__ */ jsxs6(Box7, { marginRight: 3, children: [
2524
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
3224
+ return /* @__PURE__ */ jsxs8(Box10, { marginRight: 3, children: [
3225
+ /* @__PURE__ */ jsxs8(Text9, { color: PALETTE.muted, children: [
2525
3226
  label,
2526
3227
  " "
2527
3228
  ] }),
2528
- /* @__PURE__ */ jsx11(Text6, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
3229
+ /* @__PURE__ */ jsx15(Text9, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
2529
3230
  ] });
2530
3231
  }
2531
3232
 
2532
3233
  // src/ui/screens/WordLookup.tsx
2533
- import { useEffect as useEffect8, useState as useState11 } from "react";
2534
- import { Box as Box8, Text as Text7, useInput as useInput7 } from "ink";
3234
+ import { useEffect as useEffect9, useState as useState13 } from "react";
3235
+ import { Box as Box11, Text as Text10, useInput as useInput8 } from "ink";
2535
3236
  import { readdir } from "fs/promises";
2536
- import { Fragment as Fragment2, jsx as jsx12, jsxs as jsxs7 } from "react/jsx-runtime";
3237
+ import { Fragment as Fragment2, jsx as jsx16, jsxs as jsxs9 } from "react/jsx-runtime";
2537
3238
  async function listLocalDictIds() {
2538
3239
  try {
2539
3240
  const files = await readdir(paths.dictsDir);
@@ -2545,12 +3246,12 @@ async function listLocalDictIds() {
2545
3246
  function WordLookup() {
2546
3247
  const nav = useNav();
2547
3248
  const t = useStrings();
2548
- const [query, setQuery] = useState11("");
2549
- const [allWords, setAllWords] = useState11([]);
2550
- const [book, setBook] = useState11({});
2551
- const [loading, setLoading] = useState11(true);
2552
- const [selected, setSelected] = useState11(0);
2553
- useEffect8(() => {
3249
+ const [query, setQuery] = useState13("");
3250
+ const [allWords, setAllWords] = useState13([]);
3251
+ const [book, setBook] = useState13({});
3252
+ const [loading, setLoading] = useState13(true);
3253
+ const [selected, setSelected] = useState13(0);
3254
+ useEffect9(() => {
2554
3255
  void (async () => {
2555
3256
  const ids = await listLocalDictIds();
2556
3257
  const collected = [];
@@ -2566,7 +3267,7 @@ function WordLookup() {
2566
3267
  }, []);
2567
3268
  const q = query.toLowerCase().trim();
2568
3269
  const filtered = q ? allWords.filter((h) => h.word.name.toLowerCase().includes(q)).slice(0, 50) : [];
2569
- useInput7((input, key) => {
3270
+ useInput8((input, key) => {
2570
3271
  if (key.escape) {
2571
3272
  nav.back();
2572
3273
  return;
@@ -2590,111 +3291,165 @@ function WordLookup() {
2590
3291
  }
2591
3292
  });
2592
3293
  if (loading) {
2593
- return /* @__PURE__ */ jsx12(Box8, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.indexing }) });
3294
+ return /* @__PURE__ */ jsx16(Box11, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.word.indexing }) });
2594
3295
  }
2595
3296
  if (allWords.length === 0) {
2596
- return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2597
- /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.none }),
2598
- /* @__PURE__ */ jsx12(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.pullFirst }) }),
2599
- /* @__PURE__ */ jsx12(Box8, { marginTop: 2, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
3297
+ return /* @__PURE__ */ jsxs9(Box11, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
3298
+ /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.word.none }),
3299
+ /* @__PURE__ */ jsx16(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.word.pullFirst }) }),
3300
+ /* @__PURE__ */ jsx16(Box11, { marginTop: 2, children: /* @__PURE__ */ jsxs9(Text10, { color: PALETTE.muted, children: [
2600
3301
  "[Esc] ",
2601
3302
  t.common.back
2602
3303
  ] }) })
2603
3304
  ] });
2604
3305
  }
2605
3306
  const current = filtered[selected];
2606
- return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2607
- /* @__PURE__ */ jsxs7(Box8, { children: [
2608
- /* @__PURE__ */ jsx12(Text7, { bold: true, color: PALETTE.accent, children: t.word.title }),
2609
- /* @__PURE__ */ jsx12(Box8, { flexGrow: 1 }),
2610
- /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.countAcross(allWords.length) })
3307
+ return /* @__PURE__ */ jsxs9(Box11, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3308
+ /* @__PURE__ */ jsxs9(Box11, { children: [
3309
+ /* @__PURE__ */ jsx16(Text10, { bold: true, color: PALETTE.accent, children: t.word.title }),
3310
+ /* @__PURE__ */ jsx16(Box11, { flexGrow: 1 }),
3311
+ /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.word.countAcross(allWords.length) })
2611
3312
  ] }),
2612
- /* @__PURE__ */ jsxs7(Box8, { marginTop: 1, children: [
2613
- /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: "> " }),
2614
- /* @__PURE__ */ jsx12(Text7, { color: PALETTE.text, children: query }),
2615
- /* @__PURE__ */ jsx12(Text7, { color: PALETTE.accent, children: "_" })
3313
+ /* @__PURE__ */ jsxs9(Box11, { marginTop: 1, children: [
3314
+ /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: "> " }),
3315
+ /* @__PURE__ */ jsx16(Text10, { color: PALETTE.text, children: query }),
3316
+ /* @__PURE__ */ jsx16(Text10, { color: PALETTE.accent, children: "_" })
2616
3317
  ] }),
2617
- /* @__PURE__ */ jsxs7(Box8, { marginTop: 1, flexGrow: 1, children: [
2618
- /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", width: "40%", children: [
2619
- filtered.map((h, i) => {
2620
- const active = i === selected;
2621
- return /* @__PURE__ */ jsxs7(Box8, { children: [
2622
- /* @__PURE__ */ jsx12(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2623
- /* @__PURE__ */ jsx12(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: h.word.name.padEnd(20) }),
2624
- /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: h.dictId })
2625
- ] }, `${h.dictId}-${h.word.name}-${i}`);
2626
- }),
2627
- filtered.length === 0 && q && /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.noMatches(query) })
3318
+ /* @__PURE__ */ jsxs9(Box11, { marginTop: 1, flexGrow: 1, children: [
3319
+ /* @__PURE__ */ jsxs9(Box11, { flexDirection: "column", width: "40%", children: [
3320
+ filtered.map((h, i) => /* @__PURE__ */ jsx16(HitRow, { hit: h, active: i === selected }, `${h.dictId}-${h.word.name}-${i}`)),
3321
+ filtered.length === 0 && q && /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.word.noMatches(query) })
2628
3322
  ] }),
2629
- /* @__PURE__ */ jsx12(Box8, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs7(Fragment2, { children: [
2630
- /* @__PURE__ */ jsx12(Text7, { bold: true, color: PALETTE.text, children: current.word.name }),
2631
- /* @__PURE__ */ jsxs7(Box8, { marginTop: 1, children: [
2632
- current.word.usphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2633
- "US /",
2634
- current.word.usphone,
2635
- "/ "
2636
- ] }),
2637
- current.word.ukphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
2638
- "UK /",
2639
- current.word.ukphone,
2640
- "/"
2641
- ] })
2642
- ] }),
2643
- /* @__PURE__ */ jsx12(Box8, { marginTop: 1, flexDirection: "column", children: (current.word.trans ?? []).map((tr, i) => /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.primary, children: [
2644
- "\xB7 ",
2645
- tr
2646
- ] }, i)) }),
2647
- /* @__PURE__ */ jsx12(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.inDict(current.dictId) }) }),
2648
- book[current.word.name] && /* @__PURE__ */ jsx12(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.error, children: t.word.mistakes(book[current.word.name].count, book[current.word.name].lastSeen.slice(0, 10)) }) })
2649
- ] }) })
3323
+ /* @__PURE__ */ jsx16(Box11, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsx16(Detail, { hit: current, book }) })
3324
+ ] }),
3325
+ /* @__PURE__ */ jsx16(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.word.footer }) })
3326
+ ] });
3327
+ }
3328
+ function HitRow({ hit, active }) {
3329
+ const name = useDictName(hit.dictId);
3330
+ return /* @__PURE__ */ jsxs9(Box11, { children: [
3331
+ /* @__PURE__ */ jsx16(Text10, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
3332
+ /* @__PURE__ */ jsx16(Text10, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: hit.word.name.padEnd(20) }),
3333
+ /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: truncateName(name, 18) })
3334
+ ] });
3335
+ }
3336
+ function Detail({ hit, book }) {
3337
+ const t = useStrings();
3338
+ const name = useDictName(hit.dictId);
3339
+ return /* @__PURE__ */ jsxs9(Fragment2, { children: [
3340
+ /* @__PURE__ */ jsx16(Text10, { bold: true, color: PALETTE.text, children: hit.word.name }),
3341
+ /* @__PURE__ */ jsxs9(Box11, { marginTop: 1, children: [
3342
+ hit.word.usphone && /* @__PURE__ */ jsxs9(Text10, { color: PALETTE.muted, children: [
3343
+ "US /",
3344
+ hit.word.usphone,
3345
+ "/ "
3346
+ ] }),
3347
+ hit.word.ukphone && /* @__PURE__ */ jsxs9(Text10, { color: PALETTE.muted, children: [
3348
+ "UK /",
3349
+ hit.word.ukphone,
3350
+ "/"
3351
+ ] })
2650
3352
  ] }),
2651
- /* @__PURE__ */ jsx12(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text7, { color: PALETTE.muted, children: t.word.footer }) })
3353
+ /* @__PURE__ */ jsx16(Box11, { marginTop: 1, flexDirection: "column", children: (hit.word.trans ?? []).map((tr, i) => /* @__PURE__ */ jsxs9(Text10, { color: PALETTE.primary, children: [
3354
+ "\xB7 ",
3355
+ tr
3356
+ ] }, i)) }),
3357
+ /* @__PURE__ */ jsx16(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.word.inDict(truncateName(name, 22)) }) }),
3358
+ book[hit.word.name] && /* @__PURE__ */ jsx16(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text10, { color: PALETTE.error, children: t.word.mistakes(book[hit.word.name].count, book[hit.word.name].lastSeen.slice(0, 10)) }) })
3359
+ ] });
3360
+ }
3361
+
3362
+ // src/ui/screens/HelpScreen.tsx
3363
+ import { Box as Box12, Text as Text11, useInput as useInput9 } from "ink";
3364
+ import { jsx as jsx17, jsxs as jsxs10 } from "react/jsx-runtime";
3365
+ function HelpScreen() {
3366
+ const nav = useNav();
3367
+ const t = useStrings();
3368
+ useInput9((_input, key) => {
3369
+ if (key.escape) nav.back();
3370
+ });
3371
+ const k = t.help.keys;
3372
+ const sections = [
3373
+ { title: t.help.sections.global, keys: [k.helpScreen, k.quit] },
3374
+ { title: t.help.sections.main, keys: [k.navigate, k.select, k.letterJump, k.helpScreen] },
3375
+ {
3376
+ title: t.help.sections.practice,
3377
+ keys: [k.pause, k.skip, k.replay, k.resume, k.nextChapter, k.reviewMistakes, k.stealthToggle, k.backMenu]
3378
+ },
3379
+ {
3380
+ title: t.help.sections.dict,
3381
+ keys: [k.navigate, k.filter, k.itemActions, k.moreActions, k.backScreen]
3382
+ },
3383
+ { title: t.help.sections.config, keys: [k.navigate, k.select, k.backMenu] },
3384
+ { title: t.help.sections.stats, keys: [k.cycleWindow, k.backMenu] },
3385
+ { title: t.help.sections.word, keys: [k.filter, k.navigate, k.backMenu] }
3386
+ ];
3387
+ return /* @__PURE__ */ jsxs10(Box12, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3388
+ /* @__PURE__ */ jsxs10(Box12, { children: [
3389
+ /* @__PURE__ */ jsx17(Text11, { bold: true, color: PALETTE.accent, children: t.help.title }),
3390
+ /* @__PURE__ */ jsx17(Text11, { color: PALETTE.muted, children: " \xB7 " }),
3391
+ /* @__PURE__ */ jsx17(Text11, { color: PALETTE.muted, children: t.help.subtitle })
3392
+ ] }),
3393
+ /* @__PURE__ */ jsx17(Box12, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: sections.map((sec) => /* @__PURE__ */ jsxs10(Box12, { flexDirection: "column", marginTop: 1, children: [
3394
+ /* @__PURE__ */ jsx17(Text11, { bold: true, color: PALETTE.text, children: sec.title }),
3395
+ sec.keys.map((line, i) => /* @__PURE__ */ jsx17(Box12, { children: /* @__PURE__ */ jsxs10(Text11, { color: PALETTE.muted, children: [
3396
+ " \xB7 ",
3397
+ line
3398
+ ] }) }, i))
3399
+ ] }, sec.title)) }),
3400
+ /* @__PURE__ */ jsx17(Box12, { marginTop: 1, children: /* @__PURE__ */ jsx17(Text11, { color: PALETTE.muted, children: t.help.footer }) })
2652
3401
  ] });
2653
3402
  }
2654
3403
 
2655
3404
  // src/ui/App.tsx
2656
- import { jsx as jsx13 } from "react/jsx-runtime";
2657
- function App({ initial, initialCfg }) {
2658
- return /* @__PURE__ */ jsx13(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx13(LangBridge, { children: /* @__PURE__ */ jsx13(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx13(NavProvider, { initial, children: /* @__PURE__ */ jsx13(Fullscreen, { children: /* @__PURE__ */ jsx13(Router, {}) }) }) }) }) });
3405
+ import { jsx as jsx18 } from "react/jsx-runtime";
3406
+ function App({
3407
+ initial,
3408
+ initialCfg,
3409
+ inline = false
3410
+ }) {
3411
+ return /* @__PURE__ */ jsx18(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx18(LangBridge, { children: /* @__PURE__ */ jsx18(RegistryProvider, { children: /* @__PURE__ */ jsx18(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx18(NavProvider, { initial, children: inline ? /* @__PURE__ */ jsx18(Router, { inline: true }) : /* @__PURE__ */ jsx18(Fullscreen, { children: /* @__PURE__ */ jsx18(Router, {}) }) }) }) }) }) });
2659
3412
  }
2660
3413
  function LangBridge({ children }) {
2661
3414
  const { cfg } = useAppState();
2662
- return /* @__PURE__ */ jsx13(StringsProvider, { pref: cfg.language, children });
3415
+ return /* @__PURE__ */ jsx18(StringsProvider, { pref: cfg.language, children });
2663
3416
  }
2664
3417
  function screenKey(frame) {
2665
3418
  if (frame.name === "practice") {
2666
3419
  const p = frame.params;
2667
- return `practice:${p.dictId}:${p.chapterIndex}:${p.mode}`;
3420
+ return `practice:${p.dictId}:${p.chapterIndex}:${p.mode}:${p.stealth ? "s" : "n"}`;
2668
3421
  }
2669
3422
  return frame.name;
2670
3423
  }
2671
- function Router() {
3424
+ function Router({ inline = false }) {
2672
3425
  const nav = useNav();
2673
3426
  const { cfg } = useAppState();
2674
3427
  const { exit } = useApp4();
2675
3428
  const lastKeyRef = useRef4(null);
2676
- useInput8((input, key2) => {
3429
+ useInput10((input, key2) => {
2677
3430
  if (key2.ctrl && input === "c") exit();
2678
3431
  });
2679
3432
  const frame = nav.current;
2680
3433
  const key = screenKey(frame);
2681
3434
  if (lastKeyRef.current !== key) {
2682
- if (process.stdout.isTTY) process.stdout.write("\x1B[2J\x1B[H");
3435
+ if (!inline && process.stdout.isTTY) process.stdout.write("\x1B[2J\x1B[H");
2683
3436
  lastKeyRef.current = key;
2684
3437
  }
2685
3438
  switch (frame.name) {
2686
3439
  case "main":
2687
- return /* @__PURE__ */ jsx13(MainMenu, { cfg });
3440
+ return /* @__PURE__ */ jsx18(MainMenu, { cfg });
2688
3441
  case "practice":
2689
- return /* @__PURE__ */ jsx13(PracticeScreen, { params: frame.params });
3442
+ return /* @__PURE__ */ jsx18(PracticeScreen, { params: frame.params });
2690
3443
  case "dict":
2691
- return /* @__PURE__ */ jsx13(DictBrowser, { params: frame.params });
3444
+ return /* @__PURE__ */ jsx18(DictBrowser, { params: frame.params });
2692
3445
  case "config":
2693
- return /* @__PURE__ */ jsx13(ConfigEditor, {});
3446
+ return /* @__PURE__ */ jsx18(ConfigEditor, {});
2694
3447
  case "stats":
2695
- return /* @__PURE__ */ jsx13(StatsViewer, {});
3448
+ return /* @__PURE__ */ jsx18(StatsViewer, {});
2696
3449
  case "word":
2697
- return /* @__PURE__ */ jsx13(WordLookup, {});
3450
+ return /* @__PURE__ */ jsx18(WordLookup, {});
3451
+ case "help":
3452
+ return /* @__PURE__ */ jsx18(HelpScreen, {});
2698
3453
  }
2699
3454
  }
2700
3455
 
@@ -2716,7 +3471,19 @@ function fmtDuration(ms, lang) {
2716
3471
  return `${m}m ${s}s`;
2717
3472
  }
2718
3473
  function printSessionReport(r, t, lang) {
2719
- if (r.chaptersCompleted === 0) return;
3474
+ if (r.startedAt === null && r.chaptersCompleted === 0) return;
3475
+ if (r.chaptersCompleted === 0) {
3476
+ console.log();
3477
+ console.log(chalk3.bold.cyan(t.report.title));
3478
+ const labelW2 = Math.max(visibleWidth2(t.report.duration), visibleWidth2(t.report.notPracticed)) + 2;
3479
+ const pad2 = (label) => label + " ".repeat(Math.max(0, labelW2 - visibleWidth2(label)));
3480
+ console.log(` ${chalk3.dim(pad2(t.report.duration))} ${fmtDuration(r.totalDurationMs, lang)}`);
3481
+ console.log(` ${chalk3.dim(t.report.notPracticed)}`);
3482
+ console.log();
3483
+ console.log(chalk3.dim(` ${t.report.farewell}`));
3484
+ console.log();
3485
+ return;
3486
+ }
2720
3487
  const accPct = Math.round(r.accuracy * 1e3) / 10;
2721
3488
  const labels = [
2722
3489
  t.report.duration,
@@ -2770,23 +3537,31 @@ async function runPractice(dictIdArg, options) {
2770
3537
  return;
2771
3538
  }
2772
3539
  const chapterIndex = Math.max(0, Number(options.chapter ?? 1) - 1);
3540
+ const stealth = options.stealth === true || cfg.stealth === "default";
2773
3541
  start();
2774
3542
  const { waitUntilExit } = render(
2775
3543
  createElement(App, {
2776
- initial: { name: "practice", params: { dictId, chapterIndex, mode } },
2777
- initialCfg: cfg
3544
+ initial: { name: "practice", params: { dictId, chapterIndex, mode, stealth } },
3545
+ initialCfg: cfg,
3546
+ inline: stealth
2778
3547
  }),
2779
3548
  { patchConsole: false, exitOnCtrlC: false }
2780
3549
  );
2781
3550
  await waitUntilExit();
2782
- ensureMainScreen();
3551
+ if (stealth && process.stdout.isTTY) {
3552
+ process.stdout.write("\x1B[3F\x1B[0J");
3553
+ } else {
3554
+ ensureMainScreen();
3555
+ }
2783
3556
  const { lang, t } = pickStrings(cfg.language);
2784
3557
  printSessionReport(report(), t, lang);
2785
3558
  }
2786
3559
  function buildPracticeCommand() {
2787
- return new Command3("practice").argument("[dictId]", "dictionary id; falls back to config.defaultDict").description("Start a typing practice session").option("-c, --chapter <n>", "chapter number (1-based)", "1").option("-m, --mode <mode>", "order | dictation | review | random | loop").action(async (dictIdArg, options) => {
2788
- await runPractice(dictIdArg, options);
2789
- });
3560
+ 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(
3561
+ async (dictIdArg, options) => {
3562
+ await runPractice(dictIdArg, options);
3563
+ }
3564
+ );
2790
3565
  }
2791
3566
 
2792
3567
  // src/commands/stats.ts
@@ -2910,6 +3685,16 @@ function buildWordCommand() {
2910
3685
  });
2911
3686
  }
2912
3687
 
3688
+ // src/commands/stealth.ts
3689
+ import { Command as Command6 } from "commander";
3690
+ function buildStealthCommand() {
3691
+ 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(
3692
+ async (dictIdArg, options) => {
3693
+ await runPractice(dictIdArg, { ...options, stealth: true });
3694
+ }
3695
+ );
3696
+ }
3697
+
2913
3698
  // src/commands/menu.ts
2914
3699
  import { render as render2 } from "ink";
2915
3700
  import { createElement as createElement2 } from "react";
@@ -2931,9 +3716,10 @@ async function runMainMenu() {
2931
3716
  }
2932
3717
 
2933
3718
  // src/cli.ts
2934
- var program = new Command6();
3719
+ var program = new Command7();
2935
3720
  program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version(package_default.version);
2936
3721
  program.addCommand(buildPracticeCommand());
3722
+ program.addCommand(buildStealthCommand());
2937
3723
  program.addCommand(buildDictCommand());
2938
3724
  program.addCommand(buildWordCommand());
2939
3725
  program.addCommand(buildStatsCommand());