saterminal 0.3.2 → 0.5.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 (83) hide show
  1. package/README.md +168 -4
  2. package/data/question-bank.json.zst +0 -0
  3. package/package.json +16 -10
  4. package/src/cli/commands/_app.tsx +10 -0
  5. package/src/cli/commands/config/index.tsx +21 -0
  6. package/src/cli/commands/config/reset.tsx +12 -0
  7. package/src/cli/commands/config/set.tsx +34 -0
  8. package/src/cli/commands/focus.tsx +12 -0
  9. package/src/cli/commands/history.tsx +21 -0
  10. package/src/cli/commands/index.tsx +7 -0
  11. package/src/cli/commands/review.tsx +7 -0
  12. package/src/cli/commands/show.tsx +36 -0
  13. package/src/cli/commands/stats.tsx +15 -0
  14. package/src/cli/commands/weak.tsx +13 -0
  15. package/src/cli/components/command-action.tsx +23 -0
  16. package/src/cli/components/report.tsx +16 -0
  17. package/src/cli/index.ts +10 -0
  18. package/src/cli/report-options.ts +19 -0
  19. package/src/cli/reports/focus.ts +20 -0
  20. package/src/cli/reports/history.ts +21 -0
  21. package/src/cli/reports/question.ts +24 -0
  22. package/src/cli/reports/stats.ts +66 -0
  23. package/src/cli/reports/terminal-format.ts +28 -0
  24. package/src/cli/reports/weaknesses.ts +36 -0
  25. package/src/database/focus-repository.ts +26 -0
  26. package/src/database/index.ts +28 -0
  27. package/src/database/migrations.ts +100 -0
  28. package/src/database/progress-repository.ts +90 -0
  29. package/src/local-data/paths.ts +17 -0
  30. package/src/local-data/setup.ts +25 -0
  31. package/src/practice/answer-question.ts +25 -0
  32. package/src/practice/question-queue.ts +58 -0
  33. package/src/preferences/index.ts +131 -0
  34. package/src/preferences/preferences.schema.json +45 -0
  35. package/src/progress/activity.ts +55 -0
  36. package/src/progress/attempt.ts +70 -0
  37. package/src/progress/history.ts +34 -0
  38. package/src/progress/review-queue.ts +44 -0
  39. package/src/progress/statistics.ts +35 -0
  40. package/src/progress/weaknesses.ts +44 -0
  41. package/src/questions/focus.ts +77 -0
  42. package/src/questions/local-bank.ts +133 -0
  43. package/src/questions/practice-sat.ts +14 -0
  44. package/src/questions/question.ts +25 -0
  45. package/src/questions/taxonomy.ts +56 -0
  46. package/src/text/duration.ts +4 -0
  47. package/src/{text.ts → text/html.ts} +3 -119
  48. package/src/text/media.ts +16 -0
  49. package/src/text/rich-text.ts +37 -0
  50. package/src/text/wrap.ts +59 -0
  51. package/src/tui/app.tsx +243 -0
  52. package/src/tui/components/chrome.tsx +39 -0
  53. package/src/tui/components/question-content.tsx +91 -0
  54. package/src/tui/hooks/use-terminal-size.ts +16 -0
  55. package/src/tui/screens/detail.tsx +20 -0
  56. package/src/tui/screens/focus.tsx +244 -0
  57. package/src/tui/screens/history.tsx +33 -0
  58. package/src/tui/screens/practice.tsx +159 -0
  59. package/src/tui/screens/result.tsx +169 -0
  60. package/src/tui/screens/setup.tsx +17 -0
  61. package/src/tui/screens/summary.tsx +16 -0
  62. package/src/api.ts +0 -113
  63. package/src/cli.ts +0 -917
  64. package/src/focus.ts +0 -231
  65. package/src/main.ts +0 -13
  66. package/src/progress.ts +0 -48
  67. package/src/state.ts +0 -346
  68. package/src/tui/app.ts +0 -134
  69. package/src/tui/focus-grid.ts +0 -113
  70. package/src/tui/frame.ts +0 -210
  71. package/src/tui/history.ts +0 -6
  72. package/src/tui/input.ts +0 -442
  73. package/src/tui/kit.ts +0 -9
  74. package/src/tui/layout.ts +0 -25
  75. package/src/tui/pane-content.ts +0 -150
  76. package/src/tui/question.ts +0 -46
  77. package/src/tui/render.ts +0 -627
  78. package/src/tui/timer.ts +0 -43
  79. package/src/tui/types.ts +0 -51
  80. package/src/tui/viewport.ts +0 -60
  81. package/src/tui.ts +0 -1
  82. package/src/types/terminal-kit.d.ts +0 -121
  83. package/src/types.ts +0 -69
