@jiy/quizme 0.1.0 → 0.1.1

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.
@@ -0,0 +1,227 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ function emptyData() {
4
+ return {
5
+ version: 1,
6
+ config: {},
7
+ stats: { totalAttempts: 0, correctAttempts: 0, whyCount: 0, byDay: {} },
8
+ profile: { signals: {} },
9
+ reviewQueue: []
10
+ };
11
+ }
12
+ function localStr(d) {
13
+ const y = d.getFullYear();
14
+ const m = String(d.getMonth() + 1).padStart(2, "0");
15
+ const day = String(d.getDate()).padStart(2, "0");
16
+ return `${y}-${m}-${day}`;
17
+ }
18
+ function parseDay(s) {
19
+ const parts = s.split("-").map(Number);
20
+ if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) {
21
+ return null;
22
+ }
23
+ const [y, m, d] = parts;
24
+ return new Date(y, m - 1, d);
25
+ }
26
+ function computeCurrentStreak(byDay) {
27
+ const cursor = new Date();
28
+ if (!byDay[localStr(cursor)]) {
29
+ cursor.setDate(cursor.getDate() - 1);
30
+ if (!byDay[localStr(cursor)])
31
+ return 0;
32
+ }
33
+ let streak = 0;
34
+ while (byDay[localStr(cursor)]) {
35
+ streak += 1;
36
+ cursor.setDate(cursor.getDate() - 1);
37
+ }
38
+ return streak;
39
+ }
40
+ function computeLongestStreak(byDay) {
41
+ const days = Object.keys(byDay).sort();
42
+ let longest = 0;
43
+ let run = 0;
44
+ let prev = null;
45
+ for (const day of days) {
46
+ const d = parseDay(day);
47
+ if (!d)
48
+ continue;
49
+ if (prev && Math.round((d.getTime() - prev.getTime()) / 86_400_000) === 1) {
50
+ run += 1;
51
+ }
52
+ else {
53
+ run = 1;
54
+ }
55
+ if (run > longest)
56
+ longest = run;
57
+ prev = d;
58
+ }
59
+ return longest;
60
+ }
61
+ function computeWeekRows(byDay) {
62
+ const rows = [];
63
+ const today = new Date();
64
+ for (let i = 6; i >= 0; i--) {
65
+ const d = new Date(today);
66
+ d.setDate(d.getDate() - i);
67
+ const key = localStr(d);
68
+ const bucket = byDay[key];
69
+ if (bucket)
70
+ rows.push([key, String(bucket.total)]);
71
+ }
72
+ return rows;
73
+ }
74
+ export class JsonStore {
75
+ filePath;
76
+ data;
77
+ questionBank = [];
78
+ constructor(filePath) {
79
+ this.filePath = filePath;
80
+ this.data = this.load();
81
+ }
82
+ load() {
83
+ try {
84
+ const raw = fs.readFileSync(this.filePath, "utf8");
85
+ if (!raw.trim())
86
+ return emptyData();
87
+ const parsed = JSON.parse(raw);
88
+ const stats = parsed.stats ?? {};
89
+ return {
90
+ version: 1,
91
+ config: parsed.config ?? {},
92
+ stats: {
93
+ totalAttempts: stats.totalAttempts ?? 0,
94
+ correctAttempts: stats.correctAttempts ?? 0,
95
+ whyCount: stats.whyCount ?? 0,
96
+ byDay: stats.byDay ?? {}
97
+ },
98
+ profile: { signals: parsed.profile?.signals ?? {} },
99
+ reviewQueue: Array.isArray(parsed.reviewQueue) ? parsed.reviewQueue : []
100
+ };
101
+ }
102
+ catch {
103
+ return emptyData();
104
+ }
105
+ }
106
+ persist() {
107
+ const dir = path.dirname(this.filePath);
108
+ if (dir)
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ const tmp = `${this.filePath}.tmp`;
111
+ fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + "\n", "utf8");
112
+ fs.renameSync(tmp, this.filePath);
113
+ }
114
+ init() {
115
+ // Data is loaded in the constructor; nothing to initialize eagerly.
116
+ }
117
+ setConfig(key, value) {
118
+ this.data.config[key] = value;
119
+ this.persist();
120
+ }
121
+ getConfig(key, fallback = null) {
122
+ const value = this.data.config[key];
123
+ return value === undefined ? fallback : value;
124
+ }
125
+ saveQuestion(question) {
126
+ const idx = this.questionBank.findIndex((q) => q.id === question.id);
127
+ if (idx >= 0)
128
+ this.questionBank[idx] = question;
129
+ else
130
+ this.questionBank.push(question);
131
+ }
132
+ listRecentQuestions(limit = 20) {
133
+ return [...this.questionBank].reverse().slice(0, limit);
134
+ }
135
+ clearQuestionBank() {
136
+ this.questionBank = [];
137
+ }
138
+ resetAll() {
139
+ this.data = emptyData();
140
+ this.questionBank = [];
141
+ this.persist();
142
+ }
143
+ recordAttempt({ correct }) {
144
+ this.data.stats.totalAttempts += 1;
145
+ if (correct)
146
+ this.data.stats.correctAttempts += 1;
147
+ const today = localStr(new Date());
148
+ const bucket = this.data.stats.byDay[today] ?? { total: 0, correct: 0 };
149
+ bucket.total += 1;
150
+ if (correct)
151
+ bucket.correct += 1;
152
+ this.data.stats.byDay[today] = bucket;
153
+ this.persist();
154
+ }
155
+ recordWhyAttempt(_questionId) {
156
+ this.data.stats.whyCount += 1;
157
+ this.persist();
158
+ }
159
+ updateSignal(tag, wasCorrect) {
160
+ const delta = wasCorrect ? 0.08 : -0.1;
161
+ const trend = delta > 0 ? "up" : "down";
162
+ const prev = this.data.profile.signals[tag] ?? {
163
+ score: 0.5,
164
+ confidence: 0.2,
165
+ trend,
166
+ correctCount: 0,
167
+ wrongCount: 0
168
+ };
169
+ this.data.profile.signals[tag] = {
170
+ score: Math.min(0.95, Math.max(0.05, prev.score + delta)),
171
+ confidence: Math.min(0.98, prev.confidence + 0.08),
172
+ trend,
173
+ correctCount: prev.correctCount + (wasCorrect ? 1 : 0),
174
+ wrongCount: prev.wrongCount + (wasCorrect ? 0 : 1)
175
+ };
176
+ this.persist();
177
+ }
178
+ getProfileSignals() {
179
+ return Object.entries(this.data.profile.signals)
180
+ .map(([tag, row]) => ({ tag, ...row }))
181
+ .sort((a, b) => b.score - a.score || b.confidence - a.confidence);
182
+ }
183
+ upsertReviewItem(question, resolved) {
184
+ const idx = this.data.reviewQueue.findIndex((r) => r.id === question.id);
185
+ if (resolved) {
186
+ if (idx >= 0)
187
+ this.data.reviewQueue.splice(idx, 1);
188
+ this.persist();
189
+ return;
190
+ }
191
+ const entry = {
192
+ id: question.id,
193
+ question,
194
+ addedAt: new Date().toISOString()
195
+ };
196
+ if (idx >= 0)
197
+ this.data.reviewQueue[idx] = entry;
198
+ else
199
+ this.data.reviewQueue.push(entry);
200
+ this.persist();
201
+ }
202
+ listReviewQuestions(limit = 5) {
203
+ return [...this.data.reviewQueue]
204
+ .reverse()
205
+ .slice(0, limit)
206
+ .map((r) => r.question);
207
+ }
208
+ getStats() {
209
+ const { totalAttempts, correctAttempts, whyCount, byDay } = this.data.stats;
210
+ const reviewPending = this.data.reviewQueue.length;
211
+ const today = localStr(new Date());
212
+ const todayCount = byDay[today]?.total ?? 0;
213
+ return {
214
+ attemptsTotal: totalAttempts,
215
+ attemptsCorrect: correctAttempts,
216
+ todayCount,
217
+ reviewPending,
218
+ whyCount,
219
+ currentStreak: computeCurrentStreak(byDay),
220
+ longestStreak: computeLongestStreak(byDay),
221
+ xp: correctAttempts * 10 + (totalAttempts - correctAttempts) * 4 + whyCount * 3,
222
+ level: Math.floor((correctAttempts * 10 + (totalAttempts - correctAttempts) * 4 + whyCount * 3) / 100) + 1,
223
+ accuracy: totalAttempts ? correctAttempts / totalAttempts : 0,
224
+ weekRows: computeWeekRows(byDay)
225
+ };
226
+ }
227
+ }
package/dist/ui/App.js CHANGED
@@ -6,6 +6,7 @@ import { SettingsScreen } from "./screens/SettingsScreen.js";
6
6
  import { InfoScreen } from "./screens/InfoScreen.js";
