@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.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/cli/config.js +37 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/session.js +10 -0
- package/dist/generation/dedupe.js +11 -0
- package/dist/generation/schema.js +50 -0
- package/dist/generation/validator.js +134 -0
- package/dist/platform/fs.js +17 -0
- package/dist/platform/paths.js +27 -0
- package/dist/providers/claudeAgent.js +295 -0
- package/dist/sources/claudeSession.js +112 -0
- package/dist/sources/repository.js +43 -0
- package/dist/sources/topic.js +7 -0
- package/dist/storage/index.js +18 -0
- package/dist/storage/sqlite.js +294 -0
- package/dist/types.js +1 -0
- package/dist/ui/App.js +90 -0
- package/dist/ui/components/AppHeader.js +6 -0
- package/dist/ui/components/Clawd.js +37 -0
- package/dist/ui/components/Divider.js +6 -0
- package/dist/ui/components/Feed.js +32 -0
- package/dist/ui/components/FeedColumn.js +9 -0
- package/dist/ui/components/SelectList.js +13 -0
- package/dist/ui/components/StatusBar.js +9 -0
- package/dist/ui/components/TextInput.js +23 -0
- package/dist/ui/components/WelcomeBanner.js +79 -0
- package/dist/ui/formatters.js +66 -0
- package/dist/ui/logoLayout.js +30 -0
- package/dist/ui/renderApp.js +25 -0
- package/dist/ui/screens/HomeScreen.js +57 -0
- package/dist/ui/screens/InfoScreen.js +16 -0
- package/dist/ui/screens/QuizScreen.js +344 -0
- package/dist/ui/screens/SettingsScreen.js +133 -0
- package/dist/ui/screens/SetupScreen.js +73 -0
- package/dist/ui/sound.js +101 -0
- package/dist/ui/terminal.js +37 -0
- package/dist/ui/textUtils.js +54 -0
- package/dist/ui/theme.js +28 -0
- package/dist/version.js +3 -0
- 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
|
+
}
|