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/tui/question.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { hasHtmlTable,
|
|
2
|
+
import { hasHtmlTable, parseHtmlSegments, wrapSegments } from "../text.ts";
|
|
3
3
|
import type { PracticeQuestion, QuestionDetail } from "../types.ts";
|
|
4
|
-
import {
|
|
5
|
-
import type { DisplayRow, PaneLayout } from "./types.ts";
|
|
4
|
+
import type { DisplayRow } from "./types.ts";
|
|
6
5
|
|
|
7
6
|
export function answerKeys(question?: PracticeQuestion): string[] {
|
|
8
7
|
return Object.keys(question?.detail.answerOptions ?? {}).sort();
|
|
@@ -17,10 +16,6 @@ export function questionNeedsExternalDisplay(detail: QuestionDetail): boolean {
|
|
|
17
16
|
);
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
export function questionPageSize(): number {
|
|
21
|
-
return Math.max(1, term.height - 6);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
19
|
export function questionRows(detail: QuestionDetail, width: number): DisplayRow[] {
|
|
25
20
|
const rows: DisplayRow[] = [];
|
|
26
21
|
const stimulus = wrapSegments(parseHtmlSegments(detail.stimulus), width).map((segments) => ({ segments }));
|
|
@@ -34,59 +29,6 @@ export function questionRows(detail: QuestionDetail, width: number): DisplayRow[
|
|
|
34
29
|
return rows;
|
|
35
30
|
}
|
|
36
31
|
|
|
37
|
-
export function renderQuestionPane(detail: QuestionDetail, x: number, width: number, scroll: number): void {
|
|
38
|
-
const rows = questionRows(detail, width);
|
|
39
|
-
const maxRows = questionPageSize();
|
|
40
|
-
const maxScroll = Math.max(0, rows.length - maxRows);
|
|
41
|
-
const start = Math.min(scroll, maxScroll);
|
|
42
|
-
|
|
43
|
-
for (const [offset, row] of rows.slice(start, start + maxRows).entries()) {
|
|
44
|
-
const y = 4 + offset;
|
|
45
|
-
renderStyledLine(x, y, width, row.segments, row.bold);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (start > 0) {
|
|
49
|
-
lineAt(x + width - 4, 4, 4, "^");
|
|
50
|
-
}
|
|
51
|
-
if (start < maxScroll) {
|
|
52
|
-
lineAt(x + width - 4, term.height - 3, 4, "v");
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function renderUnsupportedQuestion(question: PracticeQuestion, panes: PaneLayout): void {
|
|
57
|
-
const { meta, detail } = question;
|
|
58
|
-
const appUrl = practiceQuestionUrl(question);
|
|
59
|
-
const apiUrl = practiceQuestionApiUrl(question);
|
|
60
|
-
let y = 4;
|
|
61
|
-
|
|
62
|
-
lineAt(panes.leftX, y++, panes.leftWidth, "This question contains a table.");
|
|
63
|
-
y++;
|
|
64
|
-
y = printWrappedAt(
|
|
65
|
-
"The terminal renderer cannot display this table accurately enough to answer the question here.",
|
|
66
|
-
panes.leftX,
|
|
67
|
-
y,
|
|
68
|
-
panes.leftWidth,
|
|
69
|
-
term.height - 3,
|
|
70
|
-
);
|
|
71
|
-
y++;
|
|
72
|
-
y = printWrappedAt("Open it in PracticeSAT, search this question ID, then skip it in this app.", panes.leftX, y, panes.leftWidth, term.height - 3);
|
|
73
|
-
y++;
|
|
74
|
-
y = printWrappedAt(`Question ID: ${meta.questionId}`, panes.leftX, y, panes.leftWidth, term.height - 3);
|
|
75
|
-
y++;
|
|
76
|
-
y = printWrappedAt(appUrl, panes.leftX, y, panes.leftWidth, term.height - 3);
|
|
77
|
-
y++;
|
|
78
|
-
y = printWrappedAt(`API fallback: ${apiUrl}`, panes.leftX, y, panes.leftWidth, term.height - 3);
|
|
79
|
-
|
|
80
|
-
let rightY = 4;
|
|
81
|
-
lineAt(panes.rightX, rightY++, panes.rightWidth, `${meta.primary_class_cd} | ${meta.skill_cd}`);
|
|
82
|
-
lineAt(panes.rightX, rightY++, panes.rightWidth, `difficulty ${meta.difficulty} | ${meta.questionId}`);
|
|
83
|
-
rightY++;
|
|
84
|
-
lineAt(panes.rightX, rightY++, panes.rightWidth, "o open externally");
|
|
85
|
-
lineAt(panes.rightX, rightY++, panes.rightWidth, "n/x/enter skip");
|
|
86
|
-
rightY++;
|
|
87
|
-
printWrappedAt(htmlToText(detail.stem), panes.rightX, rightY, panes.rightWidth, term.height - 3, true);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
32
|
export function practiceQuestionUrl(question: PracticeQuestion): string {
|
|
91
33
|
return `https://practicesat.vercel.app/question?questionId=${encodeURIComponent(question.meta.questionId)}`;
|
|
92
34
|
}
|
package/src/tui/render.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { buildSummaryRows, displayStateDir, stateDir } from "../state.ts";
|
|
|
2
2
|
import { focusSummary } from "../focus.ts";
|
|
3
3
|
import { htmlToText, wrapText } from "../text.ts";
|
|
4
4
|
import type { TextSegment } from "../text.ts";
|
|
5
|
+
import { progressBarText } from "../progress.ts";
|
|
6
|
+
import { Frame, FrameRenderer, TerminalFrameOutput, truncate, type TextAttr } from "./frame.ts";
|
|
5
7
|
import { focusGrid, normalizeFocusGridPosition, type FocusGridColumn, type FocusGridRow } from "./focus-grid.ts";
|
|
6
8
|
import { historyRows } from "./history.ts";
|
|
7
9
|
import {
|
|
@@ -11,64 +13,59 @@ import {
|
|
|
11
13
|
questionRows,
|
|
12
14
|
} from "./question.ts";
|
|
13
15
|
import { elapsedQuestionSeconds, formatElapsed, timerStatus } from "./timer.ts";
|
|
14
|
-
import {
|
|
16
|
+
import { term, terminalSize } from "./kit.ts";
|
|
15
17
|
import { PANE_BODY_Y, PANE_HEADER_Y, paneLayout, paneViewportHeight } from "./layout.ts";
|
|
16
18
|
import { detailPaneRows, practiceAnswerPaneRows, reviewPaneRows, type PaneTextRow } from "./pane-content.ts";
|
|
17
19
|
import type { AppState, PaneLayout } from "./types.ts";
|
|
18
20
|
import type { Attempt, Outcome, QuestionMeta, SummaryRow } from "../types.ts";
|
|
19
21
|
import { clampScroll, maxScroll, type PaneViewport } from "./viewport.ts";
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export function createDocument(): TkDocument {
|
|
24
|
-
return new tk.Document({
|
|
25
|
-
eventSource: tk.terminal,
|
|
26
|
-
outputDst: tk.terminal,
|
|
27
|
-
backgroundAttr: {},
|
|
28
|
-
});
|
|
23
|
+
export function createFrameRenderer(): FrameRenderer {
|
|
24
|
+
return new FrameRenderer(new TerminalFrameOutput(term));
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
export function render(
|
|
32
|
-
|
|
27
|
+
export function render(renderer: FrameRenderer, state: AppState): void {
|
|
28
|
+
const { width, height } = terminalSize();
|
|
29
|
+
const frame = new Frame(width, height);
|
|
33
30
|
|
|
34
31
|
if (state.view === "practice" && state.timerPaused) {
|
|
35
|
-
renderPaused(
|
|
36
|
-
footer(
|
|
37
|
-
|
|
32
|
+
renderPaused(frame);
|
|
33
|
+
footer(frame, state);
|
|
34
|
+
renderer.draw(frame);
|
|
38
35
|
return;
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
header(
|
|
38
|
+
header(frame, state);
|
|
42
39
|
|
|
43
40
|
if (state.view === "loading") {
|
|
44
|
-
renderLoading(
|
|
41
|
+
renderLoading(frame);
|
|
45
42
|
} else if (state.view === "setup") {
|
|
46
|
-
renderSetup(
|
|
43
|
+
renderSetup(frame);
|
|
47
44
|
} else if (state.view === "focus") {
|
|
48
|
-
renderFocus(
|
|
45
|
+
renderFocus(frame, state);
|
|
49
46
|
} else if (state.view === "practice") {
|
|
50
|
-
renderPractice(
|
|
47
|
+
renderPractice(frame, state);
|
|
51
48
|
} else if (state.view === "review") {
|
|
52
|
-
renderReview(
|
|
49
|
+
renderReview(frame, state);
|
|
53
50
|
} else if (state.view === "history") {
|
|
54
|
-
renderHistory(
|
|
51
|
+
renderHistory(frame, state);
|
|
55
52
|
} else if (state.view === "summary") {
|
|
56
|
-
renderSummary(
|
|
53
|
+
renderSummary(frame, state);
|
|
57
54
|
} else if (state.view === "detail") {
|
|
58
|
-
renderDetail(
|
|
55
|
+
renderDetail(frame, state);
|
|
59
56
|
} else if (state.view === "error") {
|
|
60
|
-
renderError(
|
|
57
|
+
renderError(frame, state);
|
|
61
58
|
}
|
|
62
59
|
|
|
63
|
-
footer(
|
|
64
|
-
|
|
60
|
+
footer(frame, state);
|
|
61
|
+
renderer.draw(frame);
|
|
65
62
|
}
|
|
66
63
|
|
|
67
|
-
function renderLoading(doc:
|
|
64
|
+
function renderLoading(doc: Frame): void {
|
|
68
65
|
text(doc, 0, 3, "Loading...");
|
|
69
66
|
}
|
|
70
67
|
|
|
71
|
-
function renderSetup(doc:
|
|
68
|
+
function renderSetup(doc: Frame): void {
|
|
72
69
|
const { width } = terminalSize();
|
|
73
70
|
const location = displayStateDir(stateDir);
|
|
74
71
|
|
|
@@ -78,22 +75,23 @@ function renderSetup(doc: TkDocument): void {
|
|
|
78
75
|
text(doc, 0, 9, location, { color: "cyan" }, width);
|
|
79
76
|
}
|
|
80
77
|
|
|
81
|
-
function renderPaused(doc:
|
|
78
|
+
function renderPaused(doc: Frame): void {
|
|
82
79
|
const { width, height } = terminalSize();
|
|
83
80
|
const label = "PAUSED";
|
|
84
81
|
text(doc, Math.max(0, Math.floor((width - label.length) / 2)), Math.max(0, Math.floor(height / 2)), label, { bold: true });
|
|
85
82
|
}
|
|
86
83
|
|
|
87
|
-
function header(doc:
|
|
84
|
+
function header(doc: Frame, state: AppState): void {
|
|
88
85
|
const { width } = terminalSize();
|
|
89
86
|
const answered = state.attempts.size;
|
|
87
|
+
const mode = state.reviewMode ? "review queue" : "reading/writing";
|
|
90
88
|
const timer = state.timerHidden ? "" : ` time ${formatElapsed(elapsedQuestionSeconds(state))}${timerStatus(state)}`;
|
|
91
89
|
text(doc, 0, 0, "sat", { bold: true });
|
|
92
|
-
text(doc, 7, 0,
|
|
90
|
+
text(doc, 7, 0, `${mode} answered ${answered}${timer}`, { color: "gray" }, width - 7);
|
|
93
91
|
text(doc, 0, 1, "-".repeat(width), { color: "gray" }, width);
|
|
94
92
|
}
|
|
95
93
|
|
|
96
|
-
function footer(doc:
|
|
94
|
+
function footer(doc: Frame, state: AppState): void {
|
|
97
95
|
const { width, height } = terminalSize();
|
|
98
96
|
const controls = state.view === "setup"
|
|
99
97
|
? "y allow | n decline | q quit"
|
|
@@ -115,7 +113,7 @@ function footer(doc: TkDocument, state: AppState): void {
|
|
|
115
113
|
text(doc, 0, Math.max(0, height - 1), controls, { color: "gray" }, width);
|
|
116
114
|
}
|
|
117
115
|
|
|
118
|
-
function renderFocus(doc:
|
|
116
|
+
function renderFocus(doc: Frame, state: AppState): void {
|
|
119
117
|
const { width } = terminalSize();
|
|
120
118
|
const columns = focusGrid(state.focus);
|
|
121
119
|
const position = normalizeFocusGridPosition(columns, { column: state.focusColumn, row: state.focusRow });
|
|
@@ -131,7 +129,7 @@ function renderFocus(doc: TkDocument, state: AppState): void {
|
|
|
131
129
|
renderDomainColumns(doc, columns.slice(1), position.column - 1, position.row);
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
function renderDifficultyColumn(doc:
|
|
132
|
+
function renderDifficultyColumn(doc: Frame, column: FocusGridColumn, selectedRow: number): void {
|
|
135
133
|
text(doc, 0, 6, column.title, { color: "cyan", bold: true });
|
|
136
134
|
|
|
137
135
|
for (const [index, row] of column.rows.entries()) {
|
|
@@ -140,7 +138,7 @@ function renderDifficultyColumn(doc: TkDocument, column: FocusGridColumn, select
|
|
|
140
138
|
}
|
|
141
139
|
}
|
|
142
140
|
|
|
143
|
-
function renderDomainColumns(doc:
|
|
141
|
+
function renderDomainColumns(doc: Frame, columns: FocusGridColumn[], selectedColumn: number, selectedRow: number): void {
|
|
144
142
|
const { width } = terminalSize();
|
|
145
143
|
const startY = 11;
|
|
146
144
|
const gap = 4;
|
|
@@ -170,7 +168,7 @@ function focusOptionText(row: FocusGridRow, focused: boolean): string {
|
|
|
170
168
|
}
|
|
171
169
|
|
|
172
170
|
function writeFocusOption(
|
|
173
|
-
doc:
|
|
171
|
+
doc: Frame,
|
|
174
172
|
x: number,
|
|
175
173
|
y: number,
|
|
176
174
|
value: string,
|
|
@@ -182,7 +180,7 @@ function writeFocusOption(
|
|
|
182
180
|
text(doc, x, y, value, attr, width);
|
|
183
181
|
}
|
|
184
182
|
|
|
185
|
-
function renderPractice(doc:
|
|
183
|
+
function renderPractice(doc: Frame, state: AppState): void {
|
|
186
184
|
if (!state.question) {
|
|
187
185
|
return;
|
|
188
186
|
}
|
|
@@ -205,7 +203,7 @@ function renderPractice(doc: TkDocument, state: AppState): void {
|
|
|
205
203
|
renderPaneRows(doc, answerContent, panes.rightX, panes.rightWidth, answerViewport, state.activePane === "answers");
|
|
206
204
|
}
|
|
207
205
|
|
|
208
|
-
function renderReview(doc:
|
|
206
|
+
function renderReview(doc: Frame, state: AppState): void {
|
|
209
207
|
if (!state.question) {
|
|
210
208
|
return;
|
|
211
209
|
}
|
|
@@ -227,11 +225,14 @@ function renderReview(doc: TkDocument, state: AppState): void {
|
|
|
227
225
|
renderPaneRows(doc, reviewContent, panes.rightX, panes.rightWidth, answerViewport, state.activePane === "answers");
|
|
228
226
|
}
|
|
229
227
|
|
|
230
|
-
function renderHistory(doc:
|
|
228
|
+
function renderHistory(doc: Frame, state: AppState): void {
|
|
231
229
|
const attempts = historyRows(state);
|
|
232
230
|
const { width, height } = terminalSize();
|
|
233
231
|
let y = 3;
|
|
234
232
|
text(doc, 0, y++, "answered questions", { color: "cyan", bold: true });
|
|
233
|
+
if (state.notice) {
|
|
234
|
+
text(doc, 0, y++, state.notice, { color: "yellow" }, width);
|
|
235
|
+
}
|
|
235
236
|
y++;
|
|
236
237
|
|
|
237
238
|
if (attempts.length === 0) {
|
|
@@ -239,7 +240,7 @@ function renderHistory(doc: TkDocument, state: AppState): void {
|
|
|
239
240
|
return;
|
|
240
241
|
}
|
|
241
242
|
|
|
242
|
-
text(doc, 0, y++, " question status updated", { color: "gray" }, width);
|
|
243
|
+
text(doc, 0, y++, " question status skill updated", { color: "gray" }, width);
|
|
243
244
|
|
|
244
245
|
const visibleRows = Math.max(1, height - 8);
|
|
245
246
|
const start = Math.max(0, Math.min(state.historyIndex - visibleRows + 1, attempts.length - visibleRows));
|
|
@@ -249,7 +250,7 @@ function renderHistory(doc: TkDocument, state: AppState): void {
|
|
|
249
250
|
}
|
|
250
251
|
}
|
|
251
252
|
|
|
252
|
-
function renderSummary(doc:
|
|
253
|
+
function renderSummary(doc: Frame, state: AppState): void {
|
|
253
254
|
const { width } = terminalSize();
|
|
254
255
|
let y = 3;
|
|
255
256
|
text(doc, 0, y++, "stats summary", { color: "cyan", bold: true });
|
|
@@ -260,7 +261,7 @@ function renderSummary(doc: TkDocument, state: AppState): void {
|
|
|
260
261
|
}
|
|
261
262
|
}
|
|
262
263
|
|
|
263
|
-
function renderDetail(doc:
|
|
264
|
+
function renderDetail(doc: Frame, state: AppState): void {
|
|
264
265
|
const panes = paneLayout();
|
|
265
266
|
if (!state.detailQuestion) {
|
|
266
267
|
text(doc, 0, 3, "Could not fetch details for this question.");
|
|
@@ -286,12 +287,12 @@ function renderDetail(doc: TkDocument, state: AppState): void {
|
|
|
286
287
|
renderPaneRows(doc, detailContent, panes.rightX, panes.rightWidth, answerViewport, state.activePane === "answers");
|
|
287
288
|
}
|
|
288
289
|
|
|
289
|
-
function renderError(doc:
|
|
290
|
+
function renderError(doc: Frame, state: AppState): void {
|
|
290
291
|
text(doc, 0, 3, "Something went wrong.", { bold: true, color: "red" });
|
|
291
292
|
printWrappedAt(doc, state.error ?? "Unknown error.", 0, 5, terminalSize().width - 1, terminalSize().height - 4);
|
|
292
293
|
}
|
|
293
294
|
|
|
294
|
-
function renderUnsupportedQuestion(doc:
|
|
295
|
+
function renderUnsupportedQuestion(doc: Frame, state: AppState, panes: PaneLayout): void {
|
|
295
296
|
if (!state.question) {
|
|
296
297
|
return;
|
|
297
298
|
}
|
|
@@ -351,7 +352,7 @@ function renderUnsupportedQuestion(doc: TkDocument, state: AppState, panes: Pane
|
|
|
351
352
|
}
|
|
352
353
|
|
|
353
354
|
function renderPaneHeaders(
|
|
354
|
-
doc:
|
|
355
|
+
doc: Frame,
|
|
355
356
|
panes: PaneLayout,
|
|
356
357
|
state: AppState,
|
|
357
358
|
questionViewport: PaneViewport,
|
|
@@ -362,7 +363,7 @@ function renderPaneHeaders(
|
|
|
362
363
|
renderPaneHeader(doc, panes.rightX, panes.rightWidth, rightLabel, state.activePane === "answers", answerViewport);
|
|
363
364
|
}
|
|
364
365
|
|
|
365
|
-
function renderPaneHeader(doc:
|
|
366
|
+
function renderPaneHeader(doc: Frame, x: number, width: number, label: string, active: boolean, viewport: PaneViewport): void {
|
|
366
367
|
const attr = active ? { color: "cyan", underline: true } : { color: "gray" };
|
|
367
368
|
text(doc, x, PANE_HEADER_Y, label, attr, width);
|
|
368
369
|
|
|
@@ -376,7 +377,7 @@ function renderPaneHeader(doc: TkDocument, x: number, width: number, label: stri
|
|
|
376
377
|
}
|
|
377
378
|
|
|
378
379
|
function renderQuestionRows(
|
|
379
|
-
doc:
|
|
380
|
+
doc: Frame,
|
|
380
381
|
rows: ReturnType<typeof questionRows>,
|
|
381
382
|
x: number,
|
|
382
383
|
width: number,
|
|
@@ -390,7 +391,7 @@ function renderQuestionRows(
|
|
|
390
391
|
renderScrollGutter(doc, x, width, viewport, active);
|
|
391
392
|
}
|
|
392
393
|
|
|
393
|
-
function renderPaneRows(doc:
|
|
394
|
+
function renderPaneRows(doc: Frame, rows: PaneTextRow[], x: number, width: number, viewport: PaneViewport, active: boolean): void {
|
|
394
395
|
for (const [offset, row] of rows.slice(viewport.scroll, viewport.scroll + viewport.height).entries()) {
|
|
395
396
|
text(doc, x, PANE_BODY_Y + offset, row.text, paneRowAttr(row), width);
|
|
396
397
|
}
|
|
@@ -398,7 +399,7 @@ function renderPaneRows(doc: TkDocument, rows: PaneTextRow[], x: number, width:
|
|
|
398
399
|
renderScrollGutter(doc, x, width, viewport, active);
|
|
399
400
|
}
|
|
400
401
|
|
|
401
|
-
function renderScrollGutter(doc:
|
|
402
|
+
function renderScrollGutter(doc: Frame, x: number, width: number, viewport: PaneViewport, active: boolean): void {
|
|
402
403
|
const scrollX = Math.min(terminalSize().width - 1, x + width);
|
|
403
404
|
const attr = active ? { color: "cyan" } : { color: "gray" };
|
|
404
405
|
if (viewport.scroll > 0) {
|
|
@@ -436,7 +437,7 @@ function paneRowAttr(row: PaneTextRow): TextAttr {
|
|
|
436
437
|
return attr;
|
|
437
438
|
}
|
|
438
439
|
|
|
439
|
-
function renderStyledLine(doc:
|
|
440
|
+
function renderStyledLine(doc: Frame, x: number, y: number, width: number, segments: TextSegment[], forceBold = false): void {
|
|
440
441
|
let col = 0;
|
|
441
442
|
for (const segment of segments) {
|
|
442
443
|
if (col >= width) {
|
|
@@ -458,7 +459,7 @@ function renderStyledLine(doc: TkDocument, x: number, y: number, width: number,
|
|
|
458
459
|
}
|
|
459
460
|
|
|
460
461
|
function printWrappedAt(
|
|
461
|
-
doc:
|
|
462
|
+
doc: Frame,
|
|
462
463
|
value: string | undefined,
|
|
463
464
|
x: number,
|
|
464
465
|
y: number,
|
|
@@ -477,15 +478,16 @@ function printWrappedAt(
|
|
|
477
478
|
return y;
|
|
478
479
|
}
|
|
479
480
|
|
|
480
|
-
function renderHistoryRow(doc:
|
|
481
|
+
function renderHistoryRow(doc: Frame, attempt: Attempt, selected: boolean, y: number, width: number): void {
|
|
481
482
|
const strong = selected ? { bold: true } : {};
|
|
482
483
|
text(doc, 0, y, selected ? ">" : " ", selected ? { color: "yellow", bold: true } : { color: "gray" }, 1);
|
|
483
484
|
text(doc, 2, y, attempt.question_id, { color: selected ? "yellow" : "cyan", ...strong }, 10);
|
|
484
485
|
text(doc, 13, y, attempt.outcome, { ...outcomeAttr(attempt.outcome), ...strong }, 9);
|
|
485
|
-
text(doc, 24, y, attempt.
|
|
486
|
+
text(doc, 24, y, attempt.skill ?? "-", { color: selected ? "yellow" : "green", ...strong }, 5);
|
|
487
|
+
text(doc, 31, y, shortTimestamp(attempt.updated_at), { color: selected ? "yellow" : "gray", ...strong }, width - 31);
|
|
486
488
|
}
|
|
487
489
|
|
|
488
|
-
function renderSummaryRow(doc:
|
|
490
|
+
function renderSummaryRow(doc: Frame, row: SummaryRow, y: number, width: number): void {
|
|
489
491
|
const value = summaryValue(row);
|
|
490
492
|
const attr = summaryAttr(row);
|
|
491
493
|
|
|
@@ -499,7 +501,7 @@ function renderSummaryRow(doc: TkDocument, row: SummaryRow, y: number, width: nu
|
|
|
499
501
|
}
|
|
500
502
|
|
|
501
503
|
function renderQuestionMetadata(
|
|
502
|
-
doc:
|
|
504
|
+
doc: Frame,
|
|
503
505
|
meta: QuestionMeta,
|
|
504
506
|
attempt: Attempt | undefined,
|
|
505
507
|
x: number,
|
|
@@ -525,6 +527,19 @@ function formatSkill(meta: QuestionMeta): string {
|
|
|
525
527
|
return meta.skill_desc ? `${meta.skill_cd} ${meta.skill_desc}` : meta.skill_cd;
|
|
526
528
|
}
|
|
527
529
|
|
|
530
|
+
function shortTimestamp(value: string): string {
|
|
531
|
+
const date = new Date(value);
|
|
532
|
+
if (Number.isNaN(date.getTime())) {
|
|
533
|
+
return value;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
537
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
538
|
+
const hour = String(date.getHours()).padStart(2, "0");
|
|
539
|
+
const minute = String(date.getMinutes()).padStart(2, "0");
|
|
540
|
+
return `${month}/${day} ${hour}:${minute}`;
|
|
541
|
+
}
|
|
542
|
+
|
|
528
543
|
function outcomeAttr(outcome: Outcome): TextAttr {
|
|
529
544
|
if (outcome === "correct") {
|
|
530
545
|
return { color: "green" };
|
|
@@ -584,9 +599,7 @@ function summaryAttr(row: SummaryRow): TextAttr {
|
|
|
584
599
|
|
|
585
600
|
function accuracyBar(value: string, width: number): string {
|
|
586
601
|
const barWidth = Math.max(8, width - 2);
|
|
587
|
-
|
|
588
|
-
const filled = Math.round(ratio * barWidth);
|
|
589
|
-
return `[${"#".repeat(filled)}${"-".repeat(barWidth - filled)}]`;
|
|
602
|
+
return `[${progressBarText(readRatio(value), barWidth)}]`;
|
|
590
603
|
}
|
|
591
604
|
|
|
592
605
|
function readRatio(value: string): number {
|
|
@@ -597,34 +610,12 @@ function readRatio(value: string): number {
|
|
|
597
610
|
return Math.min(1, Math.max(0, ratio));
|
|
598
611
|
}
|
|
599
612
|
|
|
600
|
-
function text(doc:
|
|
601
|
-
if (y < 0 || y >=
|
|
613
|
+
function text(doc: Frame, x: number, y: number, value: string, attr: TextAttr = {}, width = doc.width - x): void {
|
|
614
|
+
if (y < 0 || y >= doc.height || width <= 0) {
|
|
602
615
|
return;
|
|
603
616
|
}
|
|
604
617
|
|
|
605
|
-
|
|
606
|
-
if (!content) {
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
new tk.Text({
|
|
611
|
-
parent: doc,
|
|
612
|
-
x,
|
|
613
|
-
y,
|
|
614
|
-
content,
|
|
615
|
-
attr,
|
|
616
|
-
noDraw: true,
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function truncate(value: string, width: number): string {
|
|
621
|
-
if (value.length <= width) {
|
|
622
|
-
return value;
|
|
623
|
-
}
|
|
624
|
-
if (width <= 1) {
|
|
625
|
-
return value.slice(0, width);
|
|
626
|
-
}
|
|
627
|
-
return `${value.slice(0, width - 1)}…`;
|
|
618
|
+
doc.writeText(x, y, value, attr, width);
|
|
628
619
|
}
|
|
629
620
|
|
|
630
621
|
function practiceControls(state: AppState): string {
|
package/src/tui/types.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type AppState = {
|
|
|
8
8
|
attempts: Map<string, Attempt>;
|
|
9
9
|
skippedIds: Set<string>;
|
|
10
10
|
nextQuestion?: Promise<PracticeQuestion | undefined>;
|
|
11
|
+
reviewMode?: boolean;
|
|
12
|
+
reviewQuestionIds?: string[];
|
|
11
13
|
focus: Focus;
|
|
12
14
|
focusIndex: number;
|
|
13
15
|
focusColumn: number;
|
|
@@ -26,6 +28,7 @@ export type AppState = {
|
|
|
26
28
|
lastCorrect?: boolean;
|
|
27
29
|
detailQuestion?: PracticeQuestion;
|
|
28
30
|
historyIndex: number;
|
|
31
|
+
notice?: string;
|
|
29
32
|
error?: string;
|
|
30
33
|
};
|
|
31
34
|
|
|
@@ -19,6 +19,9 @@ declare module "terminal-kit" {
|
|
|
19
19
|
underline: Terminal;
|
|
20
20
|
inverse: Terminal;
|
|
21
21
|
defaultColor: Terminal;
|
|
22
|
+
italic: Terminal;
|
|
23
|
+
styleReset(): Terminal;
|
|
24
|
+
eraseLineAfter(): Terminal;
|
|
22
25
|
fullscreen(enabled: boolean): void;
|
|
23
26
|
hideCursor(enabled?: boolean): void;
|
|
24
27
|
grabInput(enabled?: boolean): void;
|
package/src/types.ts
CHANGED
|
@@ -5,6 +5,23 @@ export type Attempt = {
|
|
|
5
5
|
outcome: Outcome;
|
|
6
6
|
updated_at: string;
|
|
7
7
|
elapsed_seconds: number;
|
|
8
|
+
difficulty?: string;
|
|
9
|
+
domain?: string;
|
|
10
|
+
domain_desc?: string;
|
|
11
|
+
skill?: string;
|
|
12
|
+
skill_desc?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AttemptEvent = {
|
|
16
|
+
question_id: string;
|
|
17
|
+
correct: boolean;
|
|
18
|
+
answered_at: string;
|
|
19
|
+
elapsed_seconds: number;
|
|
20
|
+
difficulty: string;
|
|
21
|
+
domain: string;
|
|
22
|
+
domain_desc?: string;
|
|
23
|
+
skill: string;
|
|
24
|
+
skill_desc?: string;
|
|
8
25
|
};
|
|
9
26
|
|
|
10
27
|
export type SummaryRow = {
|