7
7
  import { formatProfile, formatStats } from "./formatters.js";
8
8
  import { createSoundPlayer } from "./sound.js";
9
+ import { normalizeConfig } from "../cli/config.js";
9
10
  export function App({ store, initialConfig, resolveSource, onExit }) {
10
11
  const [config, setConfig] = useState(initialConfig);
11
12
  const [screen, setScreen] = useState("home");
@@ -35,8 +36,7 @@ export function App({ store, initialConfig, resolveSource, onExit }) {
35
36
  return;
36
37
  }
37
38
  if (action === "review") {
38
- const ids = new Set(store.listReviewQuestionIds(5));
39
- const questions = store.listRecentQuestions(50).filter((item) => ids.has(item.id));
39
+ const questions = store.listReviewQuestions(5);
40
40
  if (!questions.length) {
41
41
  setScreen("review-empty");
42
42
  return;
@@ -75,7 +75,11 @@ export function App({ store, initialConfig, resolveSource, onExit }) {
75
75
  store.setConfig("user", next);
76
76
  setConfig(next);
77
77
  };
78
- return (_jsx(SettingsScreen, { config: config, sound: sound, onPersist: persistConfig, onBack: () => setScreen("home") }));
78
+ return (_jsx(SettingsScreen, { config: config, sound: sound, onPersist: persistConfig, onReset: () => {
79
+ store.resetAll();
80
+ setConfig(normalizeConfig({}));
81
+ setScreen("home");
82
+ }, onBack: () => setScreen("home") }));
79
83
  }
80
84
  if (screen === "stats") {
81
85
  return (_jsx(InfoScreen, { title: "QuizMe Stats", lines: formatStats(store), isZh: isZh, onBack: () => setScreen("home") }));
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box, Text, useStdout } from "ink";
3
+ import { Divider } from "./Divider.js";
3
4
  import { theme } from "../theme.js";
4
5
  export function AppHeader({ title, subtitle }) {
5
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, color: theme.claude, children: title }), subtitle ? (_jsx(Text, { color: theme.inactive, children: subtitle })) : null] }));
6
+ const { stdout } = useStdout();
7
+ const columns = stdout.columns || 80;
8
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, color: theme.claude, children: title }), subtitle ? (_jsx(Text, { dimColor: true, children: subtitle })) : null, _jsx(Box, { marginTop: 0, children: _jsx(Divider, { width: columns }) })] }));
6
9
  }