@@ -1,17 +1,7 @@
1
1
  import he from "he";
2
2
  import { HTMLElement, NodeType, parse, type Node } from "node-html-parser";
3
- import wrap from "word-wrap";
4
-
5
- export type TextStyle = {
6
- bold?: boolean;
7
- underline?: boolean;
8
- italic?: boolean;
9
- };
10
-
11
- export type TextSegment = {
12
- text: string;
13
- style: TextStyle;
14
- };
3
+ import { prepareMediaHtml } from "@/text/media.ts";
4
+ import { appendSegment, finalizeSegments, type TextSegment, type TextStyle } from "@/text/rich-text.ts";
15
5
 
16
6
  export function htmlToText(html = ""): string {
17
7
  const text = parseHtmlSegments(html)
@@ -25,7 +15,7 @@ export function htmlToText(html = ""): string {
25
15
  }
26
16
 
27
17
  export function parseHtmlSegments(html = ""): TextSegment[] {
28
- const root = parse(prepareHtml(html), { lowerCaseTagName: true });
18
+ const root = parse(prepareMediaHtml(html), { lowerCaseTagName: true });
29
19
  const segments: TextSegment[] = [];
30
20
  walkNodes(root, {}, segments);
31
21
 
@@ -35,59 +25,6 @@ export function parseHtmlSegments(html = ""): TextSegment[] {
35
25
  }));
36
26
  }
37
27
 
38
- export function wrapSegments(segments: TextSegment[], width: number): TextSegment[][] {
39
- const usableWidth = Math.max(1, width);
40
- const lines: TextSegment[][] = [];
41
- let currentLine: TextSegment[] = [];
42
- let currentWidth = 0;
43
-
44
- const flushLine = () => {
45
- lines.push(currentLine);
46
- currentLine = [];
47
- currentWidth = 0;
48
- };
49
-
50
- const appendToLine = (text: string, style: TextStyle) => {
51
- if (!text) {
52
- return;
53
- }
54
-
55
- const last = currentLine[currentLine.length - 1];
56
- if (last && stylesEqual(last.style, style)) {
57
- last.text += text;
58
- } else {
59
- currentLine.push({ text, style: { ...style } });
60
- }
61
- currentWidth += text.length;
62
- };
63
-
64
- for (const segment of segments) {
65
- const parts = segment.text.split("\n");
66
- for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
67
- if (partIndex > 0) {
68
- flushLine();
69
- }
70
-
71
- const words = parts[partIndex].split(/\s+/).filter(Boolean);
72
- for (const word of words) {
73
- const needed = (currentWidth === 0 ? 0 : 1) + word.length;
74
-
75
- if (currentWidth > 0 && currentWidth + needed > usableWidth) {
76
- flushLine();
77
- }
78
-
79
- appendToLine(`${currentWidth === 0 ? "" : " "}${word}`, segment.style);
80
- }
81
- }
82
- }
83
-
84
- if (currentLine.length > 0 || lines.length === 0) {
85
- flushLine();
86
- }
87
-
88
- return lines;
89
- }
90
-
91
28
  export function hasHtmlTable(...values: Array<string | undefined>): boolean {
92
29
  return values.some((value) => parse(value ?? "").querySelector("table") !== null);
93
30
  }
@@ -96,15 +33,6 @@ export function decodeEntities(value: string): string {
96
33
  return he.decode(value);
97
34
  }
98
35
 
