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
package/src/tui/app.ts DELETED
@@ -1,134 +0,0 @@
1
- import { defaultFocus } from "../focus.ts";
2
- import { ensureStateFiles, loadAttempts, loadFocus, stateDirExists } from "../state.ts";
3
- import { handleKey, loadNextQuestion } from "./input.ts";
4
- import { createFrameRenderer, render } from "./render.ts";
5
- import { term } from "./kit.ts";
6
- import type { AppState, KeyData } from "./types.ts";
7
- import type { Attempt } from "../types.ts";
8
-
9
- export type TuiOptions = {
10
- mode?: "practice" | "review";
11
- };
12
-
13
- async function loadPersistedState(state: AppState, options: TuiOptions = {}): Promise<void> {
14
- await ensureStateFiles();
15
- state.attempts = await loadAttempts();
16
- state.focus = await loadFocus();
17
- state.focusIndex = 1;
18
- state.focusColumn = 0;
19
- state.focusRow = 1;
20
- state.notice = undefined;
21
-
22
- if (options.mode === "review") {
23
- state.reviewMode = true;
24
- state.reviewQuestionIds = reviewQuestionIds(state.attempts);
25
- if (state.reviewQuestionIds.length === 0) {
26
- state.notice = "No review queue yet. Miss or correct a question first.";
27
- state.view = "history";
28
- return;
29
- }
30
-
31
- await loadNextQuestion(state);
32
- return;
33
- }
34
-
35
- state.view = "focus";
36
- }
37
-
38
- export async function runTui(options: TuiOptions = {}): Promise<void> {
39
- const state: AppState = {
40
- attempts: new Map(),
41
- skippedIds: new Set(),
42
- reviewMode: options.mode === "review",
43
- focus: defaultFocus,
44
- focusIndex: 1,
45
- focusColumn: 0,
46
- focusRow: 1,
47
- view: "loading",
48
- selected: 0,
49
- questionScroll: 0,
50
- answerScroll: 0,
51
- activePane: "answers",
52
- elapsedMs: 0,
53
- timerPaused: false,
54
- timerHidden: false,
55
- historyIndex: 0,
56
- };
57
-
58
- term.fullscreen(true);
59
- term.hideCursor();
60
- term.grabInput(true);
61
- const renderer = createFrameRenderer();
62
- renderer.clear();
63
- const tick = setInterval(() => {
64
- if (state.view === "practice" && !state.timerPaused) {
65
- render(renderer, state);
66
- }
67
- }, 1000);
68
-
69
- const cleanup = () => {
70
- clearInterval(tick);
71
- term.grabInput(false);
72
- term.hideCursor(false);
73
- term.fullscreen(false);
74
- term.processExit(0);
75
- };
76
-
77
- process.on("SIGINT", cleanup);
78
- term.on("resize", () => {
79
- renderer.clear();
80
- render(renderer, state);
81
- });
82
-
83
- try {
84
- if (await stateDirExists()) {
85
- await loadPersistedState(state, options);
86
- } else {
87
- state.view = "setup";
88
- }
89
- } catch (error) {
90
- state.view = "error";
91
- state.error = error instanceof Error ? error.message : String(error);
92
- }
93
-
94
- render(renderer, state);
95
-
96
- term.on("key", async (name: string, _matches?: string[], data?: KeyData) => {
97
- try {
98
- if (name === "CTRL_C" || name === "q") {
99
- cleanup();
100
- return;
101
- }
102
-
103
- if (state.view === "setup") {
104
- if (name === "y" || name === "ENTER") {
105
- await loadPersistedState(state, options);
106
- } else if (name === "n") {
107
- cleanup();
108
- return;
109
- }
110
-
111
- render(renderer, state);
112
- return;
113
- }
114
-
115
- await handleKey(state, name, data);
116
- render(renderer, state);
117
- } catch (error) {
118
- state.view = "error";
119
- state.error = error instanceof Error ? error.message : String(error);
120
- render(renderer, state);
121
- }
122
- });
123
- }
124
-
125
- function reviewQuestionIds(attempts: Map<string, Attempt>): string[] {
126
- return [...attempts.values()]
127
- .filter((attempt) => attempt.outcome === "incorrect" || attempt.outcome === "corrected")
128
- .sort((a, b) => reviewPriority(a) - reviewPriority(b) || a.updated_at.localeCompare(b.updated_at))
129
- .map((attempt) => attempt.question_id);
130
- }
131
-
132
- function reviewPriority(attempt: Attempt): number {
133
- return attempt.outcome === "incorrect" ? 0 : 1;
134
- }
@@ -1,113 +0,0 @@
1
- import {
2
- difficultyLabels,
3
- difficultyOptions,
4
- domainLabels,
5
- domainOptions,
6
- skillsForDomain,
7
- skillLabels,
8
- toggleFocusRow,
9
- type FocusRow,
10
- } from "../focus.ts";
11
- import type { Domain, Focus, Skill } from "../types.ts";
12
-
13
- export type FocusGridRow = Extract<FocusRow, { kind: "option" }>;
14
-
15
- export type FocusGridColumn = {
16
- id: "difficulty" | Domain;
17
- title: string;
18
- rows: FocusGridRow[];
19
- };
20
-
21
- export type FocusGridPosition = {
22
- column: number;
23
- row: number;
24
- };
25
-
26
- export function focusGrid(focus: Focus): FocusGridColumn[] {
27
- return [
28
- {
29
- id: "difficulty",
30
- title: "Difficulty",
31
- rows: difficultyOptions.map((value) => ({
32
- kind: "option",
33
- group: "difficulties",
34
- value,
35
- label: `${value} ${difficultyLabels[value]}`,
36
- checked: focus.difficulties.includes(value),
37
- depth: 0,
38
- })),
39
- },
40
- ...domainOptions.map((domain) => domainColumn(domain, focus)),
41
- ];
42
- }
43
-
44
- export function normalizeFocusGridPosition(columns: FocusGridColumn[], position: FocusGridPosition): FocusGridPosition {
45
- const column = clamp(position.column, 0, Math.max(0, columns.length - 1));
46
- const row = clamp(position.row, 0, Math.max(0, (columns[column]?.rows.length ?? 1) - 1));
47
- return { column, row };
48
- }
49
-
50
- export function moveFocusGridPosition(
51
- columns: FocusGridColumn[],
52
- position: FocusGridPosition,
53
- direction: "up" | "down" | "next" | "previous",
54
- ): FocusGridPosition {
55
- const current = normalizeFocusGridPosition(columns, position);
56
-
57
- if (direction === "up") {
58
- return { ...current, row: Math.max(0, current.row - 1) };
59
- }
60
-
61
- if (direction === "down") {
62
- return { ...current, row: Math.min(columns[current.column].rows.length - 1, current.row + 1) };
63
- }
64
-
65
- const delta = direction === "previous" ? -1 : 1;
66
- const column = (current.column + delta + columns.length) % columns.length;
67
- return normalizeFocusGridPosition(columns, { column, row: current.row });
68
- }
69
-
70
- export function selectedFocusGridRow(focus: Focus, position: FocusGridPosition): FocusGridRow {
71
- const columns = focusGrid(focus);
72
- const current = normalizeFocusGridPosition(columns, position);
73
- return columns[current.column].rows[current.row];
74
- }
75
-
76
- export function toggleFocusGridRow(focus: Focus, position: FocusGridPosition): Focus {
77
- return toggleFocusRow(focus, selectedFocusGridRow(focus, position));
78
- }
79
-
80
- function domainColumn(domain: Domain, focus: Focus): FocusGridColumn {
81
- const skills = skillsForDomain(domain);
82
- const selected = skills.filter((skill) => focus.skills.includes(skill));
83
- const checked = selected.length > 0;
84
- const partial = checked && selected.length < skills.length;
85
-
86
- return {
87
- id: domain,
88
- title: `${domain} ${domainLabels[domain]}`,
89
- rows: [
90
- {
91
- kind: "option",
92
- group: "domains",
93
- value: domain,
94
- label: "All skills",
95
- checked,
96
- partial,
97
- depth: 0,
98
- },
99
- ...skills.map((skill) => ({
100
- kind: "option" as const,
101
- group: "skills" as const,
102
- value: skill as Skill,
103
- label: `${skill} ${skillLabels[skill]}`,
104
- checked: focus.skills.includes(skill),
105
- depth: 1,
106
- })),
107
- ],
108
- };
109
- }
110
-
111
- function clamp(value: number, min: number, max: number): number {
112
- return Math.max(min, Math.min(max, value));
113
- }
package/src/tui/frame.ts DELETED
@@ -1,210 +0,0 @@
1
- import type { Terminal } from "terminal-kit";
2
-
3
- export type TextAttr = Record<string, unknown>;
4
-
5
- type FrameCell = {
6
- char: string;
7
- attr: TextAttr;
8
- };
9
-
10
- export type FrameRun = {
11
- x: number;
12
- text: string;
13
- attr: TextAttr;
14
- };
15
-
16
- export type FrameOutput = {
17
- clear(): void;
18
- moveTo(x: number, y: number): void;
19
- eraseLineAfter(): void;
20
- reset(): void;
21
- write(value: string, attr?: TextAttr): void;
22
- };
23
-
24
- const emptyAttr: TextAttr = {};
25
-
26
- export class Frame {
27
- readonly rows: FrameCell[][];
28
-
29
- constructor(readonly width: number, readonly height: number) {
30
- this.rows = Array.from({ length: height }, () => []);
31
- }
32
-
33
- writeText(x: number, y: number, value: string, attr: TextAttr = {}, width = this.width - x): void {
34
- if (y < 0 || y >= this.height || x >= this.width || width <= 0) {
35
- return;
36
- }
37
-
38
- const start = Math.max(0, x);
39
- const content = truncate(value, width);
40
- if (!content) {
41
- return;
42
- }
43
-
44
- const row = this.rows[y];
45
- const chars = [...content].slice(Math.max(0, -x), Math.max(0, -x) + this.width - start);
46
- for (const [offset, char] of chars.entries()) {
47
- row[start + offset] = { char, attr };
48
- }
49
- }
50
-
51
- rowRuns(y: number): FrameRun[] {
52
- const row = this.rows[y] ?? [];
53
- const runs: FrameRun[] = [];
54
- let x = 0;
55
-
56
- while (x < this.width) {
57
- while (x < this.width && cellAt(row, x).char === " ") {
58
- x++;
59
- }
60
-
61
- if (x >= this.width) {
62
- break;
63
- }
64
-
65
- const start = x;
66
- const attr = cellAt(row, x).attr;
67
- const key = attrKey(attr);
68
- const chars: string[] = [];
69
-
70
- while (x < this.width && attrKey(cellAt(row, x).attr) === key) {
71
- chars.push(cellAt(row, x).char);
72
- x++;
73
- }
74
-
75
- const text = chars.join("").trimEnd();
76
- if (text) {
77
- runs.push({ x: start, text, attr });
78
- }
79
- }
80
-
81
- return runs;
82
- }
83
-
84
- rowKey(y: number): string {
85
- return this.rowRuns(y)
86
- .map((run) => `${run.x}:${attrKey(run.attr)}:${run.text}`)
87
- .join("\u001f");
88
- }
89
- }
90
-
91
- export class FrameRenderer {
92
- private previous?: {
93
- width: number;
94
- height: number;
95
- rowKeys: string[];
96
- };
97
- private cleared = false;
98
-
99
- constructor(private readonly output: FrameOutput) {}
100
-
101
- clear(): void {
102
- this.previous = undefined;
103
- this.cleared = true;
104
- this.output.reset();
105
- this.output.clear();
106
- }
107
-
108
- draw(frame: Frame): void {
109
- const rowKeys = Array.from({ length: frame.height }, (_, y) => frame.rowKey(y));
110
- const force = !this.previous || this.previous.width !== frame.width || this.previous.height !== frame.height;
111
- if (force && !this.cleared) {
112
- this.output.reset();
113
- this.output.clear();
114
- }
115
-
116
- const previous = force ? undefined : this.previous;
117
- let changed = false;
118
- for (let y = 0; y < frame.height; y++) {
119
- if (previous && previous.rowKeys[y] === rowKeys[y]) {
120
- continue;
121
- }
122
- if (!previous && !rowKeys[y]) {
123
- continue;
124
- }
125
-
126
- this.output.moveTo(1, y + 1);
127
- if (previous) {
128
- this.output.reset();
129
- this.output.eraseLineAfter();
130
- }
131
-
132
- for (const run of frame.rowRuns(y)) {
133
- this.output.moveTo(run.x + 1, y + 1);
134
- this.output.write(run.text, run.attr);
135
- }
136
- changed = true;
137
- }
138
-
139
- if (changed) {
140
- this.output.reset();
141
- }
142
- this.previous = { width: frame.width, height: frame.height, rowKeys };
143
- this.cleared = false;
144
- }
145
- }
146
-
147
- type StyledTerminal = Terminal & Record<string, Terminal>;
148
-
149
- export class TerminalFrameOutput implements FrameOutput {
150
- constructor(private readonly terminal: Terminal) {}
151
-
152
- clear(): void {
153
- this.terminal.clear();
154
- }
155
-
156
- moveTo(x: number, y: number): void {
157
- this.terminal.moveTo(x, y);
158
- }
159
-
160
- eraseLineAfter(): void {
161
- this.terminal.eraseLineAfter();
162
- }
163
-
164
- reset(): void {
165
- this.terminal.styleReset();
166
- }
167
-
168
- write(value: string, attr: TextAttr = {}): void {
169
- let output = this.terminal as StyledTerminal;
170
-
171
- if (attr.bold) {
172
- output = output.bold as StyledTerminal;
173
- }
174
- if (attr.underline) {
175
- output = output.underline as StyledTerminal;
176
- }
177
- if (attr.italic) {
178
- output = output.italic as StyledTerminal;
179
- }
180
-
181
- const color = typeof attr.color === "string" ? attr.color : undefined;
182
- if (color && typeof output[color] === "function") {
183
- output = output[color] as StyledTerminal;
184
- }
185
-
186
- output(value);
187
- }
188
- }
189
-
190
- export function truncate(value: string, width: number): string {
191
- const chars = [...value];
192
- if (chars.length <= width) {
193
- return value;
194
- }
195
- if (width <= 1) {
196
- return chars.slice(0, width).join("");
197
- }
198
- return `${chars.slice(0, width - 1).join("")}…`;
199
- }
200
-
201
- function cellAt(row: FrameCell[], x: number): FrameCell {
202
- return row[x] ?? { char: " ", attr: emptyAttr };
203
- }
204
-
205
- function attrKey(attr: TextAttr): string {
206
- return Object.keys(attr)
207
- .sort()
208
- .map((key) => `${key}:${String(attr[key])}`)
209
- .join(";");
210
- }
@@ -1,6 +0,0 @@
1
- import type { Attempt } from "../types.ts";
2
- import type { AppState } from "./types.ts";
3
-
4
- export function historyRows(state: AppState): Attempt[] {
5
- return [...state.attempts.values()].sort((a, b) => b.updated_at.localeCompare(a.updated_at));
6
- }