@@ -6,7 +6,7 @@ export function SelectList({ items, selectedIndex, showIndex = false }) {
6
6
  const selected = index === selectedIndex;
7
7
  const prefix = showIndex ? `${index + 1}. ` : selected ? `${symbols.pointer} ` : `${symbols.pointerIdle} `;
8
8
  if (selected) {
9
- return (_jsx(Box, { children: _jsxs(Text, { backgroundColor: theme.selectionBg, color: theme.text, bold: true, children: [prefix, item.label] }) }, item.id ?? item.label));
9
+ return (_jsx(Box, { children: _jsxs(Text, { color: theme.selectionFg, bold: true, children: [prefix, item.label] }) }, item.id ?? item.label));
10
10
  }
11
11
  return (_jsx(Box, { children: _jsxs(Text, { color: theme.text, children: [prefix, item.label] }) }, item.id ?? item.label));
12
12
  }) }));
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text } from "ink";
4
+ import { theme } from "../theme.js";
5
+ // Claude Code-style spinner: a pulsing dot prefix + cycling star frames.
6
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ export function Spinner({ label }) {
8
+ const [frame, setFrame] = useState(0);
9
+ useEffect(() => {
10
+ const timer = setInterval(() => {
11
+ setFrame((f) => (f + 1) % FRAMES.length);
12
+ }, 80);
13
+ return () => clearInterval(timer);
14
+ }, []);
15
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.permission, children: FRAMES[frame] }), _jsxs(Text, { color: theme.claude, children: [" ", label] })] }));
16
+ }
@@ -1,9 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { theme } from "../theme.js";
4
3
  export function StatusBar({ status, hints }) {
5
4
  if (!status && !hints) {
6
5
  return null;
7
6
  }
8
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [status ? (_jsx(Text, { color: theme.inactive, children: status })) : null, hints ? (_jsx(Text, { color: theme.subtle, children: hints })) : null] }));
7
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [status ? (_jsx(Text, { dimColor: true, children: status })) : null, hints ? (_jsx(Text, { dimColor: true, children: hints })) : null] }));
9
8
  }
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Text, useInput } from "ink";
3
- import { theme } from "../theme.js";
4
- export function TextInput({ value, onChange, onSubmit, placeholder = "" }) {
2
+ import { Box, Text, useStdout, useInput } from "ink";
3
+ import { symbols, theme } from "../theme.js";
4
+ export function TextInput({ value, onChange, onSubmit, placeholder = "", frameLabel }) {
5
+ const { stdout } = useStdout();
6
+ const columns = stdout.columns || 80;
5
7
  useInput((input, key) => {
6
8
  if (key.return) {
7
9
  onSubmit(value);
@@ -19,5 +21,6 @@ export function TextInput({ value, onChange, onSubmit, placeholder = "" }) {
19
21
  onChange(value + input);
20
22
  }
21
23
  });
22
- return (_jsxs(Text, { children: [_jsx(Text, { color: theme.permission, children: placeholder }), _jsx(Text, { color: theme.text, children: value }), _jsx(Text, { backgroundColor: theme.suggestion, color: theme.inverseText, children: " " })] }));
24
+ const prompt = placeholder || "> ";
25
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.promptBorder, marginTop: 1, width: columns, children: [frameLabel ? (_jsx(Text, { dimColor: true, children: frameLabel })) : null, _jsxs(Box, { children: [_jsx(Text, { color: theme.permission, children: prompt }), _jsx(Text, { color: theme.text, children: value }), _jsx(Text, { color: theme.selectionFg, children: symbols.cursor })] })] }));
23
26
  }
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
- import { Clawd } from "./Clawd.js";
4
3
  import { FeedColumn } from "./FeedColumn.js";
