saterminal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.envrc +1 -0
- package/AGENTS.md +23 -0
- package/README.md +4 -0
- package/bun.lock +100 -0
- package/flake.lock +59 -0
- package/flake.nix +21 -0
- package/package.json +26 -0
- package/src/api.ts +78 -0
- package/src/focus.ts +231 -0
- package/src/main.ts +4 -0
- package/src/state.ts +203 -0
- package/src/text.ts +256 -0
- package/src/tui/app.ts +77 -0
- package/src/tui/focus-grid.ts +113 -0
- package/src/tui/history.ts +6 -0
- package/src/tui/input.ts +411 -0
- package/src/tui/kit.ts +29 -0
- package/src/tui/layout.ts +25 -0
- package/src/tui/pane-content.ts +150 -0
- package/src/tui/question.ts +104 -0
- package/src/tui/render.ts +618 -0
- package/src/tui/terminal.ts +110 -0
- package/src/tui/timer.ts +43 -0
- package/src/tui/types.ts +48 -0
- package/src/tui/viewport.ts +60 -0
- package/src/tui.ts +1 -0
- package/src/types/terminal-kit.d.ts +118 -0
- package/src/types.ts +52 -0
- package/test/api.test.ts +80 -0
- package/test/focus.test.ts +55 -0
- package/test/state.test.ts +120 -0
- package/test/text.test.ts +72 -0
- package/test/viewport.test.ts +39 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { hasHtmlTable, htmlToText, parseHtmlSegments, wrapSegments } from "../text.ts";
|
|
3
|
+
import type { PracticeQuestion, QuestionDetail } from "../types.ts";
|
|
4
|
+
import { lineAt, printWrappedAt, renderStyledLine, term } from "./terminal.ts";
|
|
5
|
+
import type { DisplayRow, PaneLayout } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export function answerKeys(question?: PracticeQuestion): string[] {
|
|
8
|
+
return Object.keys(question?.detail.answerOptions ?? {}).sort();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function questionNeedsExternalDisplay(detail: QuestionDetail): boolean {
|
|
12
|
+
return hasHtmlTable(
|
|
13
|
+
detail.stimulus,
|
|
14
|
+
detail.stem,
|
|
15
|
+
detail.rationale,
|
|
16
|
+
...Object.values(detail.answerOptions),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function questionPageSize(): number {
|
|
21
|
+
return Math.max(1, term.height - 6);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function questionRows(detail: QuestionDetail, width: number): DisplayRow[] {
|
|
25
|
+
const rows: DisplayRow[] = [];
|
|
26
|
+
const stimulus = wrapSegments(parseHtmlSegments(detail.stimulus), width).map((segments) => ({ segments }));
|
|
27
|
+
const stem = wrapSegments(parseHtmlSegments(detail.stem), width).map((segments) => ({ segments, bold: true }));
|
|
28
|
+
|
|
29
|
+
rows.push(...stimulus);
|
|
30
|
+
if (stimulus.length > 0 && stem.length > 0) {
|
|
31
|
+
rows.push({ segments: [{ text: "", style: {} }] });
|
|
32
|
+
}
|
|
33
|
+
rows.push(...stem);
|
|
34
|
+
return rows;
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
export function practiceQuestionUrl(question: PracticeQuestion): string {
|
|
91
|
+
return `https://practicesat.vercel.app/question?questionId=${encodeURIComponent(question.meta.questionId)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function practiceQuestionApiUrl(question: PracticeQuestion): string {
|
|
95
|
+
return `https://practicesat.vercel.app/api/question/${encodeURIComponent(question.meta.external_id)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function openExternalQuestion(question: PracticeQuestion): void {
|
|
99
|
+
const child = spawn("open", [practiceQuestionUrl(question)], {
|
|
100
|
+
detached: true,
|
|
101
|
+
stdio: "ignore",
|
|
102
|
+
});
|
|
103
|
+
child.unref();
|
|
104
|
+
}
|