@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,294 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ensureDir } from "../platform/fs.js";
|
|
4
|
+
/**
|
|
5
|
+
* SQLite-backed store. Uses `better-sqlite3` (synchronous, in-process) instead
|
|
6
|
+
* of shelling out to the `sqlite3` CLI — eliminates a subprocess spawn per
|
|
7
|
+
* query and removes the external binary dependency.
|
|
8
|
+
*/
|
|
9
|
+
export class SqliteStore {
|
|
10
|
+
dbPath;
|
|
11
|
+
db;
|
|
12
|
+
stmtCache = new Map();
|
|
13
|
+
constructor(dbPath) {
|
|
14
|
+
this.dbPath = dbPath;
|
|
15
|
+
ensureDir(path.dirname(dbPath));
|
|
16
|
+
this.db = new Database(dbPath);
|
|
17
|
+
this.db.pragma("busy_timeout = 5000");
|
|
18
|
+
}
|
|
19
|
+
stmt(sql) {
|
|
20
|
+
let prepared = this.stmtCache.get(sql);
|
|
21
|
+
if (!prepared) {
|
|
22
|
+
prepared = this.db.prepare(sql);
|
|
23
|
+
this.stmtCache.set(sql, prepared);
|
|
24
|
+
}
|
|
25
|
+
return prepared;
|
|
26
|
+
}
|
|
27
|
+
init() {
|
|
28
|
+
this.db.exec(`
|
|
29
|
+
PRAGMA busy_timeout=5000;
|
|
30
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
31
|
+
key TEXT PRIMARY KEY,
|
|
32
|
+
value_json TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS questions (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
source_type TEXT NOT NULL,
|
|
37
|
+
topic TEXT NOT NULL,
|
|
38
|
+
difficulty INTEGER NOT NULL,
|
|
39
|
+
payload_json TEXT NOT NULL,
|
|
40
|
+
created_at TEXT NOT NULL
|
|
41
|
+
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS attempts (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
question_id TEXT NOT NULL,
|
|
45
|
+
selected TEXT NOT NULL,
|
|
46
|
+
correct INTEGER NOT NULL,
|
|
47
|
+
duration_ms INTEGER NOT NULL,
|
|
48
|
+
tags_json TEXT NOT NULL,
|
|
49
|
+
created_at TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS profile_signals (
|
|
52
|
+
tag TEXT PRIMARY KEY,
|
|
53
|
+
score REAL NOT NULL,
|
|
54
|
+
confidence REAL NOT NULL,
|
|
55
|
+
trend TEXT NOT NULL,
|
|
56
|
+
correct_count INTEGER NOT NULL,
|
|
57
|
+
wrong_count INTEGER NOT NULL,
|
|
58
|
+
updated_at TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS profile_preferences (
|
|
61
|
+
tag TEXT PRIMARY KEY,
|
|
62
|
+
kind TEXT NOT NULL,
|
|
63
|
+
note TEXT NOT NULL,
|
|
64
|
+
updated_at TEXT NOT NULL
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS why_threads (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
question_id TEXT NOT NULL,
|
|
69
|
+
turns_json TEXT NOT NULL,
|
|
70
|
+
updated_at TEXT NOT NULL
|
|
71
|
+
);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS review_items (
|
|
73
|
+
question_id TEXT PRIMARY KEY,
|
|
74
|
+
resolved INTEGER NOT NULL,
|
|
75
|
+
last_result INTEGER NOT NULL,
|
|
76
|
+
updated_at TEXT NOT NULL
|
|
77
|
+
);
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
setConfig(key, value) {
|
|
81
|
+
this.stmt(`INSERT INTO config(key, value_json)
|
|
82
|
+
VALUES (@key, @value)
|
|
83
|
+
ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json;`).run({ key, value: JSON.stringify(value) });
|
|
84
|
+
}
|
|
85
|
+
getConfig(key, fallback = null) {
|
|
86
|
+
const row = this.stmt(`SELECT value_json FROM config WHERE key=@key LIMIT 1;`).get({ key });
|
|
87
|
+
return row ? JSON.parse(row.value_json) : fallback;
|
|
88
|
+
}
|
|
89
|
+
saveQuestion(question, sourceType) {
|
|
90
|
+
this.stmt(`INSERT OR REPLACE INTO questions(id, source_type, topic, difficulty, payload_json, created_at)
|
|
91
|
+
VALUES (@id, @sourceType, @topic, @difficulty, @payload, datetime('now'));`).run({
|
|
92
|
+
id: question.id,
|
|
93
|
+
sourceType,
|
|
94
|
+
topic: question.topic,
|
|
95
|
+
difficulty: Number(question.difficulty || 1),
|
|
96
|
+
payload: JSON.stringify(question)
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
listRecentQuestions(limit = 20) {
|
|
100
|
+
const rows = this.stmt(`SELECT payload_json FROM questions
|
|
101
|
+
ORDER BY datetime(created_at) DESC
|
|
102
|
+
LIMIT @limit;`).all({ limit: Number(limit) });
|
|
103
|
+
return rows.map((row) => JSON.parse(row.payload_json));
|
|
104
|
+
}
|
|
105
|
+
recordAttempt({ questionId, selected, correct, durationMs, tags }) {
|
|
106
|
+
this.stmt(`INSERT INTO attempts(question_id, selected, correct, duration_ms, tags_json, created_at)
|
|
107
|
+
VALUES (@questionId, @selected, @correct, @durationMs, @tags, datetime('now'));`).run({
|
|
108
|
+
questionId,
|
|
109
|
+
selected,
|
|
110
|
+
correct: correct ? 1 : 0,
|
|
111
|
+
durationMs: Number(durationMs),
|
|
112
|
+
tags: JSON.stringify(tags || [])
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
updateSignal(tag, wasCorrect) {
|
|
116
|
+
const delta = wasCorrect ? 0.08 : -0.1;
|
|
117
|
+
const trend = delta > 0 ? "up" : "down";
|
|
118
|
+
// Single atomic UPSERT: read current values via subquery, clamp, write back.
|
|
119
|
+
this.stmt(`INSERT INTO profile_signals(tag, score, confidence, trend, correct_count, wrong_count, updated_at)
|
|
120
|
+
VALUES (
|
|
121
|
+
@tag,
|
|
122
|
+
MIN(0.95, MAX(0.05, COALESCE((SELECT score FROM profile_signals WHERE tag=@tag), 0.5) + @delta)),
|
|
123
|
+
MIN(0.98, COALESCE((SELECT confidence FROM profile_signals WHERE tag=@tag), 0.2) + 0.08),
|
|
124
|
+
@trend,
|
|
125
|
+
COALESCE((SELECT correct_count FROM profile_signals WHERE tag=@tag), 0) + @correctInc,
|
|
126
|
+
COALESCE((SELECT wrong_count FROM profile_signals WHERE tag=@tag), 0) + @wrongInc,
|
|
127
|
+
datetime('now')
|
|
128
|
+
)
|
|
129
|
+
ON CONFLICT(tag) DO UPDATE SET
|
|
130
|
+
score=excluded.score,
|
|
131
|
+
confidence=excluded.confidence,
|
|
132
|
+
trend=excluded.trend,
|
|
133
|
+
correct_count=excluded.correct_count,
|
|
134
|
+
wrong_count=excluded.wrong_count,
|
|
135
|
+
updated_at=excluded.updated_at;`).run({
|
|
136
|
+
tag,
|
|
137
|
+
delta,
|
|
138
|
+
trend,
|
|
139
|
+
correctInc: wasCorrect ? 1 : 0,
|
|
140
|
+
wrongInc: wasCorrect ? 0 : 1
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
getProfileSignals() {
|
|
144
|
+
const rows = this.stmt(`SELECT tag, score, confidence, trend, correct_count, wrong_count
|
|
145
|
+
FROM profile_signals
|
|
146
|
+
ORDER BY score DESC, confidence DESC;`).all();
|
|
147
|
+
return rows.map((row) => ({
|
|
148
|
+
tag: row.tag,
|
|
149
|
+
score: row.score,
|
|
150
|
+
confidence: row.confidence,
|
|
151
|
+
trend: row.trend,
|
|
152
|
+
correctCount: row.correct_count,
|
|
153
|
+
wrongCount: row.wrong_count
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
getStats() {
|
|
157
|
+
const counts = this.stmt(`SELECT
|
|
158
|
+
(SELECT COUNT(*) FROM attempts) AS total,
|
|
159
|
+
(SELECT COUNT(*) FROM attempts WHERE correct=1) AS correct,
|
|
160
|
+
(SELECT COUNT(*) FROM attempts WHERE date(created_at)=date('now','localtime')) AS today,
|
|
161
|
+
(SELECT COUNT(*) FROM review_items WHERE resolved=0) AS reviewPending,
|
|
162
|
+
(SELECT COUNT(*) FROM why_threads) AS whyCount;`).get();
|
|
163
|
+
const dayList = this.stmt(`SELECT DISTINCT date(created_at, 'localtime') AS day
|
|
164
|
+
FROM attempts
|
|
165
|
+
ORDER BY day DESC;`).all().map((row) => row.day);
|
|
166
|
+
const weekRows = this.stmt(`SELECT date(created_at, 'localtime') AS day, COUNT(*) AS count
|
|
167
|
+
FROM attempts
|
|
168
|
+
WHERE datetime(created_at) >= datetime('now', '-6 day', 'localtime')
|
|
169
|
+
GROUP BY day
|
|
170
|
+
ORDER BY day;`).all().map((row) => [row.day, String(row.count)]);
|
|
171
|
+
const longest = this.stmt(`WITH days AS (
|
|
172
|
+
SELECT DISTINCT date(created_at, 'localtime') AS day FROM attempts
|
|
173
|
+
),
|
|
174
|
+
streaks AS (
|
|
175
|
+
SELECT
|
|
176
|
+
day,
|
|
177
|
+
julianday(day) - ROW_NUMBER() OVER (ORDER BY day) AS grp
|
|
178
|
+
FROM days
|
|
179
|
+
)
|
|
180
|
+
SELECT COALESCE(MAX(cnt), 0) AS cnt
|
|
181
|
+
FROM (SELECT COUNT(*) AS cnt FROM streaks GROUP BY grp);`).get();
|
|
182
|
+
const attemptsTotal = Number(counts?.total ?? 0);
|
|
183
|
+
const attemptsCorrect = Number(counts?.correct ?? 0);
|
|
184
|
+
const todayCount = Number(counts?.today ?? 0);
|
|
185
|
+
const reviewPending = Number(counts?.reviewPending ?? 0);
|
|
186
|
+
const whyCount = Number(counts?.whyCount ?? 0);
|
|
187
|
+
let currentStreak = 0;
|
|
188
|
+
let cursor = new Date();
|
|
189
|
+
for (const day of dayList) {
|
|
190
|
+
const expected = cursor.toISOString().slice(0, 10);
|
|
191
|
+
if (day !== expected) {
|
|
192
|
+
if (currentStreak === 0) {
|
|
193
|
+
cursor.setDate(cursor.getDate() - 1);
|
|
194
|
+
const yesterday = cursor.toISOString().slice(0, 10);
|
|
195
|
+
if (day !== yesterday) {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
currentStreak += 1;
|
|
204
|
+
cursor.setDate(cursor.getDate() - 1);
|
|
205
|
+
}
|
|
206
|
+
const xp = attemptsCorrect * 10 + (attemptsTotal - attemptsCorrect) * 4 + whyCount * 3;
|
|
207
|
+
const level = Math.floor(xp / 100) + 1;
|
|
208
|
+
return {
|
|
209
|
+
attemptsTotal,
|
|
210
|
+
attemptsCorrect,
|
|
211
|
+
todayCount,
|
|
212
|
+
reviewPending,
|
|
213
|
+
whyCount,
|
|
214
|
+
currentStreak,
|
|
215
|
+
longestStreak: Number(longest?.cnt ?? 0),
|
|
216
|
+
xp,
|
|
217
|
+
level,
|
|
218
|
+
accuracy: attemptsTotal ? attemptsCorrect / attemptsTotal : 0,
|
|
219
|
+
weekRows
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
upsertReviewItem(questionId, resolved) {
|
|
223
|
+
this.stmt(`INSERT INTO review_items(question_id, resolved, last_result, updated_at)
|
|
224
|
+
VALUES (@questionId, @resolved, @lastResult, datetime('now'))
|
|
225
|
+
ON CONFLICT(question_id) DO UPDATE SET
|
|
226
|
+
resolved=excluded.resolved,
|
|
227
|
+
last_result=excluded.last_result,
|
|
228
|
+
updated_at=excluded.updated_at;`).run({ questionId, resolved: resolved ? 1 : 0, lastResult: resolved ? 1 : 0 });
|
|
229
|
+
}
|
|
230
|
+
listReviewQuestionIds(limit = 5) {
|
|
231
|
+
const rows = this.stmt(`SELECT question_id FROM review_items
|
|
232
|
+
WHERE resolved=0
|
|
233
|
+
ORDER BY datetime(updated_at) DESC
|
|
234
|
+
LIMIT @limit;`).all({ limit: Number(limit) });
|
|
235
|
+
return rows.map((row) => row.question_id);
|
|
236
|
+
}
|
|
237
|
+
appendWhyThread(questionId, turns) {
|
|
238
|
+
this.stmt(`INSERT INTO why_threads(question_id, turns_json, updated_at)
|
|
239
|
+
VALUES (@questionId, @turns, datetime('now'));`).run({ questionId, turns: JSON.stringify(turns) });
|
|
240
|
+
}
|
|
241
|
+
listProfilePreferences() {
|
|
242
|
+
const rows = this.stmt(`SELECT tag, kind, note, updated_at
|
|
243
|
+
FROM profile_preferences
|
|
244
|
+
ORDER BY datetime(updated_at) DESC;`).all();
|
|
245
|
+
return rows.map((row) => ({
|
|
246
|
+
tag: row.tag,
|
|
247
|
+
kind: row.kind,
|
|
248
|
+
note: row.note || undefined,
|
|
249
|
+
updatedAt: row.updated_at
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
upsertProfilePreference(pref) {
|
|
253
|
+
this.stmt(`INSERT INTO profile_preferences(tag, kind, note, updated_at)
|
|
254
|
+
VALUES (@tag, @kind, @note, datetime('now'))
|
|
255
|
+
ON CONFLICT(tag) DO UPDATE SET
|
|
256
|
+
kind=excluded.kind,
|
|
257
|
+
note=excluded.note,
|
|
258
|
+
updated_at=excluded.updated_at;`).run({ tag: pref.tag, kind: pref.kind, note: pref.note ?? "" });
|
|
259
|
+
}
|
|
260
|
+
deleteProfilePreference(tag) {
|
|
261
|
+
this.stmt(`DELETE FROM profile_preferences WHERE tag=@tag;`).run({ tag });
|
|
262
|
+
}
|
|
263
|
+
clearProfilePreferences() {
|
|
264
|
+
this.db.exec("DELETE FROM profile_preferences;");
|
|
265
|
+
}
|
|
266
|
+
clearAttemptHistory() {
|
|
267
|
+
this.db.exec(`
|
|
268
|
+
DELETE FROM attempts;
|
|
269
|
+
DELETE FROM review_items;
|
|
270
|
+
`);
|
|
271
|
+
}
|
|
272
|
+
clearProfileSignals() {
|
|
273
|
+
this.db.exec("DELETE FROM profile_signals;");
|
|
274
|
+
}
|
|
275
|
+
clearQuestionBank() {
|
|
276
|
+
this.db.exec("DELETE FROM questions;");
|
|
277
|
+
}
|
|
278
|
+
clearAll() {
|
|
279
|
+
this.db.exec(`
|
|
280
|
+
DELETE FROM attempts;
|
|
281
|
+
DELETE FROM questions;
|
|
282
|
+
DELETE FROM profile_signals;
|
|
283
|
+
DELETE FROM profile_preferences;
|
|
284
|
+
DELETE FROM why_threads;
|
|
285
|
+
DELETE FROM review_items;
|
|
286
|
+
`);
|
|
287
|
+
}
|
|
288
|
+
clearWhyThreads() {
|
|
289
|
+
this.db.exec("DELETE FROM why_threads;");
|
|
290
|
+
}
|
|
291
|
+
close() {
|
|
292
|
+
this.db.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { HomeScreen } from "./screens/HomeScreen.js";
|
|
4
|
+
import { QuizScreen } from "./screens/QuizScreen.js";
|
|
5
|
+
import { SettingsScreen } from "./screens/SettingsScreen.js";
|
|
6
|
+
import { InfoScreen } from "./screens/InfoScreen.js";
|
|
7
|
+
import { formatProfile, formatStats } from "./formatters.js";
|
|
8
|
+
import { createSoundPlayer } from "./sound.js";
|
|
9
|
+
export function App({ store, initialConfig, resolveSource, onExit }) {
|
|
10
|
+
const [config, setConfig] = useState(initialConfig);
|
|
11
|
+
const [screen, setScreen] = useState("home");
|
|
12
|
+
const [quizProps, setQuizProps] = useState(null);
|
|
13
|
+
const stats = store.getStats();
|
|
14
|
+
const isZh = config.language === "zh-CN";
|
|
15
|
+
const sound = useMemo(() => createSoundPlayer(config), [config]);
|
|
16
|
+
const homeSource = useMemo(() => {
|
|
17
|
+
try {
|
|
18
|
+
return resolveSource({ _: [] });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {
|
|
22
|
+
sourceType: "claude_session",
|
|
23
|
+
title: "",
|
|
24
|
+
summary: ""
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}, [resolveSource]);
|
|
28
|
+
function startQuiz({ source, questionsOverride = null, mode = "mixed" }) {
|
|
29
|
+
setQuizProps({ source, questionsOverride, mode });
|
|
30
|
+
setScreen("quiz");
|
|
31
|
+
}
|
|
32
|
+
function handleHomeAction(action) {
|
|
33
|
+
if (action === "quiz") {
|
|
34
|
+
startQuiz({ source: resolveSource({ _: [] }) });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (action === "review") {
|
|
38
|
+
const ids = new Set(store.listReviewQuestionIds(5));
|
|
39
|
+
const questions = store.listRecentQuestions(50).filter((item) => ids.has(item.id));
|
|
40
|
+
if (!questions.length) {
|
|
41
|
+
setScreen("review-empty");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
startQuiz({
|
|
45
|
+
source: { sourceType: "manual", title: "review", summary: "Review incorrect questions." },
|
|
46
|
+
questionsOverride: questions,
|
|
47
|
+
mode: "review"
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (action === "stats") {
|
|
52
|
+
setScreen("stats");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (action === "profile") {
|
|
56
|
+
setScreen("profile");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (action === "settings") {
|
|
60
|
+
setScreen("settings");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (action === "exit") {
|
|
64
|
+
onExit();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (screen === "quiz" && quizProps) {
|
|
68
|
+
return (_jsx(QuizScreen, { store: store, config: config, sound: sound, source: quizProps.source, questionsOverride: quizProps.questionsOverride, mode: quizProps.mode, onDone: () => {
|
|
69
|
+
setQuizProps(null);
|
|
70
|
+
setScreen("home");
|
|
71
|
+
} }));
|
|
72
|
+
}
|
|
73
|
+
if (screen === "settings") {
|
|
74
|
+
const persistConfig = (next) => {
|
|
75
|
+
store.setConfig("user", next);
|
|
76
|
+
setConfig(next);
|
|
77
|
+
};
|
|
78
|
+
return (_jsx(SettingsScreen, { config: config, sound: sound, onPersist: persistConfig, onBack: () => setScreen("home") }));
|
|
79
|
+
}
|
|
80
|
+
if (screen === "stats") {
|
|
81
|
+
return (_jsx(InfoScreen, { title: "QuizMe Stats", lines: formatStats(store), isZh: isZh, onBack: () => setScreen("home") }));
|
|
82
|
+
}
|
|
83
|
+
if (screen === "profile") {
|
|
84
|
+
return (_jsx(InfoScreen, { title: "QuizMe Profile", lines: formatProfile(store), isZh: isZh, onBack: () => setScreen("home") }));
|
|
85
|
+
}
|
|
86
|
+
if (screen === "review-empty") {
|
|
87
|
+
return (_jsx(InfoScreen, { title: isZh ? "复习" : "Review", lines: [isZh ? "暂无待复习题目。" : "No pending review items."], isZh: isZh, onBack: () => setScreen("home") }));
|
|
88
|
+
}
|
|
89
|
+
return (_jsx(HomeScreen, { stats: stats, config: config, source: homeSource, sound: sound, onAction: handleHomeAction }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
const POSES = {
|
|
5
|
+
default: {
|
|
6
|
+
r1L: " ▐",
|
|
7
|
+
r1E: "▛███▜",
|
|
8
|
+
r1R: "▌",
|
|
9
|
+
r2L: "▝▜",
|
|
10
|
+
r2R: "▛▘"
|
|
11
|
+
},
|
|
12
|
+
"look-left": {
|
|
13
|
+
r1L: " ▐",
|
|
14
|
+
r1E: "▟███▟",
|
|
15
|
+
r1R: "▌",
|
|
16
|
+
r2L: "▝▜",
|
|
17
|
+
r2R: "▛▘"
|
|
18
|
+
},
|
|
19
|
+
"look-right": {
|
|
20
|
+
r1L: " ▐",
|
|
21
|
+
r1E: "▙███▙",
|
|
22
|
+
r1R: "▌",
|
|
23
|
+
r2L: "▝▜",
|
|
24
|
+
r2R: "▛▘"
|
|
25
|
+
},
|
|
26
|
+
"arms-up": {
|
|
27
|
+
r1L: "▗▟",
|
|
28
|
+
r1E: "▛███▜",
|
|
29
|
+
r1R: "▙▖",
|
|
30
|
+
r2L: " ▜",
|
|
31
|
+
r2R: "▛ "
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
export function Clawd({ pose = "default" }) {
|
|
35
|
+
const p = POSES[pose];
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.clawdBody, children: p.r1L }), _jsx(Text, { color: theme.clawdBody, backgroundColor: theme.clawdBackground, children: p.r1E }), _jsx(Text, { color: theme.clawdBody, children: p.r1R })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.clawdBody, children: p.r2L }), _jsx(Text, { color: theme.clawdBody, backgroundColor: theme.clawdBackground, children: "\u2588\u2588\u2588\u2588\u2588" }), _jsx(Text, { color: theme.clawdBody, children: p.r2R })] }), _jsxs(Text, { color: theme.clawdBody, children: [" ", "\u2598\u2598 \u259D\u259D", " "] })] }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
export function Divider({ width, color = theme.claude }) {
|
|
5
|
+
return (_jsx(Text, { color: color, dimColor: !color, children: "─".repeat(Math.max(0, width)) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { truncate } from "../textUtils.js";
|
|
4
|
+
import { theme } from "../theme.js";
|
|
5
|
+
export function calculateFeedWidth(config) {
|
|
6
|
+
const { title, lines, footer, emptyMessage } = config;
|
|
7
|
+
let maxWidth = title.length;
|
|
8
|
+
if (lines.length === 0 && emptyMessage) {
|
|
9
|
+
maxWidth = Math.max(maxWidth, emptyMessage.length);
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
const gap = 2;
|
|
13
|
+
const maxTimestampWidth = Math.max(0, ...lines.map((line) => (line.timestamp ? line.timestamp.length : 0)));
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0;
|
|
16
|
+
const lineWidth = line.text.length + (timestampWidth > 0 ? timestampWidth + gap : 0);
|
|
17
|
+
maxWidth = Math.max(maxWidth, lineWidth);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (footer) {
|
|
21
|
+
maxWidth = Math.max(maxWidth, footer.length);
|
|
22
|
+
}
|
|
23
|
+
return maxWidth;
|
|
24
|
+
}
|
|
25
|
+
export function Feed({ config, actualWidth }) {
|
|
26
|
+
const { title, lines, footer, emptyMessage } = config;
|
|
27
|
+
const maxTimestampWidth = Math.max(0, ...lines.map((line) => (line.timestamp ? line.timestamp.length : 0)));
|
|
28
|
+
return (_jsxs(Box, { flexDirection: "column", width: actualWidth, children: [_jsx(Text, { bold: true, color: theme.claude, children: title }), lines.length === 0 && emptyMessage ? (_jsx(Text, { dimColor: true, children: truncate(emptyMessage, actualWidth) })) : (_jsxs(_Fragment, { children: [lines.map((line, index) => {
|
|
29
|
+
const textWidth = Math.max(10, actualWidth - (maxTimestampWidth > 0 ? maxTimestampWidth + 2 : 0));
|
|
30
|
+
return (_jsxs(Text, { children: [maxTimestampWidth > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: (line.timestamp || "").padEnd(maxTimestampWidth) }), " "] })), _jsx(Text, { children: truncate(line.text, textWidth) })] }, index));
|
|
31
|
+
}), footer && (_jsx(Text, { dimColor: true, italic: true, children: truncate(footer, actualWidth) }))] }))] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box } from "ink";
|
|
3
|
+
import { calculateFeedWidth, Feed } from "./Feed.js";
|
|
4
|
+
import { Divider } from "./Divider.js";
|
|
5
|
+
export function FeedColumn({ feeds, maxWidth }) {
|
|
6
|
+
const maxOfAllFeeds = Math.max(...feeds.map(calculateFeedWidth));
|
|
7
|
+
const actualWidth = Math.min(maxOfAllFeeds, maxWidth);
|
|
8
|
+
return (_jsx(Box, { flexDirection: "column", children: feeds.map((feed, index) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Feed, { config: feed, actualWidth: actualWidth }), index < feeds.length - 1 && (_jsx(Divider, { width: actualWidth }))] }, index))) }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { symbols, theme } from "../theme.js";
|
|
4
|
+
export function SelectList({ items, selectedIndex, showIndex = false }) {
|
|
5
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item, index) => {
|
|
6
|
+
const selected = index === selectedIndex;
|
|
7
|
+
const prefix = showIndex ? `${index + 1}. ` : selected ? `${symbols.pointer} ` : `${symbols.pointerIdle} `;
|
|
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));
|
|
10
|
+
}
|
|
11
|
+
return (_jsx(Box, { children: _jsxs(Text, { color: theme.text, children: [prefix, item.label] }) }, item.id ?? item.label));
|
|
12
|
+
}) }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
export function StatusBar({ status, hints }) {
|
|
5
|
+
if (!status && !hints) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
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] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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 = "" }) {
|
|
5
|
+
useInput((input, key) => {
|
|
6
|
+
if (key.return) {
|
|
7
|
+
onSubmit(value);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (key.backspace || key.delete) {
|
|
11
|
+
onChange(value.slice(0, -1));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (key.escape) {
|
|
15
|
+
onSubmit("");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (input && !key.ctrl && !key.meta) {
|
|
19
|
+
onChange(value + input);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
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: " " })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
|
+
import { Clawd } from "./Clawd.js";
|
|
4
|
+
import { FeedColumn } from "./FeedColumn.js";
|
|
5
|
+
import { calculateLayoutDimensions, calculateOptimalLeftWidth, getLayoutMode } from "../logoLayout.js";
|
|
6
|
+
import { truncate, wrapText } from "../textUtils.js";
|
|
7
|
+
import { theme } from "../theme.js";
|
|
8
|
+
import { formatSourceMode } from "../formatters.js";
|
|
9
|
+
import { QUIZME_VERSION } from "../../version.js";
|
|
10
|
+
function formatLevelLabel(level, isZh) {
|
|
11
|
+
if (level === "mid") {
|
|
12
|
+
return isZh ? "中级" : "mid-level";
|
|
13
|
+
}
|
|
14
|
+
return level;
|
|
15
|
+
}
|
|
16
|
+
function buildFeeds(isZh, stats, config) {
|
|
17
|
+
const accuracy = `${(stats.accuracy * 100).toFixed(0)}%`;
|
|
18
|
+
const levelLabel = formatLevelLabel(config.level, isZh);
|
|
19
|
+
const statsFeed = {
|
|
20
|
+
title: isZh ? "统计" : "Stats",
|
|
21
|
+
lines: isZh
|
|
22
|
+
? [
|
|
23
|
+
{
|
|
24
|
+
text: `连续 ${stats.currentStreak} 天 · 最佳 ${stats.longestStreak} 天`
|
|
25
|
+
},
|
|
26
|
+
{ text: `今日 ${stats.todayCount} 题 · 准确率 ${accuracy}` },
|
|
27
|
+
{ text: `复习队列 ${stats.reviewPending} · XP ${stats.xp}` }
|
|
28
|
+
]
|
|
29
|
+
: [
|
|
30
|
+
{
|
|
31
|
+
text: `Streak ${stats.currentStreak}d · best ${stats.longestStreak}d`
|
|
32
|
+
},
|
|
33
|
+
{ text: `Today ${stats.todayCount} · accuracy ${accuracy}` },
|
|
34
|
+
{ text: `Review ${stats.reviewPending} · XP ${stats.xp}` }
|
|
35
|
+
]
|
|
36
|
+
};
|
|
37
|
+
const configFeed = {
|
|
38
|
+
title: isZh ? "配置" : "Config",
|
|
39
|
+
lines: isZh
|
|
40
|
+
? [
|
|
41
|
+
{ text: `难度 ${levelLabel}` },
|
|
42
|
+
{ text: `语言 ${config.language}` },
|
|
43
|
+
{ text: `每日目标 ${config.dailyGoal} 题` },
|
|
44
|
+
{ text: `音效 ${config.soundEnabled ? "开" : "关"}` }
|
|
45
|
+
]
|
|
46
|
+
: [
|
|
47
|
+
{ text: `Level ${levelLabel}` },
|
|
48
|
+
{ text: `Language ${config.language}` },
|
|
49
|
+
{ text: `Daily goal ${config.dailyGoal}` },
|
|
50
|
+
{ text: `Sound ${config.soundEnabled ? "on" : "off"}` }
|
|
51
|
+
]
|
|
52
|
+
};
|
|
53
|
+
return [statsFeed, configFeed];
|
|
54
|
+
}
|
|
55
|
+
const CLAWD_AND_GAP = 12;
|
|
56
|
+
export function WelcomeBanner({ config, stats, source }) {
|
|
57
|
+
const { stdout } = useStdout();
|
|
58
|
+
const columns = stdout.columns || 80;
|
|
59
|
+
const isZh = config.language === "zh-CN";
|
|
60
|
+
const layoutMode = getLayoutMode(columns);
|
|
61
|
+
const tagline = isZh
|
|
62
|
+
? "将开发上下文转化为面试风格选择题"
|
|
63
|
+
: "Turn your dev context into interview-style quizzes";
|
|
64
|
+
const modeLine = isZh
|
|
65
|
+
? `当前模式:${formatSourceMode(source, isZh)}`
|
|
66
|
+
: `Source: ${formatSourceMode(source, isZh)}`;
|
|
67
|
+
const titleLine = `QuizMe v${QUIZME_VERSION}`;
|
|
68
|
+
const initialLeftWidth = calculateOptimalLeftWidth(titleLine, tagline, modeLine);
|
|
69
|
+
const textWidth = Math.max(12, initialLeftWidth - CLAWD_AND_GAP);
|
|
70
|
+
const taglineLines = wrapText(tagline, textWidth);
|
|
71
|
+
const modeLines = wrapText(modeLine, textWidth);
|
|
72
|
+
const optimalLeftWidth = calculateOptimalLeftWidth(titleLine, ...taglineLines, ...modeLines);
|
|
73
|
+
const { leftWidth, rightWidth, totalWidth } = calculateLayoutDimensions(columns, layoutMode, optimalLeftWidth + CLAWD_AND_GAP);
|
|
74
|
+
const feeds = buildFeeds(isZh, stats, config);
|
|
75
|
+
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}`)))] })] }));
|
|
77
|
+
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] })) })] }));
|
|
79
|
+
}
|