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
@@ -0,0 +1,244 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { useState } from "react";
3
+ import type { Focus } from "@/questions/focus.ts";
4
+ import { selectedDomains, toggleDifficulty, toggleDomain, toggleSkill } from "@/questions/focus.ts";
5
+ import type { Difficulty, DomainCode, SkillCode } from "@/questions/question.ts";
6
+ import { difficulties, difficultyLabels, domains, domainLabels, skillLabels, skillsByDomain } from "@/questions/taxonomy.ts";
7
+ import { HorizontalRule, Screen } from "@/tui/components/chrome.tsx";
8
+ import { useTerminalSize } from "@/tui/hooks/use-terminal-size.ts";
9
+
10
+ type FocusPosition = {
11
+ column: number;
12
+ row: number;
13
+ };
14
+
15
+ type FocusRow =
16
+ | { kind: "difficulty"; value: Difficulty }
17
+ | { kind: "domain"; value: DomainCode }
18
+ | { kind: "skill"; value: SkillCode };
19
+
20
+ type SelectionState = "none" | "partial" | "all";
21
+
22
+ type FocusColumn = {
23
+ id: "difficulty" | DomainCode;
24
+ rows: FocusRow[];
25
+ };
26
+
27
+ type VisibleColumn = {
28
+ column: FocusColumn;
29
+ columnIndex: number;
30
+ };
31
+
32
+ type FocusScreenProps = {
33
+ focus: Focus;
34
+ notice?: string;
35
+ onChange: (focus: Focus) => void;
36
+ onStart: () => void;
37
+ };
38
+
39
+ type FocusGridProps = {
40
+ focus: Focus;
41
+ columns: FocusColumn[];
42
+ position: FocusPosition;
43
+ width: number;
44
+ };
45
+
46
+ type FocusGroupProps = {
47
+ focus: Focus;
48
+ column: FocusColumn;
49
+ columnIndex: number;
50
+ position: FocusPosition;
51
+ width: number;
52
+ compact: boolean;
53
+ };
54
+
55
+ type DomainGridProps = {
56
+ focus: Focus;
57
+ columns: VisibleColumn[];
58
+ position: FocusPosition;
59
+ width: number;
60
+ };
61
+
62
+ type FocusChoiceProps = {
63
+ focus: Focus;
64
+ row: FocusRow;
65
+ active: boolean;
66
+ compact?: boolean;
67
+ };
68
+
69
+ export function FocusScreen({ focus, notice, onChange, onStart }: FocusScreenProps) {
70
+ const { width } = useTerminalSize();
71
+ const columns = focusColumns();
72
+ const [position, setPosition] = useState({ column: 0, row: 0 });
73
+ const normalized = { column: Math.min(position.column, columns.length - 1), row: Math.min(position.row, columns[Math.min(position.column, columns.length - 1)].rows.length - 1) };
74
+ useInput((input, key) => {
75
+ if (key.upArrow || input === "k") setPosition((current) => ({ ...current, row: Math.max(0, current.row - 1) }));
76
+ else if (key.downArrow || input === "j") setPosition((current) => ({ ...current, row: Math.min(columns[current.column].rows.length - 1, current.row + 1) }));
77
+ else if (key.tab || key.rightArrow) setPosition((current) => ({ column: (current.column + 1) % columns.length, row: Math.min(current.row, columns[(current.column + 1) % columns.length].rows.length - 1) }));
78
+ else if (key.leftArrow) setPosition((current) => ({ column: (current.column - 1 + columns.length) % columns.length, row: Math.min(current.row, columns[(current.column - 1 + columns.length) % columns.length].rows.length - 1) }));
79
+ else if (input === " ") onChange(toggleRow(focus, columns[normalized.column].rows[normalized.row]));
80
+ else if (key.return) onStart();
81
+ });
82
+ return (
83
+ <Screen title="focus" detail={`${focus.skills.length} skills · ${focus.difficulties.join(",")} · ${selectedDomains(focus).length} domains`} footer="j/k move · tab/←/→ group · space toggle · enter start">
84
+ {notice ? <Box marginBottom={1}><Text color="yellow">{notice}</Text></Box> : null}
85
+ <FocusGrid focus={focus} columns={columns} position={normalized} width={width} />
86
+ </Screen>
87
+ );
88
+ }
89
+
90
+ function FocusGrid({ focus, columns, position, width }: FocusGridProps) {
91
+ const [difficulty, ...domains] = columns;
92
+ const domainColumns = domains.map((column, index) => ({ column, columnIndex: index + 1 }));
93
+
94
+ return (
95
+ <Box flexDirection="column">
96
+ <Text bold color="cyan">Difficulty</Text>
97
+ <HorizontalRule width={width} />
98
+ <DifficultyChoices focus={focus} column={difficulty} position={position} />
99
+ <HorizontalRule width={width} />
100
+ <Box marginTop={1}>
101
+ <DomainGrid focus={focus} columns={domainColumns} position={position} width={width} />
102
+ </Box>
103
+ </Box>
104
+ );
105
+ }
106
+
107
+ function DifficultyChoices({ focus, column, position }: { focus: Focus; column: FocusColumn; position: FocusPosition }) {
108
+ return (
109
+ <Box flexDirection="column">
110
+ {column.rows.map((row, rowIndex) => (
111
+ <FocusChoice
112
+ key={`${row.kind}-${row.value}`}
113
+ focus={focus}
114
+ row={row}
115
+ active={position.column === 0 && position.row === rowIndex}
116
+ />
117
+ ))}
118
+ </Box>
119
+ );
120
+ }
121
+
122
+ function DomainGrid({ focus, columns, position, width }: DomainGridProps) {
123
+ const columnGap = 2;
124
+ const minimumColumnWidth = Math.max(...columns.flatMap((column) => [
125
+ Bun.stringWidth(columnLabel(column.column)),
126
+ ...column.column.rows.map((row) => focusChoiceWidth(row)),
127
+ ]));
128
+ const overviewColumns = Math.min(columns.length, Math.floor((width + columnGap) / (minimumColumnWidth + columnGap)));
129
+ const columnCount = Math.max(1, overviewColumns);
130
+ const visibleColumns: VisibleColumn[] = columnCount > 1
131
+ ? columns
132
+ : [columns[Math.max(0, position.column - 1)]];
133
+ const renderedColumnCount = Math.min(columnCount, visibleColumns.length);
134
+ const columnWidth = Math.floor((width - columnGap * (renderedColumnCount - 1)) / renderedColumnCount);
135
+ const groupRows = chunks(visibleColumns, renderedColumnCount);
136
+
137
+ return (
138
+ <Box flexDirection="column">
139
+ {groupRows.map((groupRow, rowIndex) => (
140
+ <Box
141
+ key={groupRow.map(({ column }) => column.id).join("-")}
142
+ columnGap={columnGap}
143
+ marginBottom={rowIndex === groupRows.length - 1 ? 0 : 1}
144
+ >
145
+ {groupRow.map(({ column, columnIndex }) => (
146
+ <FocusGroup
147
+ key={column.id}
148
+ focus={focus}
149
+ column={column}
150
+ columnIndex={columnIndex}
151
+ position={position}
152
+ width={columnWidth}
153
+ compact={columnWidth < minimumColumnWidth}
154
+ />
155
+ ))}
156
+ </Box>
157
+ ))}
158
+ </Box>
159
+ );
160
+ }
161
+
162
+ function FocusGroup({ focus, column, columnIndex, position, width, compact }: FocusGroupProps) {
163
+ return (
164
+ <Box width={width} flexDirection="column">
165
+ <Text bold color="cyan">{columnLabel(column, compact)}</Text>
166
+ {column.rows.map((row, rowIndex) => (
167
+ <FocusChoice
168
+ key={`${row.kind}-${row.value}`}
169
+ focus={focus}
170
+ row={row}
171
+ active={position.column === columnIndex && position.row === rowIndex}
172
+ compact={compact}
173
+ />
174
+ ))}
175
+ </Box>
176
+ );
177
+ }
178
+
179
+ function FocusChoice({ focus, row, active, compact = false }: FocusChoiceProps) {
180
+ const selection = rowSelection(focus, row);
181
+ const color = active ? "yellow" : selection === "all" ? "green" : selection === "partial" ? "yellow" : "gray";
182
+ const indent = row.kind === "skill" ? " " : "";
183
+ return (
184
+ <Text color={color} bold={active}>
185
+ {active ? ">" : " "} {indent}{selectionGlyph(selection)} {focusLabel(row, compact)}
186
+ </Text>
187
+ );
188
+ }
189
+
190
+ function chunks<T>(values: readonly T[], size: number): T[][] {
191
+ return Array.from(
192
+ { length: Math.ceil(values.length / size) },
193
+ (_, index) => values.slice(index * size, (index + 1) * size),
194
+ );
195
+ }
196
+
197
+ function columnLabel(column: FocusColumn, compact = false): string {
198
+ if (column.id === "difficulty") return "Difficulty";
199
+ return compact ? column.id : `${column.id} ${domainLabels[column.id]}`;
200
+ }
201
+
202
+ function focusLabel(row: FocusRow, compact = false): string {
203
+ if (row.kind === "difficulty") return `${row.value} ${difficultyLabels[row.value]}`;
204
+ if (row.kind === "domain") return "All skills";
205
+ return compact ? row.value : `${row.value} ${skillLabels[row.value]}`;
206
+ }
207
+
208
+ function focusChoiceWidth(row: FocusRow): number {
209
+ const indent = row.kind === "skill" ? 1 : 0;
210
+ return 4 + indent + Bun.stringWidth(focusLabel(row));
211
+ }
212
+
213
+ function focusColumns(): FocusColumn[] {
214
+ return [
215
+ { id: "difficulty", rows: difficulties.map((value) => ({ kind: "difficulty", value })) },
216
+ ...domains.map((domain) => ({
217
+ id: domain,
218
+ rows: [
219
+ { kind: "domain" as const, value: domain },
220
+ ...skillsByDomain[domain].map((value) => ({ kind: "skill" as const, value })),
221
+ ],
222
+ })),
223
+ ];
224
+ }
225
+
226
+ function toggleRow(focus: Focus, row: FocusRow): Focus {
227
+ if (row.kind === "difficulty") return toggleDifficulty(focus, row.value);
228
+ if (row.kind === "domain") return toggleDomain(focus, row.value);
229
+ return toggleSkill(focus, row.value);
230
+ }
231
+
232
+ function rowSelection(focus: Focus, row: FocusRow): SelectionState {
233
+ if (row.kind === "difficulty") return focus.difficulties.includes(row.value) ? "all" : "none";
234
+ if (row.kind === "skill") return focus.skills.includes(row.value) ? "all" : "none";
235
+ const selected = skillsByDomain[row.value].filter((skill) => focus.skills.includes(skill)).length;
236
+ if (selected === 0) return "none";
237
+ return selected === skillsByDomain[row.value].length ? "all" : "partial";
238
+ }
239
+
240
+ function selectionGlyph(selection: SelectionState): string {
241
+ if (selection === "all") return "●";
242
+ if (selection === "partial") return "◐";
243
+ return "○";
244
+ }
@@ -0,0 +1,33 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { useState } from "react";
3
+ import type { Attempt } from "@/progress/attempt.ts";
4
+ import { history } from "@/progress/history.ts";
5
+ import { Screen } from "@/tui/components/chrome.tsx";
6
+ import { useTerminalSize } from "@/tui/hooks/use-terminal-size.ts";
7
+
8
+ export function HistoryScreen({ attempts, notice, onOpen }: { attempts: Iterable<Attempt>; notice?: string; onOpen: (attempt: Attempt) => void }) {
9
+ const rows = history(attempts);
10
+ const { height } = useTerminalSize();
11
+ const [selected, setSelected] = useState(0);
12
+ useInput((input, key) => {
13
+ if (key.upArrow || input === "k") setSelected((value) => Math.max(0, value - 1));
14
+ else if (key.downArrow || input === "j") setSelected((value) => Math.min(rows.length - 1, value + 1));
15
+ else if (key.return && rows[selected]) onOpen(rows[selected]);
16
+ });
17
+ const visible = Math.max(5, height - 7);
18
+ const start = Math.max(0, Math.min(selected - visible + 1, rows.length - visible));
19
+ return (
20
+ <Screen title="history" detail={`${rows.length} answered`} footer="j/k move · enter details">
21
+ {notice ? <Text color="yellow">{notice}</Text> : null}
22
+ {rows.length ? <Text color="gray"> question result skill answered</Text> : <Text color="gray">No attempts yet.</Text>}
23
+ <Box flexDirection="column">
24
+ {rows.slice(start, start + visible).map((attempt, offset) => {
25
+ const active = start + offset === selected;
26
+ return <Text key={attempt.questionId} color={active ? "yellow" : attempt.outcome === "incorrect" ? "red" : attempt.outcome === "corrected" ? "yellow" : "green"} bold={active}>{active ? ">" : " "} {attempt.questionId.padEnd(10)} {attempt.outcome.padEnd(10)} {(attempt.skill ?? "-").padEnd(5)} {shortDate(attempt.answeredAt)}</Text>;
27
+ })}
28
+ </Box>
29
+ </Screen>
30
+ );
31
+ }
32
+
33
+ function shortDate(value: string): string { const date = new Date(value); return `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; }
@@ -0,0 +1,159 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { useEffect, useState } from "react";
3
+ import { openQuestionPage } from "@/questions/practice-sat.ts";
4
+ import type { Question } from "@/questions/question.ts";
5
+ import { hasHtmlTable } from "@/text/html.ts";
6
+ import { formatDuration } from "@/text/duration.ts";
7
+ import {
8
+ AnswerChoices,
9
+ answerChoiceLayout,
10
+ QuestionContent,
11
+ revealRange,
12
+ } from "@/tui/components/question-content.tsx";
13
+ import { PaneTitle, Screen } from "@/tui/components/chrome.tsx";
14
+ import { useTerminalSize } from "@/tui/hooks/use-terminal-size.ts";
15
+
16
+ type PracticePane = "question" | "answers";
17
+
18
+ type PracticeScreenProps = {
19
+ question: Question;
20
+ onAnswer: (answer: string, duration: number) => void;
21
+ onSkip: () => void;
22
+ };
23
+
24
+ type QuestionPaneProps = {
25
+ active: boolean;
26
+ question: Question;
27
+ width: number;
28
+ height: number;
29
+ scroll: number;
30
+ };
31
+
32
+ type AnswerPaneProps = QuestionPaneProps & {
33
+ selected: number;
34
+ };
35
+
36
+ export function PracticeScreen({ question, onAnswer, onSkip }: PracticeScreenProps) {
37
+ const { width, height } = useTerminalSize();
38
+ const sideBySide = width >= 80;
39
+ const paneWidth = sideBySide ? Math.floor((width - 3) / 2) : width;
40
+ const viewportHeight = Math.max(5, height - 8);
41
+ const pageSize = Math.max(4, viewportHeight - 2);
42
+ const [activePane, setActivePane] = useState<PracticePane>("question");
43
+ const [selected, setSelected] = useState(0);
44
+ const [questionScroll, setQuestionScroll] = useState(0);
45
+ const [answerScroll, setAnswerScroll] = useState(0);
46
+ const [elapsed, setElapsed] = useState(0);
47
+ const [paused, setPaused] = useState(false);
48
+ const [timerVisible, setTimerVisible] = useState(true);
49
+ const unsupported = hasHtmlTable(
50
+ question.passage,
51
+ question.prompt,
52
+ question.explanation,
53
+ ...question.choices.map((choice) => choice.content),
54
+ );
55
+
56
+ useEffect(() => {
57
+ if (paused) return;
58
+ const timer = setInterval(() => setElapsed((value) => value + 1), 1000);
59
+ return () => clearInterval(timer);
60
+ }, [paused]);
61
+
62
+ useEffect(() => {
63
+ const range = answerChoiceLayout(question, selected, paneWidth).ranges[selected];
64
+ setAnswerScroll((current) => revealRange(current, viewportHeight, range));
65
+ }, [paneWidth, question, selected, viewportHeight]);
66
+
67
+ const scrollActivePane = (distance: number) => {
68
+ const update = (current: number) => Math.max(0, current + distance);
69
+ if (activePane === "question") setQuestionScroll(update);
70
+ else setAnswerScroll(update);
71
+ };
72
+
73
+ useInput((input, key) => {
74
+ if (input === " ") setPaused((value) => !value);
75
+ else if (input === "t") setTimerVisible((value) => !value);
76
+ else if (unsupported && input === "o") openQuestionPage(question);
77
+ else if (unsupported && (input === "n" || input === "x" || key.return)) onSkip();
78
+ else if (unsupported) return;
79
+ else if (key.tab || key.leftArrow || key.rightArrow) {
80
+ setActivePane((current) => current === "question" ? "answers" : "question");
81
+ } else if (key.upArrow || input === "k") {
82
+ setSelected((value) => Math.max(0, value - 1));
83
+ setActivePane("answers");
84
+ } else if (key.downArrow || input === "j") {
85
+ setSelected((value) => Math.min(question.choices.length - 1, value + 1));
86
+ setActivePane("answers");
87
+ } else if (key.pageUp || input === "[") scrollActivePane(-pageSize);
88
+ else if (key.pageDown || input === "]") scrollActivePane(pageSize);
89
+ else if (key.return && question.choices[selected]) onAnswer(question.choices[selected].key, elapsed);
90
+ else {
91
+ const direct = question.choices.findIndex((choice) => choice.key.toLowerCase() === input.toLowerCase());
92
+ if (direct >= 0) {
93
+ setSelected(direct);
94
+ setActivePane("answers");
95
+ }
96
+ }
97
+ });
98
+
99
+ const timer = timerVisible ? (
100
+ <Text bold color={paused ? "yellow" : "cyan"}>
101
+ {formatDuration(elapsed)}{paused ? " PAUSED" : ""}
102
+ </Text>
103
+ ) : <Text color="gray">timer hidden</Text>;
104
+
105
+ const footer = unsupported
106
+ ? "o open · n/x/enter skip · space pause · t timer"
107
+ : "j/k or A-D choose · enter submit · tab/←/→ pane · [/] scroll · space pause · t timer";
108
+
109
+ return (
110
+ <Screen title="practice" detail={timer} footer={footer}>
111
+ {unsupported ? (
112
+ <Box flexDirection="column" paddingTop={1}>
113
+ <Text bold color="yellow">This question contains a table that needs a browser.</Text>
114
+ <Text>Press o to open it, then answer there or skip it here.</Text>
115
+ </Box>
116
+ ) : (
117
+ <Box flexDirection={sideBySide ? "row" : "column"} gap={3}>
118
+ {(sideBySide || activePane === "question") && (
119
+ <QuestionPane
120
+ active={activePane === "question"}
121
+ question={question}
122
+ width={paneWidth}
123
+ height={viewportHeight}
124
+ scroll={questionScroll}
125
+ />
126
+ )}
127
+ {(sideBySide || activePane === "answers") && (
128
+ <AnswerPane
129
+ active={activePane === "answers"}
130
+ question={question}
131
+ selected={selected}
132
+ width={paneWidth}
133
+ height={viewportHeight}
134
+ scroll={answerScroll}
135
+ />
136
+ )}
137
+ </Box>
138
+ )}
139
+ </Screen>
140
+ );
141
+ }
142
+
143
+ function QuestionPane({ active, question, width, height, scroll }: QuestionPaneProps) {
144
+ return (
145
+ <Box width={width} flexDirection="column">
146
+ <PaneTitle active={active}>Question</PaneTitle>
147
+ <QuestionContent question={question} width={width} height={height} scroll={scroll} />
148
+ </Box>
149
+ );
150
+ }
151
+
152
+ function AnswerPane({ active, question, selected, width, height, scroll }: AnswerPaneProps) {
153
+ return (
154
+ <Box width={width} flexDirection="column">
155
+ <PaneTitle active={active}>Answers</PaneTitle>
156
+ <AnswerChoices question={question} selected={selected} width={width} height={height} scroll={scroll} />
157
+ </Box>
158
+ );
159
+ }
@@ -0,0 +1,169 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { useState } from "react";
3
+ import type { AnswerRecord, Outcome } from "@/progress/attempt.ts";
4
+ import type { ResultDetail } from "@/preferences/index.ts";
5
+ import type { Difficulty, Question } from "@/questions/question.ts";
6
+ import { difficultyLabels, domainLabels, skillLabels } from "@/questions/taxonomy.ts";
7
+ import { formatDuration } from "@/text/duration.ts";
8
+ import { htmlToText } from "@/text/html.ts";
9
+ import { wrapText } from "@/text/wrap.ts";
10
+ import { PaneTitle, Screen } from "@/tui/components/chrome.tsx";
11
+ import { answerChoiceLayout, clampedScroll, QuestionContent, type ChoiceLine } from "@/tui/components/question-content.tsx";
12
+ import { useTerminalSize } from "@/tui/hooks/use-terminal-size.ts";
13
+
14
+ type ResultPane = "question" | "review";
15
+
16
+ type ResultScreenProps = {
17
+ question: Question;
18
+ result: AnswerRecord;
19
+ resultDetail: ResultDetail;
20
+ onNext: () => void;
21
+ };
22
+
23
+ type ReviewLine = ChoiceLine & {
24
+ kind?: "heading" | "explanation";
25
+ };
26
+
27
+ type ReviewContentProps = {
28
+ question: Question;
29
+ result: AnswerRecord;
30
+ width: number;
31
+ height: number;
32
+ scroll: number;
33
+ };
34
+
35
+ const verdictColorMap: Record<Outcome, "red" | "yellow" | "green"> = {
36
+ "correct": "green",
37
+ "corrected": "yellow",
38
+ "incorrect": "red",
39
+ };
40
+ const difficultyColorMap: Record<Difficulty, "red" | "yellow" | "green"> = {
41
+ "E": "green",
42
+ "M": "yellow",
43
+ "H": "red",
44
+ };
45
+
46
+ export function ResultScreen({ question, result, resultDetail, onNext }: ResultScreenProps) {
47
+ const { width, height } = useTerminalSize();
48
+ const sideBySide = width >= 80;
49
+ const paneWidth = sideBySide ? Math.floor((width - 3) / 2) : width;
50
+ const viewportHeight = Math.max(5, height - 8);
51
+ const contextHeight = answerContextHeight(question, result, resultDetail, paneWidth);
52
+ const reviewHeight = Math.max(5, viewportHeight - contextHeight - 1);
53
+ const pageSize = Math.max(4, viewportHeight - 2);
54
+ const [activePane, setActivePane] = useState<ResultPane>("review");
55
+ const [questionScroll, setQuestionScroll] = useState(0);
56
+ const [reviewScroll, setReviewScroll] = useState(0);
57
+
58
+ const scrollActivePane = (distance: number) => {
59
+ const update = (current: number) => Math.max(0, current + distance);
60
+ if (activePane === "question") setQuestionScroll(update);
61
+ else setReviewScroll(update);
62
+ };
63
+
64
+ useInput((input, key) => {
65
+ if (input === "n" || key.return) onNext();
66
+ else if (key.tab || key.leftArrow || key.rightArrow) {
67
+ setActivePane((current) => current === "question" ? "review" : "question");
68
+ } else if (key.upArrow || input === "k") scrollActivePane(-1);
69
+ else if (key.downArrow || input === "j") scrollActivePane(1);
70
+ else if (key.pageUp || input === "[") scrollActivePane(-pageSize);
71
+ else if (key.pageDown || input === "]") scrollActivePane(pageSize);
72
+ });
73
+
74
+ const verdict = result.attempt.outcome.toUpperCase();
75
+ const verdictColor = verdictColorMap[result.attempt.outcome];
76
+
77
+ return (
78
+ <Screen
79
+ title="answer"
80
+ detail={<Text bold color={verdictColor}>{verdict}</Text>}
81
+ footer="tab/←/→ pane · j/k line · [/] page · enter/n next"
82
+ >
83
+ <Box flexDirection={sideBySide ? "row" : "column"} gap={3}>
84
+ {(sideBySide || activePane === "question") && (
85
+ <Box width={paneWidth} flexDirection="column">
86
+ <PaneTitle active={activePane === "question"}>Question</PaneTitle>
87
+ <QuestionContent question={question} width={paneWidth} height={viewportHeight} scroll={questionScroll} />
88
+ </Box>
89
+ )}
90
+ {(sideBySide || activePane === "review") && (
91
+ <Box width={paneWidth} flexDirection="column">
92
+
93
+ <PaneTitle active={activePane === "review"}>Answer & Explanation</PaneTitle>
94
+
95
+ <Box marginTop={1}>
96
+ <AnswerContext question={question} result={result} resultDetail={resultDetail} />
97
+ </Box>
98
+
99
+ <Box marginTop={1}>
100
+ <ReviewContent
101
+ question={question}
102
+ result={result}
103
+ width={paneWidth}
104
+ height={reviewHeight}
105
+ scroll={reviewScroll}
106
+ />
107
+ </Box>
108
+
109
+ </Box>
110
+ )}
111
+ </Box>
112
+ </Screen>
113
+ );
114
+ }
115
+
116
+ function AnswerContext({ question, result, resultDetail }: { question: Question; result: AnswerRecord; resultDetail: ResultDetail }) {
117
+ const showDifficulty = resultDetail !== "brief";
118
+ const showTaxonomy = resultDetail === "detailed";
119
+ return (
120
+ <Box flexDirection="column">
121
+ <Text>
122
+ <Text bold color="cyan">{formatDuration(result.attempt.durationSeconds)}</Text>
123
+ {showDifficulty && <Text color="gray"> · </Text>}
124
+ {showDifficulty && <Text color={difficultyColorMap[question.difficulty]}>{difficultyLabels[question.difficulty]} difficulty</Text>}
125
+ </Text>
126
+ {showTaxonomy && <Text><Text color="magenta">{question.domain}</Text><Text color="gray"> {domainLabels[question.domain]}</Text></Text>}
127
+ {showTaxonomy && <Text><Text color="blue">{question.skill}</Text><Text color="gray"> {skillLabels[question.skill]}</Text></Text>}
128
+ </Box>
129
+ );
130
+ }
131
+
132
+ function ReviewContent({ question, result, width, height, scroll }: ReviewContentProps) {
133
+ const selected = question.choices.findIndex((choice) => choice.key === result.answer);
134
+ const choiceLines = answerChoiceLayout(question, selected, width).lines;
135
+ const explanation = htmlToText(question.explanation ?? "No explanation is available.");
136
+ const lines: ReviewLine[] = [
137
+ ...choiceLines,
138
+ { text: " " },
139
+ { text: " " },
140
+ { text: "Explanation", kind: "heading" },
141
+ ...wrapText(explanation, width).map((text) => ({ text, kind: "explanation" as const })),
142
+ ];
143
+ const start = clampedScroll(scroll, lines.length, height);
144
+ return (
145
+ <Box flexDirection="column">
146
+ {lines.slice(start, start + height).map((line, index) => {
147
+ const choice = line.choiceIndex === undefined ? undefined : question.choices[line.choiceIndex];
148
+ const correct = choice ? question.correctAnswers.includes(choice.key) : false;
149
+ const chosen = choice?.key === result.answer;
150
+ const color = line.kind === "heading" ? "cyan" : correct ? "green" : chosen ? "red" : undefined;
151
+ return (
152
+ <Text key={`${start}-${index}`} color={color} bold={line.kind === "heading" || correct || chosen}>
153
+ {line.text}
154
+ </Text>
155
+ );
156
+ })}
157
+ </Box>
158
+ );
159
+ }
160
+
161
+ function answerContextHeight(question: Question, result: AnswerRecord, resultDetail: ResultDetail, width: number): number {
162
+ const showDifficulty = resultDetail !== "brief";
163
+ const showTaxonomy = resultDetail === "detailed";
164
+ const lines = [
165
+ `${formatDuration(result.attempt.durationSeconds)}${showDifficulty ? ` · ${difficultyLabels[question.difficulty]} difficulty` : ""}`,
166
+ ...showTaxonomy ? [`${question.domain} ${domainLabels[question.domain]}`, `${question.skill} ${skillLabels[question.skill]}`] : [],
167
+ ];
168
+ return lines.reduce((height, line) => height + Math.max(1, Math.ceil(Bun.stringWidth(line) / width)), 0);
169
+ }
@@ -0,0 +1,17 @@
1
+ import { Text, useInput } from "ink";
2
+ import { Screen } from "@/tui/components/chrome.tsx";
3
+
4
+ export function SetupScreen({ location, onAccept, onDecline }: { location: string; onAccept: () => void; onDecline: () => void }) {
5
+ useInput((input, key) => {
6
+ if (input === "y" || key.return) onAccept();
7
+ else if (input === "n") onDecline();
8
+ });
9
+ return (
10
+ <Screen title="setup" footer="y/enter allow · n decline · q quit" globalNavigation={false}>
11
+ <Text bold>Keep your SAT progress locally?</Text>
12
+ <Text color="gray">Questions, answers, focus, and history stay on this computer.</Text>
13
+ <Text> </Text>
14
+ <Text color="cyan">{location}</Text>
15
+ </Screen>
16
+ );
17
+ }
@@ -0,0 +1,16 @@
1
+ import { Box, Text } from "ink";
2
+ import type { Attempt } from "@/progress/attempt.ts";
3
+ import { progressStatistics } from "@/progress/statistics.ts";
4
+ import { Screen } from "@/tui/components/chrome.tsx";
5
+
6
+ export function SummaryScreen({ attempts }: { attempts: Iterable<Attempt> }) {
7
+ const stats = progressStatistics(attempts);
8
+ const rows = [["answered", stats.answered], ["correct", stats.correct], ["incorrect", stats.incorrect], ["corrected", stats.corrected], ["accuracy", `${Math.round(stats.accuracy * 100)}%`], ["average", `${stats.averageSeconds.toFixed(1)}s`]] as const;
9
+ return (
10
+ <Screen title="stats" detail={`${stats.mastered}/${stats.answered} mastered`}>
11
+ <Box flexDirection="column">
12
+ {rows.map(([label, value]) => <Text key={label}><Text color="gray">{label.padEnd(12)}</Text> <Text bold color={label === "incorrect" ? "red" : label === "corrected" ? "yellow" : "cyan"}>{value}</Text></Text>)}
13
+ </Box>
14
+ </Screen>
15
+ );
16
+ }