5
4
  import { calculateLayoutDimensions, calculateOptimalLeftWidth, getLayoutMode } from "../logoLayout.js";
6
5
  import { truncate, wrapText } from "../textUtils.js";
@@ -52,7 +51,7 @@ function buildFeeds(isZh, stats, config) {
52
51
  };
53
52
  return [statsFeed, configFeed];
54
53
  }
55
- const CLAWD_AND_GAP = 12;
54
+ const CLAWD_AND_GAP = 0;
56
55
  export function WelcomeBanner({ config, stats, source }) {
57
56
  const { stdout } = useStdout();
58
57
  const columns = stdout.columns || 80;
@@ -73,7 +72,7 @@ export function WelcomeBanner({ config, stats, source }) {
73
72
  const { leftWidth, rightWidth, totalWidth } = calculateLayoutDimensions(columns, layoutMode, optimalLeftWidth + CLAWD_AND_GAP);
74
73
  const feeds = buildFeeds(isZh, stats, config);
75
74
  const boxWidth = Math.min(columns, totalWidth + 4);
76
- const leftPanel = (_jsxs(Box, { flexDirection: "row", gap: 2, alignItems: "center", children: [_jsx(Clawd, {}), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "QuizMe" }), _jsxs(Text, { dimColor: true, children: [" v", truncate(QUIZME_VERSION, 12)] })] }), taglineLines.map((line, index) => (_jsx(Text, { dimColor: true, children: line }, `tagline-${index}`))), modeLines.map((line, index) => (_jsx(Text, { dimColor: true, children: line }, `mode-${index}`)))] })] }));
75
+ const leftPanel = (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "QuizMe" }), _jsxs(Text, { dimColor: true, children: [" v", truncate(QUIZME_VERSION, 12)] })] }), taglineLines.map((line, index) => (_jsx(Text, { dimColor: true, children: line }, `tagline-${index}`))), modeLines.map((line, index) => (_jsx(Text, { dimColor: true, children: line }, `mode-${index}`)))] }));
77
76
  const rightPanel = _jsx(FeedColumn, { feeds: feeds, maxWidth: rightWidth });
