@jiy/quizme 0.1.0

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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/cli/config.js +37 -0
  4. package/dist/cli/index.js +64 -0
  5. package/dist/cli/session.js +10 -0
  6. package/dist/generation/dedupe.js +11 -0
  7. package/dist/generation/schema.js +50 -0
  8. package/dist/generation/validator.js +134 -0
  9. package/dist/platform/fs.js +17 -0
  10. package/dist/platform/paths.js +27 -0
  11. package/dist/providers/claudeAgent.js +295 -0
  12. package/dist/sources/claudeSession.js +112 -0
  13. package/dist/sources/repository.js +43 -0
  14. package/dist/sources/topic.js +7 -0
  15. package/dist/storage/index.js +18 -0
  16. package/dist/storage/sqlite.js +294 -0
  17. package/dist/types.js +1 -0
  18. package/dist/ui/App.js +90 -0
  19. package/dist/ui/components/AppHeader.js +6 -0
  20. package/dist/ui/components/Clawd.js +37 -0
  21. package/dist/ui/components/Divider.js +6 -0
  22. package/dist/ui/components/Feed.js +32 -0
  23. package/dist/ui/components/FeedColumn.js +9 -0
  24. package/dist/ui/components/SelectList.js +13 -0
  25. package/dist/ui/components/StatusBar.js +9 -0
  26. package/dist/ui/components/TextInput.js +23 -0
  27. package/dist/ui/components/WelcomeBanner.js +79 -0
  28. package/dist/ui/formatters.js +66 -0
  29. package/dist/ui/logoLayout.js +30 -0
  30. package/dist/ui/renderApp.js +25 -0
  31. package/dist/ui/screens/HomeScreen.js +57 -0
  32. package/dist/ui/screens/InfoScreen.js +16 -0
  33. package/dist/ui/screens/QuizScreen.js +344 -0
  34. package/dist/ui/screens/SettingsScreen.js +133 -0
  35. package/dist/ui/screens/SetupScreen.js +73 -0
  36. package/dist/ui/sound.js +101 -0
  37. package/dist/ui/terminal.js +37 -0
  38. package/dist/ui/textUtils.js +54 -0
  39. package/dist/ui/theme.js +28 -0
  40. package/dist/version.js +3 -0
  41. package/package.json +55 -0
