@jiy/quizme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/cli/config.js +37 -0
  4. package/dist/cli/index.js +64 -0
  5. package/dist/cli/session.js +10 -0
  6. package/dist/generation/dedupe.js +11 -0
  7. package/dist/generation/schema.js +50 -0
  8. package/dist/generation/validator.js +134 -0
  9. package/dist/platform/fs.js +17 -0
  10. package/dist/platform/paths.js +27 -0
  11. package/dist/providers/claudeAgent.js +295 -0
  12. package/dist/sources/claudeSession.js +112 -0
  13. package/dist/sources/repository.js +43 -0
  14. package/dist/sources/topic.js +7 -0
  15. package/dist/storage/index.js +18 -0
  16. package/dist/storage/sqlite.js +294 -0
  17. package/dist/types.js +1 -0
  18. package/dist/ui/App.js +90 -0
  19. package/dist/ui/components/AppHeader.js +6 -0
  20. package/dist/ui/components/Clawd.js +37 -0
  21. package/dist/ui/components/Divider.js +6 -0
  22. package/dist/ui/components/Feed.js +32 -0
  23. package/dist/ui/components/FeedColumn.js +9 -0
  24. package/dist/ui/components/SelectList.js +13 -0
  25. package/dist/ui/components/StatusBar.js +9 -0
  26. package/dist/ui/components/TextInput.js +23 -0
  27. package/dist/ui/components/WelcomeBanner.js +79 -0
  28. package/dist/ui/formatters.js +66 -0
  29. package/dist/ui/logoLayout.js +30 -0
  30. package/dist/ui/renderApp.js +25 -0
  31. package/dist/ui/screens/HomeScreen.js +57 -0
  32. package/dist/ui/screens/InfoScreen.js +16 -0
  33. package/dist/ui/screens/QuizScreen.js +344 -0
  34. package/dist/ui/screens/SettingsScreen.js +133 -0
  35. package/dist/ui/screens/SetupScreen.js +73 -0
  36. package/dist/ui/sound.js +101 -0
  37. package/dist/ui/terminal.js +37 -0
  38. package/dist/ui/textUtils.js +54 -0
  39. package/dist/ui/theme.js +28 -0
  40. package/dist/version.js +3 -0
  41. package/package.json +55 -0
@@ -0,0 +1,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
+ }