78
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, width: boxWidth, children: [_jsx(Box, { marginBottom: -1, marginLeft: 3, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.claude, children: "QuizMe" }), _jsxs(Text, { dimColor: true, children: [" v", QUIZME_VERSION] })] }) }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.claude, paddingX: 1, paddingY: 1, width: boxWidth, children: layoutMode === "horizontal" ? (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Box, { width: leftWidth, justifyContent: "center", children: leftPanel }), _jsx(Box, { height: "100%", borderStyle: "single", borderColor: theme.claude, borderDimColor: true, borderTop: false, borderBottom: false, borderLeft: false }), rightPanel] })) : (_jsxs(Box, { flexDirection: "column", gap: 1, alignItems: "center", children: [leftPanel, rightPanel] })) })] }));
77
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, width: boxWidth, children: [_jsx(Box, { marginBottom: -1, marginLeft: 3, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.claude, children: "QuizMe" }), _jsxs(Text, { dimColor: true, children: [" v", QUIZME_VERSION] })] }) }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.claude, paddingX: 1, paddingY: 0, width: boxWidth, borderLeft: false, borderRight: false, children: layoutMode === "horizontal" ? (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Box, { width: leftWidth, justifyContent: "center", children: leftPanel }), _jsx(Box, { height: "100%", borderStyle: "single", borderColor: theme.claude, borderDimColor: true, borderTop: false, borderBottom: false, borderLeft: false }), rightPanel] })) : (_jsxs(Box, { flexDirection: "column", gap: 1, alignItems: "center", children: [leftPanel, rightPanel] })) })] }));
79
78
  }
@@ -5,10 +5,11 @@ import { dedupeQuestions } from "../../generation/dedupe.js";
5
5
  import { generateQuestions, generateWhy } from "../../providers/claudeAgent.js";
6
6
  import { AppHeader } from "../components/AppHeader.js";
7
7
  import { SelectList } from "../components/SelectList.js";
8
+ import { Spinner } from "../components/Spinner.js";
8
9
  import { StatusBar } from "../components/StatusBar.js";
9
10
  import { TextInput } from "../components/TextInput.js";
10
11
  import { formatProfile, formatStats } from "../formatters.js";
11
- import { hintLine, theme } from "../theme.js";
12
+ import { hintLine, symbols, theme } from "../theme.js";
12
13
  export function QuizScreen({ store, config, sound, source, questionsOverride = null, mode = "mixed", onDone }) {
13
14
  const isZh = config.language === "zh-CN";
14
15
  const [phase, setPhase] = useState("generating");
@@ -47,6 +48,7 @@ export function QuizScreen({ store, config, sound, source, questionsOverride = n
47
48
  }, 1000);
48
49
  (async () => {
49
50
  try {
51
+ store.clearQuestionBank();
50
52
  const recentQuestions = store.listRecentQuestions(20);
51
53
  let loaded;
52
54
  if (questionsOverride) {
@@ -54,14 +56,12 @@ export function QuizScreen({ store, config, sound, source, questionsOverride = n
54
56
  }
55
57
  else {
56
58
  const signals = store.getProfileSignals();
57
- const preferences = store.listProfilePreferences();
58
59
  const generated = await generateQuestions({
59
60
  source,
60
61
  config,
61
62
  recentQuestions,
62
63
  mode,
63
64
  signals,
64
- preferences,
65
65
  onProgress: () => { }
66
66
  });
67
67
  loaded = dedupeQuestions(generated, recentQuestions).slice(0, 5);
@@ -73,7 +73,7 @@ export function QuizScreen({ store, config, sound, source, questionsOverride = n
73
73
  setPhase("error");
74
74
  return;
75
75
  }
76
- loaded.forEach((item) => store.saveQuestion(item, source.sourceType));
76
+ loaded.forEach((item) => store.saveQuestion(item));
77
77
  setQuestions(loaded);
78
78
  setPhase("question");
79
79
  startedAtRef.current = Date.now();
@@ -107,7 +107,7 @@ export function QuizScreen({ store, config, sound, source, questionsOverride = n
107
107
  tags: question.tags
108
108
  });
109
109
  question.tags.forEach((tag) => store.updateSignal(tag, correct));
110
- store.upsertReviewItem(question.id, correct);
110
+ store.upsertReviewItem(question, correct);
111
111
  setAnswerResult({ selected, correct });
112
112
  setResultActionIndex(0);
113
113
  setPhase("result");
@@ -158,7 +158,7 @@ export function QuizScreen({ store, config, sound, source, questionsOverride = n
158
158
  if (!question)
159
159
  return;
160
160
  if (whyTurnsRef.current.length) {
161
- store.appendWhyThread(question.id, whyTurnsRef.current);
161
+ store.recordWhyAttempt(question.id);
162
162
  }
163
163
  whyTurnsRef.current = [];
164
164
  setWhyMessages([]);
@@ -274,10 +274,10 @@ export function QuizScreen({ store, config, sound, source, questionsOverride = n
274
274
  }
275
275
  });
276
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"]) })] }));
277
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "错误" : "Error" }), _jsxs(Text, { color: theme.error, children: [symbols.error, " ", error] }), _jsx(StatusBar, { status: isZh ? "无法继续" : "Cannot continue", hints: hintLine([isZh ? "Enter 或 q 返回" : "enter or q to go back"]) })] }));
278
278
  }
279
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"]) })] }));
280
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "生成中" : "Generating" }), _jsx(Spinner, { label: isZh ? `正在生成题目 (${genElapsed}s)` : `Generating questions (${genElapsed}s)` }), _jsx(StatusBar, { status: isZh ? "请稍候" : "Please wait", hints: hintLine([isZh ? "基于当前上下文出题" : "building questions from context"]) })] }));
281
281
  }
