@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.
- package/README.md +29 -2
- package/dist/cli/config.js +19 -1
- package/dist/generation/prompts/quiz.js +57 -0
- package/dist/generation/prompts/why.js +30 -0
- package/dist/generation/schema.js +1 -1
- package/dist/providers/claudeAgent.js +138 -80
- package/dist/sources/claudeSession.js +80 -12
- package/dist/storage/index.js +20 -5
- package/dist/storage/json.js +227 -0
- package/dist/ui/App.js +7 -3
- package/dist/ui/components/AppHeader.js +5 -2
- package/dist/ui/components/SelectList.js +1 -1
- package/dist/ui/components/Spinner.js +16 -0
- package/dist/ui/components/StatusBar.js +1 -2
- package/dist/ui/components/TextInput.js +7 -4
- package/dist/ui/components/WelcomeBanner.js +3 -4
- package/dist/ui/screens/QuizScreen.js +12 -12
- package/dist/ui/screens/SettingsScreen.js +127 -1
- package/dist/ui/screens/SetupScreen.js +23 -14
- package/dist/ui/theme.js +6 -3
- package/package.json +5 -8
- package/dist/platform/fs.js +0 -17
- package/dist/platform/paths.js +0 -27
- package/dist/storage/sqlite.js +0 -294
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
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 =
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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" }),
|
|
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(
|
|
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 ?
|
|
333
|
+
? isZh ? `${symbols.success} 回答正确` : `${symbols.success} Correct`
|
|
334
334
|
: isZh
|
|
335
|
-
?
|
|
336
|
-
:
|
|
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
|
-
|
|
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",
|