99
- export function wrapText(value: string, width: number): string[] {
100
- return wrap(value, {
101
- width: Math.max(1, width),
102
- trim: true,
103
- indent: "",
104
- newline: "\n",
105
- }).split("\n");
106
- }
107
-
108
36
  function walkNodes(node: Node, style: TextStyle, segments: TextSegment[]): void {
109
37
  if (node.nodeType === NodeType.TEXT_NODE) {
110
38
  pushText(segments, node.text, style);
@@ -163,12 +91,6 @@ function hasUnderlineStyle(style: string): boolean {
163
91
  return /text-decoration\s*:\s*[^;"]*underline/i.test(style);
164
92
  }
165
93
 
166
- function prepareHtml(html: string): string {
167
- return html
168
- .replace(/<\s*svg\b([^>]*)>[\s\S]*?<\s*\/\s*svg\s*>/gi, (_match, attrs: string) => mediaLabel("Graph", attrs))
169
- .replace(/<\s*img\b([^>]*)>/gi, (_match, attrs: string) => mediaLabel("Image", attrs));
170
- }
171
-
172
94
  function pushText(segments: TextSegment[], text: string, style: TextStyle): void {
173
95
  if (!text) {
174
96
  return;
@@ -187,44 +109,6 @@ function pushText(segments: TextSegment[], text: string, style: TextStyle): void
187
109
  appendSegment(segments, normalized, style);
188
110
  }
189
111
 
190
- function appendSegment(segments: TextSegment[], text: string, style: TextStyle): void {
191
- const last = segments[segments.length - 1];
192
- if (last && stylesEqual(last.style, style)) {
193
- last.text += text;
194
- return;
195
- }
196
-
197
- segments.push({ text, style: { ...style } });
198
- }
199
-
200
- function stylesEqual(left: TextStyle, right: TextStyle): boolean {
201
- return !!left.bold === !!right.bold && !!left.underline === !!right.underline && !!left.italic === !!right.italic;
202
- }
203
-
204
- function finalizeSegments(segments: TextSegment[]): TextSegment[] {
205
- const merged: TextSegment[] = [];
206
- for (const segment of segments) {
207
- const last = merged[merged.length - 1];
208
- if (last && stylesEqual(last.style, segment.style)) {
209
- last.text += segment.text;
210
- } else {
211
- merged.push({ text: segment.text, style: { ...segment.style } });
212
- }
213
- }
214
- return merged;
215
- }
216
-
217
- function mediaLabel(kind: string, attrs: string): string {
218
- const label = readAttribute(attrs, "aria-label") ?? readAttribute(attrs, "alt");
219
- return label ? `\n[${kind}: ${label}]\n` : `\n[${kind}]\n`;
220
- }
221
-
222
- function readAttribute(attrs: string, name: string): string | undefined {
223
- const pattern = new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
224
- const match = attrs.match(pattern);
225
- return match?.[1] ?? match?.[2] ?? match?.[3];
226
- }
227
-
228
112
  function normalizeDisplayText(value: string): string {
229
113
  return value
230
114
  .replace(/\u2019/g, "'")
@@ -0,0 +1,16 @@
1
+ export function prepareMediaHtml(html: string): string {
2
+ return html
3
+ .replace(/<\s*svg\b([^>]*)>[\s\S]*?<\s*\/\s*svg\s*>/gi, (_match, attrs: string) => mediaLabel("Graph", attrs))
4
+ .replace(/<\s*img\b([^>]*)>/gi, (_match, attrs: string) => mediaLabel("Image", attrs));
5
+ }
6
+
7
+ function mediaLabel(kind: string, attrs: string): string {
8
+ const label = readAttribute(attrs, "aria-label") ?? readAttribute(attrs, "alt");
9
+ return label ? `\n[${kind}: ${label}]\n` : `\n[${kind}]\n`;
10
+ }
11
+
12
+ function readAttribute(attrs: string, name: string): string | undefined {
13
+ const pattern = new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
14
+ const match = attrs.match(pattern);
15
+ return match?.[1] ?? match?.[2] ?? match?.[3];
16
+ }
@@ -0,0 +1,37 @@
1
+ export type TextStyle = {
2
+ bold?: boolean;
3
+ underline?: boolean;
4
+ italic?: boolean;
5
+ };
6
+
7
+ export type TextSegment = {
8
+ text: string;
9
+ style: TextStyle;
10
+ };
11
+
12
+ export function appendSegment(segments: TextSegment[], text: string, style: TextStyle): void {
13
+ const last = segments[segments.length - 1];
14
+ if (last && stylesEqual(last.style, style)) {
15
+ last.text += text;
16
+ return;
17
+ }
18
+
19
+ segments.push({ text, style: { ...style } });
20
+ }
21
+
22
+ export function finalizeSegments(segments: TextSegment[]): TextSegment[] {
23
+ const merged: TextSegment[] = [];
24
+ for (const segment of segments) {
25
+ const last = merged[merged.length - 1];
26
+ if (last && stylesEqual(last.style, segment.style)) {
27
+ last.text += segment.text;
28
+ } else {
29
+ merged.push({ text: segment.text, style: { ...segment.style } });
30
+ }
31
+ }
32
+ return merged;
33
+ }
34
+
35
+ export function stylesEqual(left: TextStyle, right: TextStyle): boolean {
36
+ return !!left.bold === !!right.bold && !!left.underline === !!right.underline && !!left.italic === !!right.italic;
37
+ }
@@ -0,0 +1,59 @@
1
+ import { appendSegment, stylesEqual, type TextSegment, type TextStyle } from "@/text/rich-text.ts";
2
+
3
+ export function wrapSegments(segments: TextSegment[], width: number): TextSegment[][] {
4
+ const usableWidth = Math.max(1, width);
5
+ const lines: TextSegment[][] = [];
6
+ let currentLine: TextSegment[] = [];
7
+ let currentWidth = 0;
8
+
9
+ const flushLine = () => {
10
+ lines.push(currentLine);
11
+ currentLine = [];
12
+ currentWidth = 0;
13
+ };
14
+
15
+ const appendToLine = (text: string, style: TextStyle) => {
16
+ if (!text) {
17
+ return;
18
+ }
19
+
20
+ const last = currentLine[currentLine.length - 1];
21
+ if (last && stylesEqual(last.style, style)) {
22
+ last.text += text;
23
+ } else {
24
+ currentLine.push({ text, style: { ...style } });
25
+ }
26
+ currentWidth += Bun.stringWidth(text);
27
+ };
28
+
29
+ for (const segment of segments) {
30
+ const parts = segment.text.split("\n");
31
+ for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
32
+ if (partIndex > 0) {
33
+ flushLine();
34
+ }
35
+
36
+ const words = parts[partIndex].split(/\s+/).filter(Boolean);
37
+ for (const word of words) {
38
+ const spacer = currentWidth === 0 ? "" : " ";
39
+ const needed = Bun.stringWidth(spacer + word);
40
+
41
+ if (currentWidth > 0 && currentWidth + needed > usableWidth) {
42
+ flushLine();
43
+ }
44
+
45
+ appendToLine(`${currentWidth === 0 ? "" : " "}${word}`, segment.style);
46
+ }
47
+ }
48
+ }
49
+
50
+ if (currentLine.length > 0 || lines.length === 0) {
51
+ flushLine();
52
+ }
53
+
54
+ return lines;
55
+ }
56
+
57
+ export function wrapText(value: string, width: number): string[] {
58
+ return Bun.wrapAnsi(value, Math.max(1, width), { hard: false }).split("\n").map((line) => line.trim());
59
+ }
@@ -0,0 +1,243 @@
1
+ import { Spinner } from "@inkjs/ui";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { loadFocus, saveFocus } from "@/database/focus-repository.ts";
5
+ import { dataDirectoryExists } from "@/database/index.ts";
6
+ import { loadAttemptEvents, loadAttempts } from "@/database/progress-repository.ts";
7
+ import { dataDirectory, displayPath } from "@/local-data/paths.ts";
8
+ import { ensureLocalData } from "@/local-data/setup.ts";
9
+ import { answerQuestion } from "@/practice/answer-question.ts";
10
+ import {
11
+ emptyQueueMessage,
12
+ questionsToReview,
13
+ skipQuestion,
14
+ takeNextQuestion,
15
+ unansweredQuestions,
16
+ type QuestionQueue,
17
+ } from "@/practice/question-queue.ts";
18
+ import { loadPreferences, type ResultDetail, type ReviewPreferences } from "@/preferences/index.ts";
19
+ import type { AnswerRecord, Attempt, AttemptEvent } from "@/progress/attempt.ts";
20
+ import type { Focus } from "@/questions/focus.ts";
21
+ import { defaultFocus } from "@/questions/focus.ts";
22
+ import { findQuestion, loadQuestionBank, questionBankStatus } from "@/questions/local-bank.ts";
23
+ import type { Question } from "@/questions/question.ts";
24
+ import { useTerminalSize } from "@/tui/hooks/use-terminal-size.ts";
25
+ import { DetailScreen } from "@/tui/screens/detail.tsx";
26
+ import { FocusScreen } from "@/tui/screens/focus.tsx";
27
+ import { HistoryScreen } from "@/tui/screens/history.tsx";
28
+ import { PracticeScreen } from "@/tui/screens/practice.tsx";
29
+ import { ResultScreen } from "@/tui/screens/result.tsx";
30
+ import { SetupScreen } from "@/tui/screens/setup.tsx";
31
+ import { SummaryScreen } from "@/tui/screens/summary.tsx";
32
+
33
+ type View = "setup" | "loading" | "focus" | "practice" | "result" | "history" | "summary" | "detail" | "error";
34
+
35
+ type StudyRecords = {
36
+ attempts: ReadonlyMap<string, Attempt>;
37
+ events: readonly AttemptEvent[];
38
+ reviewPreferences: ReviewPreferences;
39
+ };
40
+
41
+ type SessionEntry = (records: StudyRecords) => {
42
+ queue: QuestionQueue;
43
+ destination: "focus" | "question";
44
+ emptyNotice?: string;
45
+ };
46
+
47
+ const choosePracticeFocus: SessionEntry = () => ({
48
+ queue: unansweredQuestions(),
49
+ destination: "focus",
50
+ });
51
+
52
+ const beginReview: SessionEntry = ({ attempts, events, reviewPreferences }) => ({
53
+ queue: questionsToReview(attempts.values(), events, reviewPreferences),
54
+ destination: "question",
55
+ emptyNotice: `No questions are ready. Review requires ${reviewPreferences.minimumDays} days and ${reviewPreferences.minimumAnswersAfter} later answers.`,
56
+ });
57
+
58
+ export function PracticeSession() {
59
+ return <StudyShell enterSession={choosePracticeFocus} />;
60
+ }
61
+
62
+ export function ReviewSession() {
63
+ return <StudyShell enterSession={beginReview} />;
64
+ }
65
+
66
+ function StudyShell({ enterSession }: { enterSession: SessionEntry }) {
67
+ const { exit } = useApp();
68
+ const { stdout } = useStdout();
69
+ const terminal = useTerminalSize();
70
+ const [view, setView] = useState<View>("loading");
71
+ const [focus, setFocus] = useState<Focus>(defaultFocus);
72
+ const [attempts, setAttempts] = useState<Map<string, Attempt>>(() => new Map());
73
+ const [question, setQuestion] = useState<Question>();
74
+ const [detail, setDetail] = useState<{ question: Question; attempt: Attempt }>();
75
+ const [result, setResult] = useState<AnswerRecord>();
76
+ const [notice, setNotice] = useState<string>();
77
+ const [error, setError] = useState<string>();
78
+ const [resultDetail, setResultDetail] = useState<ResultDetail>("standard");
79
+ const [queue, setQueue] = useState<QuestionQueue>(() => unansweredQuestions());
80
+
81
+ const showNextQuestion = useCallback(async (
82
+ currentQueue = queue,
83
+ currentAttempts = attempts,
84
+ currentFocus = focus,
85
+ ) => {
86
+ setView("loading");
87
+ try {
88
+ const next = await takeNextQuestion(currentQueue, currentAttempts, currentFocus);
89
+ setQueue(next.queue);
90
+ if (!next.question) {
91
+ setNotice(emptyQueueMessage(next.queue));
92
+ setView("history");
93
+ return;
94
+ }
95
+ setQuestion(next.question);
96
+ setResult(undefined);
97
+ setView("practice");
98
+ } catch (cause) {
99
+ setError(message(cause));
100
+ setView("error");
101
+ }
102
+ }, [attempts, focus, queue]);
103
+
104
+ const initialize = useCallback(async () => {
105
+ setView("loading");
106
+ try {
107
+ await ensureLocalData();
108
+ const currentAttempts = loadAttempts();
109
+ const currentEvents = loadAttemptEvents();
110
+ const currentFocus = loadFocus();
111
+ const preferences = await loadPreferences();
112
+ setResultDetail(preferences.display.resultDetail);
113
+ await loadQuestionBank();
114
+ const status = await questionBankStatus();
115
+ setAttempts(currentAttempts);
116
+ setFocus(currentFocus);
117
+ setNotice(`${status.questions ?? 0} questions available offline.`);
118
+ const entry = enterSession({
119
+ attempts: currentAttempts,
120
+ events: currentEvents,
121
+ reviewPreferences: preferences.review,
122
+ });
123
+ setQueue(entry.queue);
124
+ if (entry.destination === "question") {
125
+ if (entry.queue.kind === "review" && !entry.queue.pendingIds.length) {
126
+ setNotice(entry.emptyNotice ?? "No questions are ready for review.");
127
+ setView("history");
128
+ return;
129
+ }
130
+ const first = await takeNextQuestion(entry.queue, currentAttempts, currentFocus);
131
+ setQueue(first.queue);
132
+ if (first.question) {
133
+ setQuestion(first.question);
134
+ setView("practice");
135
+ } else {
136
+ setNotice(emptyQueueMessage(first.queue));
137
+ setView("history");
138
+ }
139
+ } else {
140
+ setView("focus");
141
+ }
142
+ } catch (cause) {
143
+ setError(message(cause));
144
+ setView("error");
145
+ }
146
+ }, [enterSession]);
147
+
148
+ useEffect(() => {
149
+ void dataDirectoryExists()
150
+ .then((exists) => { if (exists) void initialize(); else setView("setup"); })
151
+ .catch((cause) => { setError(message(cause)); setView("error"); });
152
+ }, [initialize]);
153
+
154
+ useEffect(() => {
155
+ if (!stdout.isTTY) return;
156
+ stdout.write("\x1b[?1049h\x1b[?25l");
157
+ return () => { stdout.write("\x1b[?25h\x1b[?1049l"); };
158
+ }, [stdout]);
159
+
160
+ useInput((input) => {
161
+ if (input === "q") exit();
162
+ else if (input === "f" && view !== "setup") setView("focus");
163
+ else if (input === "h" && view !== "setup") setView("history");
164
+ else if (input === "s" && view !== "setup") setView("summary");
165
+ else if (input === "p" && view !== "setup") {
166
+ if (question && view !== "result") setView("practice");
167
+ else void showNextQuestion(unansweredQuestions());
168
+ }
169
+ });
170
+
171
+ const answer = async (choice: string, durationSeconds: number) => {
172
+ if (!question) return;
173
+ setView("loading");
174
+ try {
175
+ const answerResult = await answerQuestion({ attempts, question, answer: choice, durationSeconds });
176
+ setAttempts((current) => new Map(current).set(answerResult.attempt.questionId, answerResult.attempt));
177
+ setResult(answerResult);
178
+ setView("result");
179
+ } catch (cause) {
180
+ setError(message(cause));
181
+ setView("error");
182
+ }
183
+ };
184
+
185
+ const updateFocus = (next: Focus) => {
186
+ saveFocus(next);
187
+ setFocus(next);
188
+ setQuestion(undefined);
189
+ setQueue(unansweredQuestions());
190
+ };
191
+
192
+ const openAttempt = async (attempt: Attempt) => {
193
+ setView("loading");
194
+ const found = await findQuestion(attempt.questionId);
195
+ if (found) {
196
+ setDetail({ question: found, attempt });
197
+ setView("detail");
198
+ } else {
199
+ setNotice(`Question ${attempt.questionId} is no longer in the local bank.`);
200
+ setView("history");
201
+ }
202
+ };
203
+
204
+ let content;
205
+ if (view === "setup") {
206
+ content = <SetupScreen location={displayPath(dataDirectory)} onAccept={() => void initialize()} onDecline={exit} />;
207
+ } else if (view === "focus") {
208
+ content = <FocusScreen focus={focus} notice={notice} onChange={updateFocus} onStart={() => void showNextQuestion(unansweredQuestions())} />;
209
+ } else if (view === "practice" && question) {
210
+ content = (
211
+ <PracticeScreen
212
+ key={question.id}
213
+ question={question}
214
+ onAnswer={(choice, duration) => void answer(choice, duration)}
215
+ onSkip={() => void showNextQuestion(skipQuestion(queue, question.id))}
216
+ />
217
+ );
218
+ } else if (view === "result" && question && result) {
219
+ content = <ResultScreen question={question} result={result} resultDetail={resultDetail} onNext={() => void showNextQuestion()} />;
220
+ } else if (view === "history") {
221
+ content = <HistoryScreen attempts={attempts.values()} notice={notice} onOpen={(attempt) => void openAttempt(attempt)} />;
222
+ } else if (view === "summary") {
223
+ content = <SummaryScreen attempts={attempts.values()} />;
224
+ } else if (view === "detail" && detail) {
225
+ content = <DetailScreen question={detail.question} attempt={detail.attempt} onBack={() => setView("history")} />;
226
+ } else if (view === "error") {
227
+ content = (
228
+ <Box flexDirection="column">
229
+ <Text bold color="red">Something went wrong.</Text>
230
+ <Text>{error}</Text>
231
+ <Text color="gray">Press q to quit.</Text>
232
+ </Box>
233
+ );
234
+ } else {
235
+ content = <Spinner label="Loading local SAT data" />;
236
+ }
237
+
238
+ return <Box width={terminal.width} height={terminal.height}>{content}</Box>;
239
+ }
240
+
241
+ function message(value: unknown): string {
242
+ return value instanceof Error ? value.message : String(value);
243
+ }
@@ -0,0 +1,39 @@
1
+ import { Box, Text, useStdout } from "ink";
2
+ import type { ReactNode } from "react";
3
+
4
+ const globalShortcuts = "f focus · h history · s stats · p practice · q quit";
5
+
6
+ type ScreenProps = {
7
+ title: string;
8
+ detail?: ReactNode;
9
+ children: ReactNode;
10
+ footer?: string;
11
+ globalNavigation?: boolean;
12
+ };
13
+
14
+ export function HorizontalRule({ width }: { width: number }) {
15
+ return <Text color="gray">{"─".repeat(Math.max(1, width - 1))}</Text>;
16
+ }
17
+
18
+ export function PaneTitle({ active, children }: { active: boolean; children: string }) {
19
+ return <Text bold color="cyan" underline={active}>{children}</Text>;
20
+ }
21
+
22
+ export function Screen({ title, detail, children, footer, globalNavigation = true }: ScreenProps) {
23
+ const { stdout } = useStdout();
24
+ const shortcuts = [footer, globalNavigation ? globalShortcuts : undefined].filter(Boolean).join(" · ");
25
+ const renderedDetail = typeof detail === "string"
26
+ ? <Text color="gray">{detail}</Text>
27
+ : detail ?? <Text color="gray">offline</Text>;
28
+ return (
29
+ <Box flexDirection="column" height="100%">
30
+ <Box justifyContent="space-between">
31
+ <Text bold color="cyan">saterminal :: {title}</Text>
32
+ {renderedDetail}
33
+ </Box>
34
+ <Box flexGrow={1} flexDirection="column" paddingTop={1}>{children}</Box>
35
+ <HorizontalRule width={stdout.columns ?? 80} />
36
+ <Text color="gray">{shortcuts}</Text>
37
+ </Box>
38
+ );
39
+ }
@@ -0,0 +1,91 @@
1
+ import { Box, Text } from "ink";
2
+ import { htmlToText } from "@/text/html.ts";
3
+ import { wrapText } from "@/text/wrap.ts";
4
+ import type { Question } from "@/questions/question.ts";
5
+
6
+ export type ChoiceLine = {
7
+ text: string;
8
+ choiceIndex?: number;
9
+ };
10
+
11
+ export type ChoiceRange = {
12
+ start: number;
13
+ end: number;
14
+ };
15
+
16
+ type ViewportProps = {
17
+ question: Question;
18
+ width: number;
19
+ height: number;
20
+ scroll?: number;
21
+ };
22
+
23
+ type AnswerChoicesProps = ViewportProps & {
24
+ selected: number;
25
+ };
26
+
27
+ export function QuestionContent({ question, width, height, scroll = 0 }: ViewportProps) {
28
+ const passage = htmlToText(question.passage ?? "");
29
+ const prompt = htmlToText(question.prompt);
30
+ const lines = [
31
+ ...(passage ? wrapText(passage, width) : []),
32
+ ...(passage ? [""] : []),
33
+ ...wrapText(prompt, width),
34
+ ];
35
+ const maximum = Math.max(0, lines.length - height);
36
+ const start = Math.max(0, Math.min(scroll, maximum));
37
+ return <Text>{lines.slice(start, start + height).join("\n")}</Text>;
38
+ }
39
+
40
+ export function AnswerChoices({ question, selected, width, height, scroll = 0 }: AnswerChoicesProps) {
41
+ const { lines } = answerChoiceLayout(question, selected, width);
42
+ const start = clampedScroll(scroll, lines.length, height);
43
+ const visible = lines.slice(start, start + height);
44
+ return (
45
+ <Box flexDirection="column">
46
+ {visible.map((entry, index) => (
47
+ <Text key={`${start}-${index}`} color={entry.choiceIndex === selected ? "yellow" : undefined} bold={entry.choiceIndex === selected}>
48
+ {entry.text}
49
+ </Text>
50
+ ))}
51
+ </Box>
52
+ );
53
+ }
54
+
55
+ export function answerChoiceLayout(question: Question, selected: number | undefined, width: number): { lines: ChoiceLine[]; ranges: ChoiceRange[] } {
56
+ const markerWidth = Math.max(...question.choices.map((choice) => Bun.stringWidth(` ${choice.key}.`)));
57
+ const choices = question.choices.map((choice, index) => ({
58
+ index,
59
+ lines: hangingChoiceLines(choice.key, choice.content, index === selected, markerWidth, width),
60
+ }));
61
+ const ranges: ChoiceRange[] = [];
62
+ let cursor = 0;
63
+ const lines = choices.flatMap((choice) => {
64
+ ranges.push({ start: cursor, end: cursor + choice.lines.length });
65
+ const rendered = [
66
+ ...choice.lines.map((text) => ({ text, choiceIndex: choice.index })),
67
+ ...(choice.index === choices.length - 1 ? [] : [{ text: " " }]),
68
+ ];
69
+ cursor += rendered.length;
70
+ return rendered;
71
+ });
72
+ return { lines, ranges };
73
+ }
74
+
75
+ export function clampedScroll(scroll: number, lineCount: number, height: number): number {
76
+ return Math.max(0, Math.min(scroll, Math.max(0, lineCount - height)));
77
+ }
78
+
79
+ export function revealRange(scroll: number, height: number, range: ChoiceRange | undefined): number {
80
+ if (!range) return scroll;
81
+ if (range.start < scroll) return range.start;
82
+ if (range.end > scroll + height) return Math.max(0, range.end - height);
83
+ return scroll;
84
+ }
85
+
86
+ function hangingChoiceLines(key: string, content: string, selected: boolean, markerWidth: number, width: number): string[] {
87
+ const marker = `${selected ? ">" : " "} ${key}.`.padEnd(markerWidth);
88
+ const indent = " ".repeat(markerWidth + 1);
89
+ return wrapText(htmlToText(content).replace(/\n+/g, " "), Math.max(10, width - indent.length))
90
+ .map((line, index) => index === 0 ? `${marker} ${line}` : `${indent}${line}`);
91
+ }
@@ -0,0 +1,16 @@
1
+ import { useStdout } from "ink";
2
+ import { useEffect, useState } from "react";
3
+
4
+ export type TerminalSize = { width: number; height: number };
5
+
6
+ export function useTerminalSize(): TerminalSize {
7
+ const { stdout } = useStdout();
8
+ const read = () => ({ width: stdout.columns ?? 80, height: stdout.rows ?? 24 });
9
+ const [size, setSize] = useState<TerminalSize>(read);
10
+ useEffect(() => {
11
+ const resize = () => setSize(read());
12
+ stdout.on("resize", resize);
13
+ return () => { stdout.off("resize", resize); };
14
+ }, [stdout]);
15
+ return size;
16
+ }
@@ -0,0 +1,20 @@
1
+ import { Text, useInput } from "ink";
2
+ import { htmlToText } from "@/text/html.ts";
3
+ import type { Question } from "@/questions/question.ts";
4
+ import type { Attempt } from "@/progress/attempt.ts";
5
+ import { Screen } from "@/tui/components/chrome.tsx";
6
+
7
+ export function DetailScreen({ question, attempt, onBack }: { question: Question; attempt: Attempt; onBack: () => void }) {
8
+ useInput((_input, key) => { if (key.escape) onBack(); });
9
+ return (
10
+ <Screen title="question details" detail={attempt.outcome} footer="esc back">
11
+ <Text bold color={attempt.outcome === "incorrect" ? "red" : attempt.outcome === "corrected" ? "yellow" : "green"}>{attempt.outcome.toUpperCase()}</Text>
12
+ <Text color="gray">{question.domain} · {question.skill} · difficulty {question.difficulty} · {question.id}</Text>
13
+ <Text> </Text>
14
+ <Text bold>Correct answer: <Text color="green">{question.correctAnswers.join(", ")}</Text></Text>
15
+ <Text> </Text>
16
+ <Text bold color="cyan">Explanation</Text>
17
+ <Text>{htmlToText(question.explanation ?? "No explanation is available.")}</Text>
18
+ </Screen>
19
+ );
20
+ }