saterminal 0.2.0 → 0.3.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/AGENTS.md +26 -12
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/cli.ts +899 -0
- package/src/main.ts +10 -1
- package/src/progress.ts +48 -0
- package/src/state.ts +115 -6
- package/src/tui/app.ts +51 -15
- package/src/tui/frame.ts +210 -0
- package/src/tui/input.ts +33 -2
- package/src/tui/kit.ts +0 -20
- package/src/tui/question.ts +2 -60
- package/src/tui/render.ts +74 -83
- package/src/tui/types.ts +3 -0
- package/src/types/terminal-kit.d.ts +3 -0
- package/src/types.ts +17 -0
- package/test/cli.test.ts +223 -0
- package/test/frame.test.ts +94 -0
- package/test/progress.test.ts +18 -0
- package/test/state.test.ts +73 -3
- package/src/tui/terminal.ts +0 -110
package/src/main.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { parseArgs, runCliCommand } from "./cli.ts";
|
|
2
3
|
import { runTui } from "./tui.ts";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
6
|
+
|
|
7
|
+
if (parsed.kind === "tui") {
|
|
8
|
+
await runTui();
|
|
9
|
+
} else if (parsed.kind === "review") {
|
|
10
|
+
await runTui({ mode: "review" });
|
|
11
|
+
} else {
|
|
12
|
+
process.exitCode = await runCliCommand(parsed);
|
|
13
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const partialBlocks = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] as const;
|
|
2
|
+
|
|
3
|
+
export type ProgressBar = {
|
|
4
|
+
filled: string;
|
|
5
|
+
partial: string;
|
|
6
|
+
empty: string;
|
|
7
|
+
fullCells: number;
|
|
8
|
+
emptyCells: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function progressBar(ratio: number, width: number): ProgressBar {
|
|
12
|
+
const normalized = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0;
|
|
13
|
+
const cells = Math.max(0, Math.floor(width));
|
|
14
|
+
const exact = normalized * cells;
|
|
15
|
+
const full = Math.floor(exact);
|
|
16
|
+
const partial = Math.round((exact - full) * 8);
|
|
17
|
+
|
|
18
|
+
if (cells === 0) {
|
|
19
|
+
return { filled: "", partial: "", empty: "", fullCells: 0, emptyCells: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (partial === 8 && full < cells) {
|
|
23
|
+
const fullCells = full + 1;
|
|
24
|
+
const emptyCells = cells - fullCells;
|
|
25
|
+
return {
|
|
26
|
+
filled: "█".repeat(fullCells),
|
|
27
|
+
partial: "",
|
|
28
|
+
empty: " ".repeat(emptyCells),
|
|
29
|
+
fullCells,
|
|
30
|
+
emptyCells,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const partialBlock = full < cells ? partialBlocks[partial] : "";
|
|
35
|
+
const emptyCells = cells - full - (partialBlock ? 1 : 0);
|
|
36
|
+
return {
|
|
37
|
+
filled: "█".repeat(full),
|
|
38
|
+
partial: partialBlock,
|
|
39
|
+
empty: " ".repeat(emptyCells),
|
|
40
|
+
fullCells: full,
|
|
41
|
+
emptyCells,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function progressBarText(ratio: number, width: number): string {
|
|
46
|
+
const bar = progressBar(ratio, width);
|
|
47
|
+
return `${bar.filled}${bar.partial}${bar.empty}`;
|
|
48
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join, sep } from "node:path";
|
|
4
4
|
import { defaultFocus, normalizeFocus } from "./focus.ts";
|
|
5
|
-
import type { Attempt, Focus, Outcome, SummaryRow } from "./types.ts";
|
|
5
|
+
import type { Attempt, AttemptEvent, Focus, Outcome, QuestionMeta, SummaryRow } from "./types.ts";
|
|
6
6
|
|
|
7
7
|
export function resolveStateDir(home = homedir()): string {
|
|
8
8
|
return join(home, ".saterminal", "userlocal");
|
|
@@ -23,10 +23,12 @@ export function displayStateDir(dir: string, home = homedir()): string {
|
|
|
23
23
|
|
|
24
24
|
export const stateDir = resolveStateDir();
|
|
25
25
|
export const attemptsPath = join(stateDir, "attempts.csv");
|
|
26
|
+
export const eventsPath = join(stateDir, "events.csv");
|
|
26
27
|
export const summaryPath = join(stateDir, "summary.csv");
|
|
27
28
|
export const focusPath = join(stateDir, "focus.json");
|
|
28
29
|
|
|
29
|
-
const attemptsHeader = "question_id,outcome,updated_at,elapsed_seconds";
|
|
30
|
+
const attemptsHeader = "question_id,outcome,updated_at,elapsed_seconds,difficulty,domain,domain_desc,skill,skill_desc";
|
|
31
|
+
const eventsHeader = "question_id,correct,answered_at,elapsed_seconds,difficulty,domain,domain_desc,skill,skill_desc";
|
|
30
32
|
const summaryHeader = "metric,value,updated_at";
|
|
31
33
|
|
|
32
34
|
export async function stateDirExists(dir = stateDir): Promise<boolean> {
|
|
@@ -44,6 +46,7 @@ export async function stateDirExists(dir = stateDir): Promise<boolean> {
|
|
|
44
46
|
export async function ensureStateFiles(dir = stateDir): Promise<void> {
|
|
45
47
|
await mkdir(dir, { recursive: true });
|
|
46
48
|
await ensureFile(join(dir, "attempts.csv"), `${attemptsHeader}\n`);
|
|
49
|
+
await ensureFile(join(dir, "events.csv"), `${eventsHeader}\n`);
|
|
47
50
|
await ensureFile(join(dir, "summary.csv"), `${summaryHeader}\n`);
|
|
48
51
|
await ensureFile(join(dir, "focus.json"), `${JSON.stringify(defaultFocus, null, 2)}\n`);
|
|
49
52
|
}
|
|
@@ -55,7 +58,7 @@ export async function loadAttempts(path = attemptsPath): Promise<Map<string, Att
|
|
|
55
58
|
const attempts = new Map<string, Attempt>();
|
|
56
59
|
|
|
57
60
|
for (const row of rows.slice(1)) {
|
|
58
|
-
const [question_id, outcome, updated_at, elapsed_seconds] = row;
|
|
61
|
+
const [question_id, outcome, updated_at, elapsed_seconds, difficulty, domain, domain_desc, skill, skill_desc] = row;
|
|
59
62
|
if (!question_id || !isOutcome(outcome) || !updated_at) {
|
|
60
63
|
continue;
|
|
61
64
|
}
|
|
@@ -65,6 +68,7 @@ export async function loadAttempts(path = attemptsPath): Promise<Map<string, Att
|
|
|
65
68
|
outcome,
|
|
66
69
|
updated_at,
|
|
67
70
|
elapsed_seconds: readElapsedSeconds(elapsed_seconds),
|
|
71
|
+
...optionalMetadata({ difficulty, domain, domain_desc, skill, skill_desc }),
|
|
68
72
|
});
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -75,22 +79,91 @@ export async function saveAttempts(attempts: Map<string, Attempt>, path = attemp
|
|
|
75
79
|
await mkdir(dirname(path), { recursive: true });
|
|
76
80
|
const rows = [...attempts.values()]
|
|
77
81
|
.sort((a, b) => a.updated_at.localeCompare(b.updated_at))
|
|
78
|
-
.map((attempt) => [
|
|
82
|
+
.map((attempt) => [
|
|
83
|
+
attempt.question_id,
|
|
84
|
+
attempt.outcome,
|
|
85
|
+
attempt.updated_at,
|
|
86
|
+
String(attempt.elapsed_seconds),
|
|
87
|
+
attempt.difficulty ?? "",
|
|
88
|
+
attempt.domain ?? "",
|
|
89
|
+
attempt.domain_desc ?? "",
|
|
90
|
+
attempt.skill ?? "",
|
|
91
|
+
attempt.skill_desc ?? "",
|
|
92
|
+
]);
|
|
79
93
|
|
|
80
94
|
await writeFile(path, formatCsv([attemptsHeader.split(","), ...rows]), "utf8");
|
|
81
95
|
}
|
|
82
96
|
|
|
97
|
+
export async function loadAttemptEvents(path = eventsPath): Promise<AttemptEvent[]> {
|
|
98
|
+
await ensureFile(path, `${eventsHeader}\n`);
|
|
99
|
+
const raw = await readFile(path, "utf8");
|
|
100
|
+
const rows = parseCsv(raw);
|
|
101
|
+
const events: AttemptEvent[] = [];
|
|
102
|
+
|
|
103
|
+
for (const row of rows.slice(1)) {
|
|
104
|
+
const [question_id, correct, answered_at, elapsed_seconds, difficulty, domain, domain_desc, skill, skill_desc] = row;
|
|
105
|
+
if (!question_id || !isBooleanText(correct) || !answered_at) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
events.push({
|
|
110
|
+
question_id,
|
|
111
|
+
correct: correct === "true",
|
|
112
|
+
answered_at,
|
|
113
|
+
elapsed_seconds: readElapsedSeconds(elapsed_seconds),
|
|
114
|
+
difficulty: difficulty ?? "",
|
|
115
|
+
domain: domain ?? "",
|
|
116
|
+
domain_desc: domain_desc || undefined,
|
|
117
|
+
skill: skill ?? "",
|
|
118
|
+
skill_desc: skill_desc || undefined,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return events;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function appendAttemptEvent(
|
|
126
|
+
meta: QuestionMeta,
|
|
127
|
+
correct: boolean,
|
|
128
|
+
elapsedSeconds = 0,
|
|
129
|
+
now = new Date(),
|
|
130
|
+
path = eventsPath,
|
|
131
|
+
): Promise<AttemptEvent> {
|
|
132
|
+
const event: AttemptEvent = {
|
|
133
|
+
question_id: meta.questionId,
|
|
134
|
+
correct,
|
|
135
|
+
answered_at: now.toISOString(),
|
|
136
|
+
elapsed_seconds: elapsedSeconds,
|
|
137
|
+
difficulty: meta.difficulty,
|
|
138
|
+
domain: meta.primary_class_cd,
|
|
139
|
+
domain_desc: meta.primary_class_cd_desc,
|
|
140
|
+
skill: meta.skill_cd,
|
|
141
|
+
skill_desc: meta.skill_desc,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
await ensureFile(path, `${eventsHeader}\n`);
|
|
145
|
+
await appendFile(path, formatCsv([attemptEventRow(event)]), "utf8");
|
|
146
|
+
return event;
|
|
147
|
+
}
|
|
148
|
+
|
|
83
149
|
export function recordAttempt(
|
|
84
150
|
attempts: Map<string, Attempt>,
|
|
85
151
|
questionId: string,
|
|
86
152
|
wasCorrect: boolean,
|
|
87
153
|
elapsedSeconds = 0,
|
|
88
154
|
now = new Date(),
|
|
155
|
+
meta?: QuestionMeta,
|
|
89
156
|
): Attempt {
|
|
90
157
|
const existing = attempts.get(questionId);
|
|
91
158
|
const updated_at = now.toISOString();
|
|
92
159
|
const outcome = nextOutcome(existing?.outcome, wasCorrect);
|
|
93
|
-
const attempt = {
|
|
160
|
+
const attempt = {
|
|
161
|
+
question_id: questionId,
|
|
162
|
+
outcome,
|
|
163
|
+
updated_at,
|
|
164
|
+
elapsed_seconds: elapsedSeconds,
|
|
165
|
+
...metadataFromQuestionMeta(meta),
|
|
166
|
+
};
|
|
94
167
|
attempts.set(questionId, attempt);
|
|
95
168
|
return attempt;
|
|
96
169
|
}
|
|
@@ -157,11 +230,47 @@ function isOutcome(value: string | undefined): value is Outcome {
|
|
|
157
230
|
return value === "correct" || value === "incorrect" || value === "corrected";
|
|
158
231
|
}
|
|
159
232
|
|
|
233
|
+
function isBooleanText(value: string | undefined): value is "true" | "false" {
|
|
234
|
+
return value === "true" || value === "false";
|
|
235
|
+
}
|
|
236
|
+
|
|
160
237
|
function readElapsedSeconds(value: string | undefined): number {
|
|
161
238
|
const seconds = Number(value);
|
|
162
239
|
return Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
|
|
163
240
|
}
|
|
164
241
|
|
|
242
|
+
function metadataFromQuestionMeta(meta: QuestionMeta | undefined): Partial<Attempt> {
|
|
243
|
+
if (!meta) {
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return optionalMetadata({
|
|
248
|
+
difficulty: meta.difficulty,
|
|
249
|
+
domain: meta.primary_class_cd,
|
|
250
|
+
domain_desc: meta.primary_class_cd_desc,
|
|
251
|
+
skill: meta.skill_cd,
|
|
252
|
+
skill_desc: meta.skill_desc,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function optionalMetadata(metadata: Pick<Attempt, "difficulty" | "domain" | "domain_desc" | "skill" | "skill_desc">): Partial<Attempt> {
|
|
257
|
+
return Object.fromEntries(Object.entries(metadata).filter(([, value]) => value)) as Partial<Attempt>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function attemptEventRow(event: AttemptEvent): string[] {
|
|
261
|
+
return [
|
|
262
|
+
event.question_id,
|
|
263
|
+
String(event.correct),
|
|
264
|
+
event.answered_at,
|
|
265
|
+
String(event.elapsed_seconds),
|
|
266
|
+
event.difficulty,
|
|
267
|
+
event.domain,
|
|
268
|
+
event.domain_desc ?? "",
|
|
269
|
+
event.skill,
|
|
270
|
+
event.skill_desc ?? "",
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
|
|
165
274
|
async function ensureFile(path: string, contents: string): Promise<void> {
|
|
166
275
|
try {
|
|
167
276
|
await readFile(path, "utf8");
|
package/src/tui/app.ts
CHANGED
|
@@ -1,24 +1,45 @@
|
|
|
1
1
|
import { defaultFocus } from "../focus.ts";
|
|
2
2
|
import { ensureStateFiles, loadAttempts, loadFocus, stateDirExists } from "../state.ts";
|
|
3
|
-
import { handleKey } from "./input.ts";
|
|
4
|
-
import {
|
|
5
|
-
import { term } from "./
|
|
3
|
+
import { handleKey, loadNextQuestion } from "./input.ts";
|
|
4
|
+
import { createFrameRenderer, render } from "./render.ts";
|
|
5
|
+
import { term } from "./kit.ts";
|
|
6
6
|
import type { AppState, KeyData } from "./types.ts";
|
|
7
|
+
import type { Attempt } from "../types.ts";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
export type TuiOptions = {
|
|
10
|
+
mode?: "practice" | "review";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function loadPersistedState(state: AppState, options: TuiOptions = {}): Promise<void> {
|
|
9
14
|
await ensureStateFiles();
|
|
10
15
|
state.attempts = await loadAttempts();
|
|
11
16
|
state.focus = await loadFocus();
|
|
12
17
|
state.focusIndex = 1;
|
|
13
18
|
state.focusColumn = 0;
|
|
14
19
|
state.focusRow = 1;
|
|
20
|
+
state.notice = undefined;
|
|
21
|
+
|
|
22
|
+
if (options.mode === "review") {
|
|
23
|
+
state.reviewMode = true;
|
|
24
|
+
state.reviewQuestionIds = reviewQuestionIds(state.attempts);
|
|
25
|
+
if (state.reviewQuestionIds.length === 0) {
|
|
26
|
+
state.notice = "No review queue yet. Miss or correct a question first.";
|
|
27
|
+
state.view = "history";
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await loadNextQuestion(state);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
state.view = "focus";
|
|
16
36
|
}
|
|
17
37
|
|
|
18
|
-
export async function runTui(): Promise<void> {
|
|
38
|
+
export async function runTui(options: TuiOptions = {}): Promise<void> {
|
|
19
39
|
const state: AppState = {
|
|
20
40
|
attempts: new Map(),
|
|
21
41
|
skippedIds: new Set(),
|
|
42
|
+
reviewMode: options.mode === "review",
|
|
22
43
|
focus: defaultFocus,
|
|
23
44
|
focusIndex: 1,
|
|
24
45
|
focusColumn: 0,
|
|
@@ -36,16 +57,17 @@ export async function runTui(): Promise<void> {
|
|
|
36
57
|
|
|
37
58
|
term.fullscreen(true);
|
|
38
59
|
term.hideCursor();
|
|
39
|
-
|
|
60
|
+
term.grabInput(true);
|
|
61
|
+
const renderer = createFrameRenderer();
|
|
62
|
+
renderer.clear();
|
|
40
63
|
const tick = setInterval(() => {
|
|
41
64
|
if (state.view === "practice" && !state.timerPaused) {
|
|
42
|
-
render(
|
|
65
|
+
render(renderer, state);
|
|
43
66
|
}
|
|
44
67
|
}, 1000);
|
|
45
68
|
|
|
46
69
|
const cleanup = () => {
|
|
47
70
|
clearInterval(tick);
|
|
48
|
-
doc.destroy();
|
|
49
71
|
term.grabInput(false);
|
|
50
72
|
term.hideCursor(false);
|
|
51
73
|
term.fullscreen(false);
|
|
@@ -53,11 +75,14 @@ export async function runTui(): Promise<void> {
|
|
|
53
75
|
};
|
|
54
76
|
|
|
55
77
|
process.on("SIGINT", cleanup);
|
|
56
|
-
term.on("resize", () =>
|
|
78
|
+
term.on("resize", () => {
|
|
79
|
+
renderer.clear();
|
|
80
|
+
render(renderer, state);
|
|
81
|
+
});
|
|
57
82
|
|
|
58
83
|
try {
|
|
59
84
|
if (await stateDirExists()) {
|
|
60
|
-
await loadPersistedState(state);
|
|
85
|
+
await loadPersistedState(state, options);
|
|
61
86
|
} else {
|
|
62
87
|
state.view = "setup";
|
|
63
88
|
}
|
|
@@ -66,7 +91,7 @@ export async function runTui(): Promise<void> {
|
|
|
66
91
|
state.error = error instanceof Error ? error.message : String(error);
|
|
67
92
|
}
|
|
68
93
|
|
|
69
|
-
render(
|
|
94
|
+
render(renderer, state);
|
|
70
95
|
|
|
71
96
|
term.on("key", async (name: string, _matches?: string[], data?: KeyData) => {
|
|
72
97
|
try {
|
|
@@ -77,22 +102,33 @@ export async function runTui(): Promise<void> {
|
|
|
77
102
|
|
|
78
103
|
if (state.view === "setup") {
|
|
79
104
|
if (name === "y" || name === "ENTER") {
|
|
80
|
-
await loadPersistedState(state);
|
|
105
|
+
await loadPersistedState(state, options);
|
|
81
106
|
} else if (name === "n") {
|
|
82
107
|
cleanup();
|
|
83
108
|
return;
|
|
84
109
|
}
|
|
85
110
|
|
|
86
|
-
render(
|
|
111
|
+
render(renderer, state);
|
|
87
112
|
return;
|
|
88
113
|
}
|
|
89
114
|
|
|
90
115
|
await handleKey(state, name, data);
|
|
91
|
-
render(
|
|
116
|
+
render(renderer, state);
|
|
92
117
|
} catch (error) {
|
|
93
118
|
state.view = "error";
|
|
94
119
|
state.error = error instanceof Error ? error.message : String(error);
|
|
95
|
-
render(
|
|
120
|
+
render(renderer, state);
|
|
96
121
|
}
|
|
97
122
|
});
|
|
98
123
|
}
|
|
124
|
+
|
|
125
|
+
function reviewQuestionIds(attempts: Map<string, Attempt>): string[] {
|
|
126
|
+
return [...attempts.values()]
|
|
127
|
+
.filter((attempt) => attempt.outcome === "incorrect" || attempt.outcome === "corrected")
|
|
128
|
+
.sort((a, b) => reviewPriority(a) - reviewPriority(b) || a.updated_at.localeCompare(b.updated_at))
|
|
129
|
+
.map((attempt) => attempt.question_id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function reviewPriority(attempt: Attempt): number {
|
|
133
|
+
return attempt.outcome === "incorrect" ? 0 : 1;
|
|
134
|
+
}
|
package/src/tui/frame.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { Terminal } from "terminal-kit";
|
|
2
|
+
|
|
3
|
+
export type TextAttr = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
type FrameCell = {
|
|
6
|
+
char: string;
|
|
7
|
+
attr: TextAttr;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type FrameRun = {
|
|
11
|
+
x: number;
|
|
12
|
+
text: string;
|
|
13
|
+
attr: TextAttr;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type FrameOutput = {
|
|
17
|
+
clear(): void;
|
|
18
|
+
moveTo(x: number, y: number): void;
|
|
19
|
+
eraseLineAfter(): void;
|
|
20
|
+
reset(): void;
|
|
21
|
+
write(value: string, attr?: TextAttr): void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const emptyAttr: TextAttr = {};
|
|
25
|
+
|
|
26
|
+
export class Frame {
|
|
27
|
+
readonly rows: FrameCell[][];
|
|
28
|
+
|
|
29
|
+
constructor(readonly width: number, readonly height: number) {
|
|
30
|
+
this.rows = Array.from({ length: height }, () => []);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
writeText(x: number, y: number, value: string, attr: TextAttr = {}, width = this.width - x): void {
|
|
34
|
+
if (y < 0 || y >= this.height || x >= this.width || width <= 0) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const start = Math.max(0, x);
|
|
39
|
+
const content = truncate(value, width);
|
|
40
|
+
if (!content) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const row = this.rows[y];
|
|
45
|
+
const chars = [...content].slice(Math.max(0, -x), Math.max(0, -x) + this.width - start);
|
|
46
|
+
for (const [offset, char] of chars.entries()) {
|
|
47
|
+
row[start + offset] = { char, attr };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
rowRuns(y: number): FrameRun[] {
|
|
52
|
+
const row = this.rows[y] ?? [];
|
|
53
|
+
const runs: FrameRun[] = [];
|
|
54
|
+
let x = 0;
|
|
55
|
+
|
|
56
|
+
while (x < this.width) {
|
|
57
|
+
while (x < this.width && cellAt(row, x).char === " ") {
|
|
58
|
+
x++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (x >= this.width) {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const start = x;
|
|
66
|
+
const attr = cellAt(row, x).attr;
|
|
67
|
+
const key = attrKey(attr);
|
|
68
|
+
const chars: string[] = [];
|
|
69
|
+
|
|
70
|
+
while (x < this.width && attrKey(cellAt(row, x).attr) === key) {
|
|
71
|
+
chars.push(cellAt(row, x).char);
|
|
72
|
+
x++;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const text = chars.join("").trimEnd();
|
|
76
|
+
if (text) {
|
|
77
|
+
runs.push({ x: start, text, attr });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return runs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
rowKey(y: number): string {
|
|
85
|
+
return this.rowRuns(y)
|
|
86
|
+
.map((run) => `${run.x}:${attrKey(run.attr)}:${run.text}`)
|
|
87
|
+
.join("\u001f");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class FrameRenderer {
|
|
92
|
+
private previous?: {
|
|
93
|
+
width: number;
|
|
94
|
+
height: number;
|
|
95
|
+
rowKeys: string[];
|
|
96
|
+
};
|
|
97
|
+
private cleared = false;
|
|
98
|
+
|
|
99
|
+
constructor(private readonly output: FrameOutput) {}
|
|
100
|
+
|
|
101
|
+
clear(): void {
|
|
102
|
+
this.previous = undefined;
|
|
103
|
+
this.cleared = true;
|
|
104
|
+
this.output.reset();
|
|
105
|
+
this.output.clear();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
draw(frame: Frame): void {
|
|
109
|
+
const rowKeys = Array.from({ length: frame.height }, (_, y) => frame.rowKey(y));
|
|
110
|
+
const force = !this.previous || this.previous.width !== frame.width || this.previous.height !== frame.height;
|
|
111
|
+
if (force && !this.cleared) {
|
|
112
|
+
this.output.reset();
|
|
113
|
+
this.output.clear();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const previous = force ? undefined : this.previous;
|
|
117
|
+
let changed = false;
|
|
118
|
+
for (let y = 0; y < frame.height; y++) {
|
|
119
|
+
if (previous && previous.rowKeys[y] === rowKeys[y]) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!previous && !rowKeys[y]) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.output.moveTo(1, y + 1);
|
|
127
|
+
if (previous) {
|
|
128
|
+
this.output.reset();
|
|
129
|
+
this.output.eraseLineAfter();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const run of frame.rowRuns(y)) {
|
|
133
|
+
this.output.moveTo(run.x + 1, y + 1);
|
|
134
|
+
this.output.write(run.text, run.attr);
|
|
135
|
+
}
|
|
136
|
+
changed = true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (changed) {
|
|
140
|
+
this.output.reset();
|
|
141
|
+
}
|
|
142
|
+
this.previous = { width: frame.width, height: frame.height, rowKeys };
|
|
143
|
+
this.cleared = false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type StyledTerminal = Terminal & Record<string, Terminal>;
|
|
148
|
+
|
|
149
|
+
export class TerminalFrameOutput implements FrameOutput {
|
|
150
|
+
constructor(private readonly terminal: Terminal) {}
|
|
151
|
+
|
|
152
|
+
clear(): void {
|
|
153
|
+
this.terminal.clear();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
moveTo(x: number, y: number): void {
|
|
157
|
+
this.terminal.moveTo(x, y);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
eraseLineAfter(): void {
|
|
161
|
+
this.terminal.eraseLineAfter();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
reset(): void {
|
|
165
|
+
this.terminal.styleReset();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
write(value: string, attr: TextAttr = {}): void {
|
|
169
|
+
let output = this.terminal as StyledTerminal;
|
|
170
|
+
|
|
171
|
+
if (attr.bold) {
|
|
172
|
+
output = output.bold as StyledTerminal;
|
|
173
|
+
}
|
|
174
|
+
if (attr.underline) {
|
|
175
|
+
output = output.underline as StyledTerminal;
|
|
176
|
+
}
|
|
177
|
+
if (attr.italic) {
|
|
178
|
+
output = output.italic as StyledTerminal;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const color = typeof attr.color === "string" ? attr.color : undefined;
|
|
182
|
+
if (color && typeof output[color] === "function") {
|
|
183
|
+
output = output[color] as StyledTerminal;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
output(value);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function truncate(value: string, width: number): string {
|
|
191
|
+
const chars = [...value];
|
|
192
|
+
if (chars.length <= width) {
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
if (width <= 1) {
|
|
196
|
+
return chars.slice(0, width).join("");
|
|
197
|
+
}
|
|
198
|
+
return `${chars.slice(0, width - 1).join("")}…`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function cellAt(row: FrameCell[], x: number): FrameCell {
|
|
202
|
+
return row[x] ?? { char: " ", attr: emptyAttr };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function attrKey(attr: TextAttr): string {
|
|
206
|
+
return Object.keys(attr)
|
|
207
|
+
.sort()
|
|
208
|
+
.map((key) => `${key}:${String(attr[key])}`)
|
|
209
|
+
.join(";");
|
|
210
|
+
}
|
package/src/tui/input.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fetchPracticeQuestion, findQuestionByShortId } from "../api.ts";
|
|
2
|
-
import { recordAttempt, saveAttempts, saveFocus, saveSummary } from "../state.ts";
|
|
2
|
+
import { appendAttemptEvent, recordAttempt, saveAttempts, saveFocus, saveSummary } from "../state.ts";
|
|
3
3
|
import { focusGrid, moveFocusGridPosition, normalizeFocusGridPosition, toggleFocusGridRow } from "./focus-grid.ts";
|
|
4
4
|
import {
|
|
5
5
|
answerKeys,
|
|
@@ -120,7 +120,9 @@ export async function handleKey(state: AppState, name: string, data?: KeyData):
|
|
|
120
120
|
state.lastCorrect = correct;
|
|
121
121
|
pauseTimer(state);
|
|
122
122
|
if (state.question) {
|
|
123
|
-
|
|
123
|
+
const answeredAt = new Date();
|
|
124
|
+
recordAttempt(state.attempts, state.question.meta.questionId, correct, elapsedSeconds, answeredAt, state.question.meta);
|
|
125
|
+
await appendAttemptEvent(state.question.meta, correct, elapsedSeconds, answeredAt);
|
|
124
126
|
await saveAttempts(state.attempts);
|
|
125
127
|
await saveSummary(state.attempts);
|
|
126
128
|
}
|
|
@@ -211,6 +213,11 @@ async function handleFocusKey(state: AppState, name: string, data?: KeyData): Pr
|
|
|
211
213
|
export async function loadNextQuestion(state: AppState): Promise<void> {
|
|
212
214
|
state.view = "loading";
|
|
213
215
|
state.question = await takeNextQuestion(state);
|
|
216
|
+
if (!state.question) {
|
|
217
|
+
state.notice = "Review queue complete.";
|
|
218
|
+
state.view = "history";
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
214
221
|
state.selected = 0;
|
|
215
222
|
resetPaneScroll(state);
|
|
216
223
|
state.elapsedMs = 0;
|
|
@@ -223,6 +230,10 @@ export async function loadNextQuestion(state: AppState): Promise<void> {
|
|
|
223
230
|
}
|
|
224
231
|
|
|
225
232
|
async function takeNextQuestion(state: AppState) {
|
|
233
|
+
if (state.reviewMode) {
|
|
234
|
+
return takeReviewQuestion(state);
|
|
235
|
+
}
|
|
236
|
+
|
|
226
237
|
const cached = state.nextQuestion;
|
|
227
238
|
state.nextQuestion = undefined;
|
|
228
239
|
|
|
@@ -236,7 +247,27 @@ async function takeNextQuestion(state: AppState) {
|
|
|
236
247
|
return fetchPracticeQuestion(questionExclusions(state), state.focus);
|
|
237
248
|
}
|
|
238
249
|
|
|
250
|
+
async function takeReviewQuestion(state: AppState): Promise<PracticeQuestion | undefined> {
|
|
251
|
+
while (state.reviewQuestionIds && state.reviewQuestionIds.length > 0) {
|
|
252
|
+
const id = state.reviewQuestionIds.shift();
|
|
253
|
+
if (!id) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const question = await findQuestionByShortId(id);
|
|
258
|
+
if (question) {
|
|
259
|
+
return question;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
239
266
|
function cacheNextQuestion(state: AppState): void {
|
|
267
|
+
if (state.reviewMode) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
240
271
|
state.nextQuestion = fetchPracticeQuestion(questionExclusions(state), state.focus).catch(() => undefined);
|
|
241
272
|
}
|
|
242
273
|
|
package/src/tui/kit.ts
CHANGED
|
@@ -1,29 +1,9 @@
|
|
|
1
1
|
import terminalKit from "terminal-kit";
|
|
2
2
|
|
|
3
|
-
export const tk = terminalKit;
|
|
4
3
|
export const term = terminalKit.terminal;
|
|
5
|
-
export const gutter = 2;
|
|
6
|
-
|
|
7
|
-
export type TkDocument = InstanceType<typeof terminalKit.Document>;
|
|
8
|
-
export type TkColumnMenuMulti = InstanceType<typeof terminalKit.ColumnMenuMulti>;
|
|
9
|
-
export type TkWindow = InstanceType<typeof terminalKit.Window>;
|
|
10
4
|
|
|
11
5
|
export function terminalSize(): { width: number; height: number } {
|
|
12
6
|
const width = Number.isFinite(term.width) ? term.width : 80;
|
|
13
7
|
const height = Number.isFinite(term.height) ? term.height : 24;
|
|
14
8
|
return { width, height };
|
|
15
9
|
}
|
|
16
|
-
|
|
17
|
-
export function contentBounds(): { x: number; y: number; width: number; height: number } {
|
|
18
|
-
const { width, height } = terminalSize();
|
|
19
|
-
return {
|
|
20
|
-
x: 1,
|
|
21
|
-
y: 4,
|
|
22
|
-
width: Math.max(40, width),
|
|
23
|
-
height: Math.max(10, height - 5),
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function menuItem(content: string, value: string): { content: string; value: string } {
|
|
28
|
-
return { content, value };
|
|
29
|
-
}
|