@@ -0,0 +1,66 @@
1
+ export function formatSourceMode(source, isZh) {
2
+ switch (source.sourceType) {
3
+ case "claude_session":
4
+ return isZh ? "Claude Code 记录" : "Claude Code session";
5
+ case "repo":
6
+ return isZh ? "代码仓库" : "Code repository";
7
+ case "topic":
8
+ return isZh ? "用户指定提示词" : "User prompt";
9
+ default:
10
+ return isZh ? "手动" : "Manual";
11
+ }
12
+ }
13
+ export function formatStats(store) {
14
+ const stats = store.getStats();
15
+ const week = renderWeek(stats.weekRows);
16
+ return [
17
+ "QuizMe Stats",
18
+ `Streak: ${stats.currentStreak} days`,
19
+ `Best streak: ${stats.longestStreak} days`,
20
+ `Today: ${stats.todayCount} questions`,
21
+ `All-time: ${stats.attemptsTotal} questions`,
22
+ `Accuracy: ${(stats.accuracy * 100).toFixed(0)}%`,
23
+ `Review queue: ${stats.reviewPending}`,
24
+ `Why threads: ${stats.whyCount}`,
25
+ `XP: ${stats.xp}`,
26
+ `Level: ${stats.level}`,
27
+ "",
28
+ "Last 7 days:",
29
+ week
30
+ ];
31
+ }
32
+ export function formatProfile(store) {
33
+ const signals = store.getProfileSignals();
34
+ const strong = signals.slice(0, 3).map(formatSignal).join(", ") || "Still learning your profile";
35
+ const weakSignals = [...signals]
36
+ .filter((item) => item.wrongCount > 0)
37
+ .sort((a, b) => a.score - b.score || b.wrongCount - a.wrongCount)
38
+ .slice(0, 3);
39
+ const weak = weakSignals.map(formatSignal).join(", ") || "Not enough data";
40
+ const profileRead = buildProfileRead(signals);
41
+ return [
42
+ "QuizMe Profile",
43
+ profileRead,
44
+ `Strong: ${strong}`,
45
+ `Needs review: ${weak}`
46
+ ];
47
+ }
48
+ function formatSignal(item) {
49
+ return `${item.tag} (${Math.round(item.score * 100)}%, ${item.trend})`;
50
+ }
51
+ function buildProfileRead(signals) {
52
+ if (!signals.length) {
53
+ return "Current read: still learning your profile.";
54
+ }
55
+ const strongest = signals[0];
56
+ const weakest = [...signals].sort((a, b) => a.score - b.score)[0];
57
+ return `Current read: stronger on ${strongest.tag}, weaker on ${weakest.tag}.`;
58
+ }
59
+ function renderWeek(weekRows) {
60
+ if (!weekRows.length) {
61
+ return "No activity yet.";
62
+ }
63
+ return weekRows
64
+ .map(([day, count]) => `${day} ${"#".repeat(Number(count))} ${count}`)
65
+ .join("\n");
66
+ }
@@ -0,0 +1,30 @@
1
+ const MAX_LEFT_WIDTH = 50;
2
+ const BORDER_PADDING = 4;
3
+ const DIVIDER_WIDTH = 1;
4
+ const CONTENT_PADDING = 2;
5
+ export function getLayoutMode(columns) {
6
+ return columns >= 70 ? "horizontal" : "compact";
7
+ }
8
+ export function calculateLayoutDimensions(columns, layoutMode, optimalLeftWidth) {
9
+ if (layoutMode === "horizontal") {
10
+ const leftWidth = optimalLeftWidth;
11
+ const usedSpace = BORDER_PADDING + CONTENT_PADDING + DIVIDER_WIDTH + leftWidth;
12
+ const availableForRight = columns - usedSpace;
13
+ let rightWidth = Math.max(30, availableForRight);
14
+ const totalWidth = Math.min(leftWidth + rightWidth + DIVIDER_WIDTH + CONTENT_PADDING, columns - BORDER_PADDING);
15
+ if (totalWidth < leftWidth + rightWidth + DIVIDER_WIDTH + CONTENT_PADDING) {
16
+ rightWidth = totalWidth - leftWidth - DIVIDER_WIDTH - CONTENT_PADDING;
17
+ }
18
+ return { leftWidth, rightWidth, totalWidth };
19
+ }
20
+ const totalWidth = Math.min(columns - BORDER_PADDING, MAX_LEFT_WIDTH + 20);
21
+ return {
22
+ leftWidth: totalWidth,
23
+ rightWidth: totalWidth,
24
+ totalWidth
25
+ };
26
+ }
27
+ export function calculateOptimalLeftWidth(...lines) {
28
+ const contentWidth = Math.max(...lines.map((line) => line.length), 20);
29
+ return Math.min(contentWidth + 4, MAX_LEFT_WIDTH);
30
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { App } from "./App.js";
4
+ import { QuizScreen } from "./screens/QuizScreen.js";
5
+ import { SetupScreen } from "./screens/SetupScreen.js";
6
+ import { createSoundPlayer } from "./sound.js";
7
+ export async function runInkHome({ store, config, resolveSource }) {
8
+ const rendered = render(_jsx(App, { store: store, initialConfig: config, resolveSource: resolveSource, onExit: () => rendered.unmount() }));
9
+ await rendered.waitUntilExit();
10
+ }
11
+ export async function runInkQuiz(props) {
12
+ const sound = createSoundPlayer(props.config);
13
+ const rendered = render(_jsx(QuizScreen, { ...props, sound: sound, onDone: () => rendered.unmount() }));
14
+ await rendered.waitUntilExit();
15
+ }
16
+ export async function runInkSetup({ onComplete }) {
17
+ return new Promise((resolve) => {
18
+ const { unmount, waitUntilExit } = render(_jsx(SetupScreen, { onComplete: (config) => {
19
+ onComplete(config);
20
+ unmount();
21
+ resolve(config);
22
+ } }));
23
+ waitUntilExit().catch(() => { });
24
+ });
25
+ }
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { Box, useInput } from "ink";
4
+ import { SelectList } from "../components/SelectList.js";
5
+ import { StatusBar } from "../components/StatusBar.js";
6
+ import { WelcomeBanner } from "../components/WelcomeBanner.js";
7
+ import { hintLine } from "../theme.js";
8
+ export function HomeScreen({ stats, config, source, sound, onAction }) {
9
+ const isZh = config.language === "zh-CN";
10
+ const [selectedIndex, setSelectedIndex] = useState(0);
11
+ const soundRef = useRef(sound);
12
+ soundRef.current = sound;
13
+ const items = isZh
14
+ ? [
15
+ { id: "quiz", label: "开始答题" },
16
+ { id: "review", label: "复习错题" },
17
+ { id: "stats", label: "查看统计" },
18
+ { id: "profile", label: "查看档案" },
19
+ { id: "settings", label: "设置" },
20
+ { id: "exit", label: "退出" }
21
+ ]
22
+ : [
23
+ { id: "quiz", label: "Start quiz" },
24
+ { id: "review", label: "Review mistakes" },
25
+ { id: "stats", label: "View stats" },
26
+ { id: "profile", label: "View profile" },
27
+ { id: "settings", label: "Settings" },
28
+ { id: "exit", label: "Exit" }
29
+ ];
30
+ useInput((input, key) => {
31
+ if (key.upArrow) {
32
+ setSelectedIndex((i) => Math.max(0, i - 1));
33
+ soundRef.current.playNavigate();
34
+ return;
35
+ }
36
+ if (key.downArrow) {
37
+ setSelectedIndex((i) => Math.min(items.length - 1, i + 1));
38
+ soundRef.current.playNavigate();
39
+ return;
40
+ }
41
+ if (key.return) {
42
+ soundRef.current.playSelect();
43
+ onAction(items[selectedIndex].id);
44
+ return;
45
+ }
46
+ const num = Number(input);
47
+ if (num >= 1 && num <= items.length) {
48
+ soundRef.current.playSelect();
49
+ onAction(items[num - 1].id);
50
+ }
51
+ });
52
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(WelcomeBanner, { config: config, stats: stats, source: source }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(SelectList, { items: items, selectedIndex: selectedIndex, showIndex: true }) }), _jsx(StatusBar, { status: isZh ? "主菜单" : "Home", hints: hintLine([
53
+ isZh ? "↑↓ 选择" : "↑↓ select",
54
+ isZh ? "Enter 确认" : "enter confirm",
55
+ isZh ? "1-6 快捷" : "1-6 shortcut"
56
+ ]) })] }));
57
+ }
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { AppHeader } from "../components/AppHeader.js";
4
+ import { StatusBar } from "../components/StatusBar.js";
5
+ import { hintLine, theme } from "../theme.js";
6
+ export function InfoScreen({ title, lines, isZh, onBack }) {
7
+ useInput((input, key) => {
8
+ if (key.return || key.escape || input === "q") {
9
+ onBack();
10
+ }
11
+ });
12
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: title }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: lines.map((line, index) => {
13
+ const isHeading = index === 0 || line.endsWith(":") || line === "";
14
+ return (_jsx(Text, { color: isHeading && line ? theme.claude : theme.text, bold: isHeading && line !== "", children: line || " " }, `${line}-${index}`));
15
+ }) }), _jsx(StatusBar, { status: title, hints: hintLine([isZh ? "Enter 或 q 返回" : "enter or q to go back"]) })] }));
16
+ }
@@ -0,0 +1,344 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { dedupeQuestions } from "../../generation/dedupe.js";
5
+ import { generateQuestions, generateWhy } from "../../providers/claudeAgent.js";
6
+ import { AppHeader } from "../components/AppHeader.js";
7
+ import { SelectList } from "../components/SelectList.js";
8
+ import { StatusBar } from "../components/StatusBar.js";
9
+ import { TextInput } from "../components/TextInput.js";
10
+ import { formatProfile, formatStats } from "../formatters.js";
11
+ import { hintLine, theme } from "../theme.js";
12
+ export function QuizScreen({ store, config, sound, source, questionsOverride = null, mode = "mixed", onDone }) {
13
+ const isZh = config.language === "zh-CN";
14
+ const [phase, setPhase] = useState("generating");
15
+ const [error, setError] = useState(null);
16
+ const [questions, setQuestions] = useState([]);
17
+ const [questionIndex, setQuestionIndex] = useState(0);
18
+ const [choiceIndex, setChoiceIndex] = useState(0);
19
+ const [resultActionIndex, setResultActionIndex] = useState(0);
20
+ const [answerResult, setAnswerResult] = useState(null);
21
+ const [genElapsed, setGenElapsed] = useState(0);
22
+ const [overlay, setOverlay] = useState(null);
23
+ const [whyInput, setWhyInput] = useState("");
24
+ const [whyMessages, setWhyMessages] = useState([]);
25
+ const [whyStreaming, setWhyStreaming] = useState("");
26
+ const [whyLoading, setWhyLoading] = useState(false);
27
+ const whyTurnsRef = useRef([]);
28
+ const startedAtRef = useRef(Date.now());
29
+ const soundRef = useRef(sound);
30
+ soundRef.current = sound;
31
+ const question = questions[questionIndex];
32
+ const total = questions.length;
33
+ const resultActions = isZh
34
+ ? [
35
+ { id: "next", label: "下一题 (Next)" },
36
+ { id: "why", label: "深入了解 (Why)" }
37
+ ]
38
+ : [
39
+ { id: "next", label: "Next" },
40
+ { id: "why", label: "Why (deeper)" }
41
+ ];
42
+ useEffect(() => {
43
+ let cancelled = false;
44
+ const genStart = Date.now();
45
+ const timer = setInterval(() => {
46
+ setGenElapsed(Math.floor((Date.now() - genStart) / 1000));
47
+ }, 1000);
48
+ (async () => {
49
+ try {
50
+ const recentQuestions = store.listRecentQuestions(20);
51
+ let loaded;
52
+ if (questionsOverride) {
53
+ loaded = dedupeQuestions(questionsOverride, recentQuestions).slice(0, 5);
54
+ }
55
+ else {
56
+ const signals = store.getProfileSignals();
57
+ const preferences = store.listProfilePreferences();
58
+ const generated = await generateQuestions({
59
+ source,
60
+ config,
61
+ recentQuestions,
62
+ mode,
63
+ signals,
64
+ preferences,
65
+ onProgress: () => { }
66
+ });
67
+ loaded = dedupeQuestions(generated, recentQuestions).slice(0, 5);
68
+ }
69
+ if (cancelled)
70
+ return;
71
+ if (!loaded.length) {
72
+ setError(isZh ? "去重后没有可用的新题目。" : "No fresh questions were generated after dedupe.");
73
+ setPhase("error");
74
+ return;
75
+ }
76
+ loaded.forEach((item) => store.saveQuestion(item, source.sourceType));
77
+ setQuestions(loaded);
78
+ setPhase("question");
79
+ startedAtRef.current = Date.now();
80
+ soundRef.current.playStart();
81
+ }
82
+ catch (err) {
83
+ if (!cancelled) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ setError(message);
86
+ setPhase("error");
87
+ }
88
+ }
89
+ finally {
90
+ clearInterval(timer);
91
+ }
92
+ })();
93
+ return () => {
94
+ cancelled = true;
95
+ clearInterval(timer);
96
+ };
97
+ }, [store, config, source, questionsOverride, mode, isZh]);
98
+ function submitAnswer(selected) {
99
+ if (!question)
100
+ return;
101
+ const correct = selected === question.answer;
102
+ store.recordAttempt({
103
+ questionId: question.id,
104
+ selected,
105
+ correct,
106
+ durationMs: Date.now() - startedAtRef.current,
107
+ tags: question.tags
108
+ });
109
+ question.tags.forEach((tag) => store.updateSignal(tag, correct));
110
+ store.upsertReviewItem(question.id, correct);
111
+ setAnswerResult({ selected, correct });
112
+ setResultActionIndex(0);
113
+ setPhase("result");
114
+ if (correct) {
115
+ soundRef.current.playCorrect();
116
+ }
117
+ else {
118
+ soundRef.current.playIncorrect();
119
+ }
120
+ }
121
+ async function submitWhyQuestion(text) {
122
+ if (!question)
123
+ return;
124
+ const asked = text.trim();
125
+ if (!asked)
126
+ return;
127
+ setWhyInput("");
128
+ setWhyLoading(true);
129
+ setWhyStreaming("");
130
+ try {
131
+ let streamed = false;
132
+ let streamedText = "";
133
+ const answer = await generateWhy({
134
+ question,
135
+ config,
136
+ asked,
137
+ userAnswer: answerResult?.selected ?? "none",
138
+ onProgress: (chunk) => {
139
+ streamed = true;
140
+ streamedText += chunk;
141
+ setWhyStreaming(streamedText);
142
+ }
143
+ });
144
+ const resolved = streamed ? streamedText || answer : answer;
145
+ setWhyMessages((prev) => [...prev, { asked, answer: resolved }]);
146
+ whyTurnsRef.current.push({ asked, answer: resolved, at: new Date().toISOString() });
147
+ }
148
+ catch (err) {
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ setWhyMessages((prev) => [...prev, { asked, answer: `Error: ${message}` }]);
151
+ }
152
+ finally {
153
+ setWhyLoading(false);
154
+ setWhyStreaming("");
155
+ }
156
+ }
157
+ function finishWhy() {
158
+ if (!question)
159
+ return;
160
+ if (whyTurnsRef.current.length) {
161
+ store.appendWhyThread(question.id, whyTurnsRef.current);
162
+ }
163
+ whyTurnsRef.current = [];
164
+ setWhyMessages([]);
165
+ setWhyInput("");
166
+ setPhase("result");
167
+ }
168
+ function goNextQuestion() {
169
+ setAnswerResult(null);
170
+ setResultActionIndex(0);
171
+ setChoiceIndex(0);
172
+ setWhyMessages([]);
173
+ whyTurnsRef.current = [];
174
+ if (questionIndex + 1 >= total) {
175
+ soundRef.current.playComplete();
176
+ onDone();
177
+ return;
178
+ }
179
+ setQuestionIndex((i) => i + 1);
180
+ setPhase("question");
181
+ startedAtRef.current = Date.now();
182
+ }
183
+ useInput((input, key) => {
184
+ if (!question && (phase === "question" || phase === "result" || phase === "why")) {
185
+ return;
186
+ }
187
+ if (phase === "error") {
188
+ if (key.return || input === "q")
189
+ onDone();
190
+ return;
191
+ }
192
+ if (overlay) {
193
+ if (key.return || key.escape || input === "q") {
194
+ setOverlay(null);
195
+ }
196
+ return;
197
+ }
198
+ if (phase === "generating")
199
+ return;
200
+ if (phase === "question") {
201
+ if (input === "q" || key.escape) {
202
+ onDone();
203
+ return;
204
+ }
205
+ if (input === "s") {
206
+ setOverlay("stats");
207
+ return;
208
+ }
209
+ if (input === "p") {
210
+ setOverlay("profile");
211
+ return;
212
+ }
213
+ if (key.upArrow) {
214
+ setChoiceIndex((i) => Math.max(0, i - 1));
215
+ soundRef.current.playNavigate();
216
+ return;
217
+ }
218
+ if (key.downArrow) {
219
+ setChoiceIndex((i) => Math.min(question.choices.length - 1, i + 1));
220
+ soundRef.current.playNavigate();
221
+ return;
222
+ }
223
+ const num = Number(input);
224
+ if (num >= 1 && num <= question.choices.length) {
225
+ setChoiceIndex(num - 1);
226
+ return;
227
+ }
228
+ const letter = input.toUpperCase();
229
+ const letterIndex = question.choices.findIndex((c) => c.id === letter);
230
+ if (letterIndex >= 0) {
231
+ setChoiceIndex(letterIndex);
232
+ return;
233
+ }
234
+ if (key.return) {
235
+ submitAnswer(question.choices[choiceIndex].id);
236
+ }
237
+ return;
238
+ }
239
+ if (phase === "result") {
240
+ if (input === "s") {
241
+ setOverlay("stats");
242
+ return;
243
+ }
244
+ if (input === "p") {
245
+ setOverlay("profile");
246
+ return;
247
+ }
248
+ if (key.upArrow) {
249
+ setResultActionIndex((i) => Math.max(0, i - 1));
250
+ soundRef.current.playNavigate();
251
+ return;
252
+ }
253
+ if (key.downArrow) {
254
+ setResultActionIndex((i) => Math.min(resultActions.length - 1, i + 1));
255
+ soundRef.current.playNavigate();
256
+ return;
257
+ }
258
+ if (key.return) {
259
+ const action = resultActions[resultActionIndex].id;
260
+ if (action === "next") {
261
+ goNextQuestion();
262
+ }
263
+ else {
264
+ setPhase("why");
265
+ setWhyInput("");
266
+ }
267
+ }
268
+ return;
269
+ }
270
+ if (phase === "why" && !whyLoading) {
271
+ if (input === "q" || key.escape) {
272
+ finishWhy();
273
+ }
274
+ }
275
+ });
276
+ if (phase === "error") {
277
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "错误" : "Error" }), _jsx(Text, { color: theme.error, children: error }), _jsx(StatusBar, { status: isZh ? "无法继续" : "Cannot continue", hints: hintLine([isZh ? "Enter 或 q 返回" : "enter or q to go back"]) })] }));
278
+ }
279
+ if (phase === "generating") {
280
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "生成中" : "Generating" }), _jsx(Text, { color: theme.claude, children: isZh ? `正在生成题目 (${genElapsed}s)...` : `Generating questions (${genElapsed}s)...` }), _jsx(StatusBar, { status: isZh ? "请稍候" : "Please wait", hints: hintLine([isZh ? "基于当前上下文出题" : "building questions from context"]) })] }));
281
+ }
282
+ if (!question) {
283
+ return null;
284
+ }
285
+ const choiceItems = question.choices.map((c) => ({
286
+ id: c.id,
287
+ label: `${c.id}. ${c.text}`
288
+ }));
289
+ let statusText = "";
290
+ let hintsText = "";
291
+ if (overlay === "stats") {
292
+ statusText = isZh ? "统计" : "Stats";
293
+ hintsText = hintLine([isZh ? "Enter 关闭" : "enter close"]);
294
+ }
295
+ else if (overlay === "profile") {
296
+ statusText = isZh ? "档案" : "Profile";
297
+ hintsText = hintLine([isZh ? "Enter 关闭" : "enter close"]);
298
+ }
299
+ else if (phase === "question") {
300
+ statusText = isZh
301
+ ? `Q${questionIndex + 1}/${total} · ${question.topic}`
302
+ : `Q${questionIndex + 1}/${total} · ${question.topic}`;
303
+ hintsText = hintLine([
304
+ isZh ? "↑↓ 选择" : "↑↓ select",
305
+ isZh ? "Enter 确认" : "enter confirm",
306
+ "A-D/1-4",
307
+ "s stats",
308
+ "p profile",
309
+ "q exit"
310
+ ]);
311
+ }
312
+ else if (phase === "result") {
313
+ statusText = isZh ? "结果" : "Result";
314
+ hintsText = hintLine([
315
+ isZh ? "↑↓ 选择" : "↑↓ select",
316
+ isZh ? "Enter 确认" : "enter confirm",
317
+ "s stats",
318
+ "p profile"
319
+ ]);
320
+ }
321
+ else if (phase === "why") {
322
+ statusText = isZh ? "Why" : "Why";
323
+ hintsText = hintLine([
324
+ isZh ? "Enter 发送" : "enter send",
325
+ isZh ? "Esc 返回" : "esc back",
326
+ "back/next"
327
+ ]);
328
+ }
329
+ const quizSubtitle = isZh
330
+ ? `第 ${questionIndex + 1}/${total} 题 · 难度 ${question.difficulty}`
331
+ : `Question ${questionIndex + 1}/${total} · Difficulty ${question.difficulty}`;
332
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: quizSubtitle }), overlay === "stats" ? (_jsx(Box, { flexDirection: "column", children: formatStats(store).map((line, index) => (_jsx(Text, { color: index === 0 ? theme.claude : theme.text, bold: index === 0, children: line }, line))) })) : overlay === "profile" ? (_jsx(Box, { flexDirection: "column", children: formatProfile(store).map((line, index) => (_jsx(Text, { color: index === 0 ? theme.claude : theme.text, bold: index === 0, children: line }, line))) })) : phase === "question" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.claude, children: question.topic }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: theme.text, wrap: "wrap", children: question.question }) }), _jsx(SelectList, { items: choiceItems, selectedIndex: choiceIndex })] })) : phase === "result" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: answerResult?.correct ? theme.success : theme.error, children: answerResult?.correct
333
+ ? isZh ? "回答正确" : "Correct"
334
+ : isZh
335
+ ? `回答错误 · 正确答案 ${question.answer}`
336
+ : `Incorrect · Correct answer ${question.answer}` }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: theme.text, wrap: "wrap", children: question.explanation }) }), !answerResult?.correct && answerResult && question.whyWrong[answerResult.selected] ? (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: theme.inactive, wrap: "wrap", children: [answerResult.selected, ": ", question.whyWrong[answerResult.selected]] }) })) : null, _jsx(SelectList, { items: resultActions, selectedIndex: resultActionIndex })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.permission, children: isZh ? "Why" : "Why" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [whyMessages.map((msg, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: theme.suggestion, children: [isZh ? "问: " : "Q: ", msg.asked] }), _jsx(Text, { color: theme.text, wrap: "wrap", children: msg.answer })] }, `${msg.asked}-${i}`))), whyLoading && whyStreaming ? (_jsx(Text, { color: theme.text, wrap: "wrap", children: whyStreaming })) : null, whyLoading && !whyStreaming ? (_jsx(Text, { color: theme.inactive, children: isZh ? "思考中..." : "Thinking..." })) : null] }), !whyLoading ? (_jsx(TextInput, { value: whyInput, onChange: setWhyInput, onSubmit: (value) => {
337
+ const normalized = value.trim().toLowerCase();
338
+ if (["back", "next", "quiz"].includes(normalized)) {
339
+ finishWhy();
340
+ return;
341
+ }
342
+ submitWhyQuestion(value);
343
+ }, placeholder: "why> " })) : null] })), _jsx(StatusBar, { status: statusText, hints: hintsText })] }));
344
+ }
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { AppHeader } from "../components/AppHeader.js";
5
+ import { SelectList } from "../components/SelectList.js";
6
+ import { StatusBar } from "../components/StatusBar.js";
7
+ import { hintLine, theme } from "../theme.js";
8
+ const LEVELS = [
9
+ { id: "junior", label: "Junior" },
10
+ { id: "mid", label: "Mid-level" },
11
+ { id: "senior", label: "Senior" },
12
+ { id: "staff", label: "Staff+" }
13
+ ];
14
+ export function SettingsScreen({ config, sound, onPersist, onBack }) {
15
+ const isZh = config.language === "zh-CN";
16
+ const [step, setStep] = useState("menu");
17
+ const [menuIndex, setMenuIndex] = useState(0);
18
+ const [levelIndex, setLevelIndex] = useState(Math.max(0, LEVELS.findIndex((l) => l.id === config.level)));
19
+ const soundRef = useRef(sound);
20
+ soundRef.current = sound;
21
+ const configRef = useRef(config);
22
+ configRef.current = config;
23
+ const menuItems = isZh
24
+ ? [
25
+ { id: "language", label: `语言: ${config.language === "zh-CN" ? "中文" : "English"}` },
26
+ { id: "level", label: `等级: ${config.level}` },
27
+ { id: "goal", label: `每日目标: ${config.dailyGoal}` },
28
+ { id: "sound", label: `音效: ${config.soundEnabled ? "开" : "关"}` },
29
+ { id: "back", label: "返回" }
30
+ ]
31
+ : [
32
+ { id: "language", label: `Language: ${config.language}` },
33
+ { id: "level", label: `Level: ${config.level}` },
34
+ { id: "goal", label: `Daily goal: ${config.dailyGoal}` },
35
+ { id: "sound", label: `Sound: ${config.soundEnabled ? "On" : "Off"}` },
36
+ { id: "back", label: "Back" }
37
+ ];
38
+ useInput((input, key) => {
39
+ if (step === "menu") {
40
+ if (key.upArrow) {
41
+ setMenuIndex((i) => Math.max(0, i - 1));
42
+ soundRef.current.playNavigate();
43
+ return;
44
+ }
45
+ if (key.downArrow) {
46
+ setMenuIndex((i) => Math.min(menuItems.length - 1, i + 1));
47
+ soundRef.current.playNavigate();
48
+ return;
49
+ }
50
+ if (key.return) {
51
+ const action = menuItems[menuIndex].id;
52
+ const current = configRef.current;
53
+ if (action === "language") {
54
+ onPersist({
55
+ ...current,
56
+ language: current.language === "zh-CN" ? "en" : "zh-CN"
57
+ });
58
+ return;
59
+ }
60
+ if (action === "sound") {
61
+ const next = !current.soundEnabled;
62
+ if (next) {
63
+ soundRef.current.playToggleOn();
64
+ }
65
+ else {
66
+ soundRef.current.playToggleOff();
67
+ }
68
+ onPersist({ ...current, soundEnabled: next });
69
+ return;
70
+ }
71
+ if (action === "level") {
72
+ setLevelIndex(Math.max(0, LEVELS.findIndex((l) => l.id === current.level)));
73
+ setStep("level");
74
+ return;
75
+ }
76
+ if (action === "goal") {
77
+ setStep("goal");
78
+ return;
79
+ }
80
+ if (action === "back") {
81
+ onBack();
82
+ }
83
+ }
84
+ if (key.escape)
85
+ onBack();
86
+ return;
87
+ }
88
+ if (step === "level") {
89
+ if (key.upArrow) {
90
+ setLevelIndex((i) => Math.max(0, i - 1));
91
+ soundRef.current.playNavigate();
92
+ return;
93
+ }
94
+ if (key.downArrow) {
95
+ setLevelIndex((i) => Math.min(LEVELS.length - 1, i + 1));
96
+ soundRef.current.playNavigate();
97
+ return;
98
+ }
99
+ if (key.return) {
100
+ onPersist({ ...configRef.current, level: LEVELS[levelIndex].id });
101
+ setStep("menu");
102
+ }
103
+ if (key.escape)
104
+ setStep("menu");
105
+ return;
106
+ }
107
+ if (step === "goal") {
108
+ const num = Number(input);
109
+ if (num >= 1 && num <= 9) {
110
+ onPersist({ ...configRef.current, dailyGoal: num });
111
+ setStep("menu");
112
+ }
113
+ if (key.escape)
114
+ setStep("menu");
115
+ }
116
+ });
117
+ if (step === "level") {
118
+ const levelItems = LEVELS.map((l) => ({ id: l.id, label: l.label }));
119
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "设置 · 等级" : "Settings · Level" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectList, { items: levelItems, selectedIndex: levelIndex, showIndex: true }) }), _jsx(StatusBar, { status: isZh ? "等级" : "Level", hints: hintLine([
120
+ isZh ? "↑↓ 选择" : "↑↓ select",
121
+ isZh ? "Enter 确认" : "enter confirm",
122
+ isZh ? "Esc 返回" : "esc back"
123
+ ]) })] }));
124
+ }
125
+ if (step === "goal") {
126
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "设置 · 每日目标" : "Settings · Daily goal" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.inactive, children: isZh ? "输入数字 1-9" : "Type a number from 1 to 9" }) }), _jsx(StatusBar, { status: isZh ? "每日目标" : "Daily goal", hints: hintLine([isZh ? "输入 1-9" : "type 1-9", isZh ? "Esc 返回" : "esc back"]) })] }));
127
+ }
128
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "设置" : "Settings" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectList, { items: menuItems, selectedIndex: menuIndex }) }), _jsx(StatusBar, { status: isZh ? "偏好" : "Preferences", hints: hintLine([
129
+ isZh ? "↑↓ 选择" : "↑↓ select",
130
+ isZh ? "Enter 确认/切换" : "enter confirm/toggle",
131
+ isZh ? "Esc 返回" : "esc back"
132
+ ]) })] }));
133
+ }