learn-loops 0.4.3 → 0.4.4
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/dist/cli.js +20 -3
- package/dist/store.js +8 -1
- package/dist/table.js +50 -0
- package/dist/ui.js +15 -0
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
|
-
import { listTopics, loadProfile, configDir } from "./store.js";
|
|
3
|
+
import { listTopics, loadProfile, configDir, deleteTopic } from "./store.js";
|
|
4
4
|
import { PROVIDER_HELP, allStatuses, noProviderMessage, setNotifier } from "./llm.js";
|
|
5
5
|
import { isDue } from "./srs.js";
|
|
6
6
|
import { currentStreak, doneToday, loadStats, recordActivity, saveCheckin } from "./stats.js";
|
|
@@ -19,9 +19,12 @@ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as
|
|
|
19
19
|
loops streak your streak
|
|
20
20
|
loops map show a topic's map and prerequisite diagram
|
|
21
21
|
loops topics list your topics and progress
|
|
22
|
+
loops delete delete a topic and all its notes and progress
|
|
22
23
|
loops profile edit how you like to learn
|
|
23
24
|
loops --help this help
|
|
24
25
|
|
|
26
|
+
To delete a course: run ${t.bold("loops delete")}, or choose "Delete a topic" from the menu.
|
|
27
|
+
|
|
25
28
|
Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, LOOPS_ALLOW_API_BILLING=1 (allow pay-per-token logins), NO_COLOR`;
|
|
26
29
|
const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
|
|
27
30
|
function progressOf(tp) {
|
|
@@ -102,12 +105,15 @@ async function startScreen(profile, topics, rec) {
|
|
|
102
105
|
}),
|
|
103
106
|
{ value: "__new", label: t.accent("+ Start something new") },
|
|
104
107
|
{ value: "__menu", label: t.dim("Go to the main menu") },
|
|
108
|
+
{ value: "__delete", label: t.dim("Delete a topic"), hint: "or run `loops delete`" },
|
|
105
109
|
],
|
|
106
110
|
}));
|
|
107
111
|
if (choice === "__menu")
|
|
108
112
|
return;
|
|
109
113
|
if (choice === "__new")
|
|
110
114
|
return dispatch("new", profile);
|
|
115
|
+
if (choice === "__delete")
|
|
116
|
+
return dispatch("delete", profile);
|
|
111
117
|
const tp = recent[Number(choice)];
|
|
112
118
|
if (tp.state.map && tp.state.phase === "survey" && !tp.state.progress[tp.state.map.areas[0].id]?.surveyed)
|
|
113
119
|
printMap(tp);
|
|
@@ -134,7 +140,7 @@ async function menu(profile) {
|
|
|
134
140
|
...(due ? [{ value: "review", label: "Review flashcards", hint: `${due} due${star("review")}` }] : []),
|
|
135
141
|
...(cards >= 4 ? [{ value: "quiz", label: "Mixed quiz", hint: `interleaved, weakest first${star("quiz")}` }] : []),
|
|
136
142
|
...(topics.some((tp) => tp.state.mistakes?.length) ? [{ value: "mistakes", label: "Mistake journal" }] : []),
|
|
137
|
-
...(topics.length ? [{ value: "topics", label: "My topics" }] : []),
|
|
143
|
+
...(topics.length ? [{ value: "topics", label: "My topics" }, { value: "delete", label: "Delete a topic" }] : []),
|
|
138
144
|
];
|
|
139
145
|
// put the recommendation first
|
|
140
146
|
opts.sort((a, b) => Number(b.value === rec) - Number(a.value === rec));
|
|
@@ -216,6 +222,17 @@ async function dispatch(action, profile, arg) {
|
|
|
216
222
|
printMap(tp);
|
|
217
223
|
break;
|
|
218
224
|
}
|
|
225
|
+
case "delete": {
|
|
226
|
+
if (!topics.length)
|
|
227
|
+
return p.log.warn("No topics to delete.");
|
|
228
|
+
const tp = arg ? topics.find((x) => x.state.title.toLowerCase() === arg.toLowerCase()) ?? (await chooseTopic(topics, "Which topic?")) : await chooseTopic(topics, "Which topic do you want to delete?");
|
|
229
|
+
const ok = must(await p.confirm({ message: `Delete "${tp.state.title}" and all its notes, cards and progress? This can't be undone.`, initialValue: false }));
|
|
230
|
+
if (!ok)
|
|
231
|
+
return p.log.info("Kept it.");
|
|
232
|
+
await deleteTopic(tp);
|
|
233
|
+
p.log.success(t.ok(`Deleted "${tp.state.title}".`));
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
219
236
|
case "profile":
|
|
220
237
|
await onboard(profile);
|
|
221
238
|
break;
|
|
@@ -255,7 +272,7 @@ async function main() {
|
|
|
255
272
|
}
|
|
256
273
|
if (!cmd)
|
|
257
274
|
return menu(profile);
|
|
258
|
-
if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map"].includes(cmd))
|
|
275
|
+
if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map", "delete"].includes(cmd))
|
|
259
276
|
return dispatch(cmd, profile, rest.join(" ") || undefined);
|
|
260
277
|
console.log(HELP);
|
|
261
278
|
process.exit(1);
|
package/dist/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile, readdir, rm } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
@@ -54,6 +54,13 @@ export async function listTopics(profile) {
|
|
|
54
54
|
return topics.sort((a, b) => lastTouched(b).localeCompare(lastTouched(a)));
|
|
55
55
|
}
|
|
56
56
|
const lastTouched = (t) => t.state.cards.map((c) => c.due).sort().pop() ?? t.state.createdAt;
|
|
57
|
+
/** Removes a topic's folder (notes, cards, progress) and its category folder if that leaves it empty. */
|
|
58
|
+
export async function deleteTopic(topic) {
|
|
59
|
+
await rm(topic.dir, { recursive: true, force: true });
|
|
60
|
+
const cat = dirname(topic.dir);
|
|
61
|
+
if (!(await readdir(cat).catch(() => ["x"])).length)
|
|
62
|
+
await rm(cat, { recursive: true, force: true });
|
|
63
|
+
}
|
|
57
64
|
export async function listCategories(profile) {
|
|
58
65
|
const root = expandHome(profile.rootDir);
|
|
59
66
|
if (!existsSync(root))
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Table from "cli-table3";
|
|
2
|
+
export const isTableLine = (s) => /^\s*\|.*\|\s*$/.test(s);
|
|
3
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
4
|
+
const visible = (s) => s.replace(ANSI, "").length;
|
|
5
|
+
function splitRow(line) {
|
|
6
|
+
const body = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
7
|
+
return body.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, "|").trim());
|
|
8
|
+
}
|
|
9
|
+
const alignOf = (c) => (/^:-+:$/.test(c) ? "center" : /^-+:$/.test(c) ? "right" : "left");
|
|
10
|
+
/**
|
|
11
|
+
* Draw a markdown table (consecutive `| a | b |` lines) as a bordered, aligned box that wraps to maxWidth.
|
|
12
|
+
* `cell` styles a cell's text (e.g. inline bold/code/math); `head` styles header cells.
|
|
13
|
+
*/
|
|
14
|
+
export function renderTable(lines, cell, head, border, maxWidth) {
|
|
15
|
+
const rows = lines.map(splitRow);
|
|
16
|
+
const hasHeader = rows.length > 1 && rows[1].every((c) => /^:?-+:?$/.test(c));
|
|
17
|
+
const aligns = hasHeader ? rows[1].map(alignOf) : [];
|
|
18
|
+
const data = hasHeader ? [rows[0], ...rows.slice(2)] : rows;
|
|
19
|
+
const n = Math.max(...data.map((r) => r.length));
|
|
20
|
+
const grid = data.map((r, i) => Array.from({ length: n }, (_, c) => (hasHeader && i === 0 ? head : cell)(r[c] ?? "")));
|
|
21
|
+
const widths = Array.from({ length: n }, (_, c) => Math.max(1, ...grid.map((r) => Math.max(...r[c].split("\n").map(visible)))));
|
|
22
|
+
const budget = maxWidth - (3 * n + 1);
|
|
23
|
+
if (budget < 5 * n)
|
|
24
|
+
return stacked(data, hasHeader, cell, head);
|
|
25
|
+
while (widths.reduce((a, b) => a + b, 0) > budget)
|
|
26
|
+
widths[widths.indexOf(Math.max(...widths))]--;
|
|
27
|
+
const table = new Table({
|
|
28
|
+
chars: { top: "─", "top-mid": "┬", "top-left": "╭", "top-right": "╮", bottom: "─", "bottom-mid": "┴", "bottom-left": "╰", "bottom-right": "╯", left: "│", "left-mid": "├", mid: "─", "mid-mid": "┼", right: "│", "right-mid": "┤", middle: "│" },
|
|
29
|
+
style: { head: [], border: [], "padding-left": 1, "padding-right": 1 },
|
|
30
|
+
colWidths: widths.map((w) => w + 2),
|
|
31
|
+
colAligns: Array.from({ length: n }, (_, c) => aligns[c] ?? "left"),
|
|
32
|
+
wordWrap: true,
|
|
33
|
+
});
|
|
34
|
+
grid.forEach((r) => table.push(r));
|
|
35
|
+
let drawn = table.toString().split("\n");
|
|
36
|
+
// keep the rule under the header; drop the ones between single-line rows (they only add noise)
|
|
37
|
+
const wrapped = drawn.length - (grid.length * 2 + 1) > 0;
|
|
38
|
+
if (!wrapped)
|
|
39
|
+
drawn = drawn.filter((l, i) => !l.startsWith("├") || i === drawn.findIndex((x) => x.startsWith("├")));
|
|
40
|
+
return drawn.map((l) => l.replace(/[─│╭╮╰╯├┤┬┴┼]+/g, border));
|
|
41
|
+
}
|
|
42
|
+
/** Fallback for terminals too narrow for a grid: one "header: value" block per row. */
|
|
43
|
+
function stacked(data, hasHeader, cell, head) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const keys = hasHeader ? data[0] : [];
|
|
46
|
+
for (const r of hasHeader ? data.slice(1) : data) {
|
|
47
|
+
out.push(...r.map((v, c) => (keys[c] ? `${head(keys[c])}: ${cell(v)}` : cell(v))), "");
|
|
48
|
+
}
|
|
49
|
+
return out.slice(0, -1);
|
|
50
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -3,6 +3,7 @@ import pc from "picocolors";
|
|
|
3
3
|
import { renderDiagram } from "./diagram.js";
|
|
4
4
|
import { parsePlot, plotToBraille, renderPlot, sanitizeSvg, svgCaption } from "./figures.js";
|
|
5
5
|
import { mathToText, svgToPng, texToLines, texToPng } from "./math.js";
|
|
6
|
+
import { isTableLine, renderTable } from "./table.js";
|
|
6
7
|
import { drawImage, imageProtocol } from "./termimg.js";
|
|
7
8
|
const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
8
9
|
const c256 = (n) => (s) => (useColor ? `\x1b[38;5;${n}m${s}\x1b[39m` : s);
|
|
@@ -112,7 +113,15 @@ export function renderMd(md) {
|
|
|
112
113
|
.replace(/\*\*(.+?)\*\*/g, (_, x) => t.bold(x))
|
|
113
114
|
.replace(/`([^`]+)`/g, (_, x) => t.cyan(x))
|
|
114
115
|
.replace(/(?<![*\w])\*([^*]+)\*(?!\w)/g, (_, x) => t.italic(x));
|
|
116
|
+
let tableBuf = [];
|
|
117
|
+
const flushTable = () => {
|
|
118
|
+
const cols = process.stdout.columns ?? 100;
|
|
119
|
+
out.push(...renderTable(tableBuf, inline, (x) => t.bold(t.accent(inline(x))), t.dim, cols - 4).map((l) => " " + l));
|
|
120
|
+
tableBuf = [];
|
|
121
|
+
};
|
|
115
122
|
for (const raw of md.split("\n")) {
|
|
123
|
+
if (tableBuf.length && !isTableLine(raw))
|
|
124
|
+
flushTable();
|
|
116
125
|
// display math: $$ … $$ (one or many lines) and \[ … \]
|
|
117
126
|
if (math) {
|
|
118
127
|
const close = raw.match(/^(.*?)(\$\$|\\\])\s*$/);
|
|
@@ -162,6 +171,10 @@ export function renderMd(md) {
|
|
|
162
171
|
out.push(" " + t.dim("│ ") + t.cyan(raw));
|
|
163
172
|
continue;
|
|
164
173
|
}
|
|
174
|
+
if (isTableLine(raw)) {
|
|
175
|
+
tableBuf.push(raw);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
165
178
|
const h = raw.match(/^(#{1,4})\s+(.*)/);
|
|
166
179
|
if (h) {
|
|
167
180
|
out.push("", " " + (h[1].length <= 2 ? t.bold(t.accent(inline(h[2]))) : t.bold(inline(h[2]))));
|
|
@@ -179,6 +192,8 @@ export function renderMd(md) {
|
|
|
179
192
|
}
|
|
180
193
|
out.push(raw.trim() ? " " + inline(raw) : "");
|
|
181
194
|
}
|
|
195
|
+
if (tableBuf.length)
|
|
196
|
+
flushTable();
|
|
182
197
|
return out.join("\n");
|
|
183
198
|
}
|
|
184
199
|
/** Plain-text math for prompts, options and flashcards. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "learn-loops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "A learning CLI that uses Claude Code / Codex as your tutor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@clack/prompts": "^1.8.1",
|
|
22
22
|
"@resvg/resvg-js": "^2.6.2",
|
|
23
23
|
"beautiful-mermaid": "^1.1.3",
|
|
24
|
+
"cli-table3": "^0.6.5",
|
|
24
25
|
"mathjax-full": "^3.2.1",
|
|
25
26
|
"mathjs": "^15.2.0",
|
|
26
27
|
"picocolors": "^1.1.1",
|