282
282
  if (!question) {
283
283
  return null;
@@ -330,15 +330,15 @@ export function QuizScreen({ store, config, sound, source, questionsOverride = n
330
330
  ? `第 ${questionIndex + 1}/${total} 题 · 难度 ${question.difficulty}`
331
331
  : `Question ${questionIndex + 1}/${total} · Difficulty ${question.difficulty}`;
332
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"
333
+ ? isZh ? `${symbols.success} 回答正确` : `${symbols.success} Correct`
334
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) => {
335
+ ? `${symbols.error} 回答错误 · 正确答案 ${question.answer}`
336
+ : `${symbols.error} 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, { backgroundColor: theme.userMessageBg, children: [_jsxs(Text, { color: theme.permission, children: [symbols.pointer, " "] }), msg.asked] }), _jsx(Text, { color: theme.text, wrap: "wrap", children: msg.answer })] }, `${msg.asked}-${i}`))), whyLoading && whyStreaming ? (_jsxs(Text, { color: theme.text, wrap: "wrap", children: [whyStreaming, _jsx(Text, { color: theme.selectionFg, children: symbols.cursor })] })) : null, whyLoading && !whyStreaming ? (_jsx(Spinner, { label: isZh ? "思考中" : "Thinking" })) : null] }), !whyLoading ? (_jsx(TextInput, { value: whyInput, onChange: setWhyInput, onSubmit: (value) => {
337
337
  const normalized = value.trim().toLowerCase();
338
338
  if (["back", "next", "quiz"].includes(normalized)) {
339
339
  finishWhy();
340
340
  return;
341
341
  }
342
342
  submitWhyQuestion(value);
343
- }, placeholder: "why> " })) : null] })), _jsx(StatusBar, { status: statusText, hints: hintsText })] }));
343
+ }, placeholder: "why> ", frameLabel: isZh ? `why · 第 ${questionIndex + 1}/${total} 题` : `why · Q${questionIndex + 1}/${total}` })) : null] })), _jsx(StatusBar, { status: statusText, hints: hintsText })] }));
344
344
  }
@@ -11,11 +11,26 @@ const LEVELS = [
11
11
  { id: "senior", label: "Senior" },
12
12
  { id: "staff", label: "Staff+" }
13
13
  ];
14
- export function SettingsScreen({ config, sound, onPersist, onBack }) {
14
+ const CLAUDE_MODELS = [
15
+ { id: "haiku", label: "Haiku (fast)" },
16
+ { id: "sonnet", label: "Sonnet" },
17
+ { id: "opus", label: "Opus" },
18
+ { id: "", label: "Account default" }
19
+ ];
20
+ const CLAUDE_EFFORTS = [
21
+ { id: "low", label: "Low" },
22
+ { id: "medium", label: "Medium" },
23
+ { id: "high", label: "High" },
24
+ { id: "xhigh", label: "xHigh" },
25
+ { id: "max", label: "Max" }
26
+ ];
27
+ export function SettingsScreen({ config, sound, onPersist, onReset, onBack }) {
15
28
  const isZh = config.language === "zh-CN";
16
29
  const [step, setStep] = useState("menu");
17
30
  const [menuIndex, setMenuIndex] = useState(0);
18
31
  const [levelIndex, setLevelIndex] = useState(Math.max(0, LEVELS.findIndex((l) => l.id === config.level)));
32
+ const [modelIndex, setModelIndex] = useState(Math.max(0, CLAUDE_MODELS.findIndex((m) => m.id === (config.claudeModel ?? ""))));
33
+ const [effortIndex, setEffortIndex] = useState(Math.max(0, CLAUDE_EFFORTS.findIndex((e) => e.id === (config.claudeEffort ?? ""))));
19
34
  const soundRef = useRef(sound);
20
35
  soundRef.current = sound;
21
36
  const configRef = useRef(config);
@@ -26,6 +41,15 @@ export function SettingsScreen({ config, sound, onPersist, onBack }) {
26
41
  { id: "level", label: `等级: ${config.level}` },
27
42
  { id: "goal", label: `每日目标: ${config.dailyGoal}` },
28
43
  { id: "sound", label: `音效: ${config.soundEnabled ? "开" : "关"}` },
44
+ {
45
+ id: "model",
46
+ label: `题目模型: ${config.claudeModel ? config.claudeModel : "默认"}`
47
+ },
48
+ {
49
+ id: "effort",
50
+ label: `题目 Effort: ${config.claudeEffort ?? "默认"}`
51
+ },
52
+ { id: "reset", label: "清除设置和缓存" },
29
53
  { id: "back", label: "返回" }
30
54
  ]
31
55
  : [
@@ -33,6 +57,15 @@ export function SettingsScreen({ config, sound, onPersist, onBack }) {
33
57
  { id: "level", label: `Level: ${config.level}` },
34
58
  { id: "goal", label: `Daily goal: ${config.dailyGoal}` },
35
59
  { id: "sound", label: `Sound: ${config.soundEnabled ? "On" : "Off"}` },
60
+ {
61
+ id: "model",
62
+ label: `Quiz model: ${config.claudeModel ? config.claudeModel : "default"}`
63
+ },
64
+ {
65
+ id: "effort",
66
+ label: `Quiz effort: ${config.claudeEffort ?? "default"}`
67
+ },
68
+ { id: "reset", label: "Clear settings & cache" },
36
69
  { id: "back", label: "Back" }
37
70
  ];
38
71
  useInput((input, key) => {
@@ -73,10 +106,24 @@ export function SettingsScreen({ config, sound, onPersist, onBack }) {
73
106
  setStep("level");
74
107
  return;
75
108
  }
109
+ if (action === "model") {
110
+ setModelIndex(Math.max(0, CLAUDE_MODELS.findIndex((m) => m.id === (current.claudeModel ?? ""))));
111
+ setStep("model");
112
+ return;
113
+ }
114
+ if (action === "effort") {
115
+ setEffortIndex(Math.max(0, CLAUDE_EFFORTS.findIndex((e) => e.id === (current.claudeEffort ?? ""))));
116
+ setStep("effort");
117
+ return;
118
+ }
76
119
  if (action === "goal") {
77
120
  setStep("goal");
78
121
  return;
79
122
  }
123
+ if (action === "reset") {
124
+ setStep("confirm-reset");
125
+ return;
126
+ }
80
127
  if (action === "back") {
81
128
  onBack();
82
129
  }
@@ -85,6 +132,17 @@ export function SettingsScreen({ config, sound, onPersist, onBack }) {
85
132
  onBack();
86
133
  return;
87
134
  }
135
+ if (step === "confirm-reset") {
136
+ if (input === "y" || input === "Y") {
137
+ soundRef.current.playToggleOff();
138
+ onReset();
139
+ return;
140
+ }
141
+ if (input === "n" || input === "N" || key.escape) {
142
+ setStep("menu");
143
+ }
144
+ return;
145
+ }
88
146
  if (step === "level") {
89
147
  if (key.upArrow) {
90
148
  setLevelIndex((i) => Math.max(0, i - 1));
@@ -104,6 +162,47 @@ export function SettingsScreen({ config, sound, onPersist, onBack }) {
104
162
  setStep("menu");
105
163
  return;
106
164
  }
165
+ if (step === "model") {
166
+ if (key.upArrow) {
167
+ setModelIndex((i) => Math.max(0, i - 1));
168
+ soundRef.current.playNavigate();
169
+ return;
170
+ }
171
+ if (key.downArrow) {
172
+ setModelIndex((i) => Math.min(CLAUDE_MODELS.length - 1, i + 1));
173
+ soundRef.current.playNavigate();
174
+ return;
175
+ }
176
+ if (key.return) {
177
+ onPersist({ ...configRef.current, claudeModel: CLAUDE_MODELS[modelIndex].id });
178
+ setStep("menu");
179
+ }
180
+ if (key.escape)
181
+ setStep("menu");
182
+ return;
183
+ }
184
+ if (step === "effort") {
185
+ if (key.upArrow) {
186
+ setEffortIndex((i) => Math.max(0, i - 1));
187
+ soundRef.current.playNavigate();
188
+ return;
189
+ }
190
+ if (key.downArrow) {
191
+ setEffortIndex((i) => Math.min(CLAUDE_EFFORTS.length - 1, i + 1));
192
+ soundRef.current.playNavigate();
193
+ return;
194
+ }
195
+ if (key.return) {
196
+ onPersist({
197
+ ...configRef.current,
198
+ claudeEffort: CLAUDE_EFFORTS[effortIndex].id
199
+ });
200
+ setStep("menu");
201
+ }
202
+ if (key.escape)
203
+ setStep("menu");
204
+ return;
205
+ }
107
206
  if (step === "goal") {
108
207
  const num = Number(input);
109
208
  if (num >= 1 && num <= 9) {
@@ -122,9 +221,36 @@ export function SettingsScreen({ config, sound, onPersist, onBack }) {
122
221
  isZh ? "Esc 返回" : "esc back"
123
222
  ]) })] }));
124
223
  }
224
+ if (step === "model") {
225
+ const modelItems = CLAUDE_MODELS.map((m) => ({ id: m.id || "default", label: m.label }));
226
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "设置 · 题目模型" : "Settings · Quiz model" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectList, { items: modelItems, selectedIndex: modelIndex, showIndex: true }) }), _jsx(StatusBar, { status: isZh ? "模型" : "Model", hints: hintLine([
227
+ isZh ? "↑↓ 选择" : "↑↓ select",
228
+ isZh ? "Enter 确认" : "enter confirm",
229
+ isZh ? "Esc 返回" : "esc back"
230
+ ]) })] }));
231
+ }
232
+ if (step === "effort") {
233
+ const effortItems = CLAUDE_EFFORTS.map((e) => ({
234
+ id: e.id,
235
+ label: e.label
236
+ }));
237
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "设置 · 题目 Effort" : "Settings · Quiz effort" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectList, { items: effortItems, selectedIndex: effortIndex, showIndex: true }) }), _jsx(StatusBar, { status: isZh ? "Effort" : "Effort", hints: hintLine([
238
+ isZh ? "↑↓ 选择" : "↑↓ select",
239
+ isZh ? "Enter 确认" : "enter confirm",
240
+ isZh ? "Esc 返回" : "esc back"
241
+ ]) })] }));
242
+ }
125
243
  if (step === "goal") {
126
244
  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
245
  }
246
+ if (step === "confirm-reset") {
247
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(AppHeader, { title: "QuizMe", subtitle: isZh ? "设置 · 清除设置和缓存" : "Settings · Clear settings & cache" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: isZh
248
+ ? "将清除所有设置、统计、画像与复习队列,且不可恢复。"
249
+ : "This will erase all settings, stats, profile signals, and the review queue. This cannot be undone." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.inactive, children: isZh ? "按 Y 确认清除,N 取消" : "Press Y to confirm, N to cancel" }) })] }), _jsx(StatusBar, { status: isZh ? "确认" : "Confirm", hints: hintLine([
250
+ isZh ? "Y 确认" : "Y confirm",
251
+ isZh ? "N/Esc 取消" : "N/esc cancel"
252
+ ]) })] }));
253
+ }
128
254
  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
255
  isZh ? "↑↓ 选择" : "↑↓ select",
130
256
  isZh ? "Enter 确认/切换" : "enter confirm/toggle",