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/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
- await runTui();
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
+ }
@@ -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) => [attempt.question_id, attempt.outcome, attempt.updated_at, String(attempt.elapsed_seconds)]);
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 = { question_id: questionId, outcome, updated_at, elapsed_seconds: elapsedSeconds };
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 { createDocument, render } from "./render.ts";
5
- import { term } from "./terminal.ts";
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
- async function loadPersistedState(state: AppState): Promise<void> {
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
- const doc = createDocument();
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(doc, state);
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", () => render(doc, state));
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(doc, state);
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(doc, state);
111
+ render(renderer, state);
87
112
  return;
88
113
  }
89
114
 
90
115
  await handleKey(state, name, data);
91
- render(doc, state);
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(doc, state);
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
+ }
@@ -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
- recordAttempt(state.attempts, state.question.meta.questionId, correct, elapsedSeconds);
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
- }