learn-loops 0.1.0 → 0.4.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/README.md +64 -0
- package/dist/cli.js +138 -9
- package/dist/diagram.js +49 -0
- package/dist/figures.js +200 -0
- package/dist/flows/learn.js +115 -14
- package/dist/flows/onboard.js +14 -1
- package/dist/flows/quiz.js +104 -27
- package/dist/flows/think.js +120 -0
- package/dist/llm.js +15 -4
- package/dist/markdown.js +62 -11
- package/dist/math.js +307 -0
- package/dist/prompts.js +86 -10
- package/dist/stats.js +48 -0
- package/dist/store.js +8 -0
- package/dist/termimg.js +27 -0
- package/dist/ui.js +93 -7
- package/package.json +8 -3
package/dist/termimg.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function imageProtocol(env = process.env, isTTY = !!process.stdout.isTTY) {
|
|
2
|
+
if (env.LOOPS_IMAGES === "off")
|
|
3
|
+
return null;
|
|
4
|
+
if (env.LOOPS_IMAGES === "iterm" || env.LOOPS_IMAGES === "kitty")
|
|
5
|
+
return env.LOOPS_IMAGES; // forced
|
|
6
|
+
if (!isTTY)
|
|
7
|
+
return null;
|
|
8
|
+
if (env.TMUX)
|
|
9
|
+
return null; // needs passthrough; not worth the risk of garbage output
|
|
10
|
+
if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty" || env.TERM_PROGRAM === "ghostty")
|
|
11
|
+
return "kitty";
|
|
12
|
+
if (["iTerm.app", "WezTerm", "vscode"].includes(env.TERM_PROGRAM ?? ""))
|
|
13
|
+
return "iterm";
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/** Escape sequence that draws `png` about `cells` columns wide. */
|
|
17
|
+
export function drawImage(png, cells, protocol) {
|
|
18
|
+
const b64 = png.toString("base64");
|
|
19
|
+
if (protocol === "iterm")
|
|
20
|
+
return `\x1b]1337;File=inline=1;width=${cells};preserveAspectRatio=1:${b64}\x07`;
|
|
21
|
+
const chunks = [];
|
|
22
|
+
for (let i = 0; i < b64.length; i += 4096) {
|
|
23
|
+
const more = i + 4096 < b64.length ? 1 : 0;
|
|
24
|
+
chunks.push(`\x1b_G${i === 0 ? `a=T,f=100,c=${cells},` : ""}m=${more};${b64.slice(i, i + 4096)}\x1b\\`);
|
|
25
|
+
}
|
|
26
|
+
return chunks.join("");
|
|
27
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
+
import { renderDiagram } from "./diagram.js";
|
|
4
|
+
import { parsePlot, plotToBraille, renderPlot, sanitizeSvg, svgCaption } from "./figures.js";
|
|
5
|
+
import { mathToText, svgToPng, texToLines, texToPng } from "./math.js";
|
|
6
|
+
import { drawImage, imageProtocol } from "./termimg.js";
|
|
3
7
|
const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
4
8
|
const c256 = (n) => (s) => (useColor ? `\x1b[38;5;${n}m${s}\x1b[39m` : s);
|
|
5
9
|
export const t = {
|
|
@@ -52,35 +56,115 @@ export function bar(done, total, width = 20) {
|
|
|
52
56
|
const filled = total === 0 ? 0 : Math.round((done / total) * width);
|
|
53
57
|
return t.accent("█".repeat(filled)) + t.dim("░".repeat(width - filled)) + t.dim(` ${done}/${total}`);
|
|
54
58
|
}
|
|
55
|
-
|
|
59
|
+
let tipShown = false;
|
|
60
|
+
/** Draw a raster inline if the terminal can; returns null otherwise. */
|
|
61
|
+
function inlineImage(r, maxCells) {
|
|
62
|
+
const proto = imageProtocol();
|
|
63
|
+
if (!proto)
|
|
64
|
+
return null;
|
|
65
|
+
const cols = process.stdout.columns ?? 100;
|
|
66
|
+
const cells = Math.max(8, Math.min(Math.ceil(r.width / 8), maxCells, cols - 4));
|
|
67
|
+
return " " + drawImage(r.png, cells, proto);
|
|
68
|
+
}
|
|
69
|
+
function figureNote(what, caption) {
|
|
70
|
+
const lines = [" " + t.pink(`◆ ${what}`) + (caption ? t.dim(` — ${caption}`) : "") + t.dim(" (saved in the lesson's markdown file)")];
|
|
71
|
+
if (!tipShown) {
|
|
72
|
+
tipShown = true;
|
|
73
|
+
lines.push(" " + t.dim("tip: iTerm2, WezTerm, Kitty and Ghostty draw figures inline; elsewhere open the .md file"));
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
function renderEquation(tex) {
|
|
78
|
+
try {
|
|
79
|
+
const img = imageProtocol() ? inlineImage(texToPng(tex, true), 60) : null;
|
|
80
|
+
if (img)
|
|
81
|
+
return ["", img, ""];
|
|
82
|
+
return ["", ...texToLines(tex).map((l) => " " + t.cyan(l)), ""];
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return ["", " " + t.dim(tex.trim()), ""];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function renderFigure(kind, src) {
|
|
89
|
+
if (kind === "plot") {
|
|
90
|
+
const p = renderPlot(src);
|
|
91
|
+
if (!p)
|
|
92
|
+
return figureNote("plot", "couldn't be drawn");
|
|
93
|
+
const img = imageProtocol() ? inlineImage(p.raster, 70) : null;
|
|
94
|
+
if (img)
|
|
95
|
+
return ["", img, ""];
|
|
96
|
+
const braille = plotToBraille(parsePlot(src), Math.min(64, (process.stdout.columns ?? 100) - 14), 14, useColor);
|
|
97
|
+
return braille ? ["", ...braille.map((l) => " " + l), ""] : figureNote("plot", p.caption);
|
|
98
|
+
}
|
|
99
|
+
const svg = sanitizeSvg(src);
|
|
100
|
+
if (!svg)
|
|
101
|
+
return figureNote("figure", "couldn't be drawn");
|
|
102
|
+
const img = imageProtocol() ? inlineImage(svgToPng(svg, 2), 70) : null;
|
|
103
|
+
return ["", img ?? figureNote("figure", svgCaption(svg)).join("\n"), ""];
|
|
104
|
+
}
|
|
105
|
+
/** Terminal markdown renderer: styling, mermaid, TeX equations, SVG figures and plots. */
|
|
56
106
|
export function renderMd(md) {
|
|
57
107
|
const out = [];
|
|
58
108
|
let fence = null;
|
|
59
|
-
|
|
109
|
+
let buf = [];
|
|
110
|
+
let math = null;
|
|
111
|
+
const inline = (s) => mathToText(s)
|
|
60
112
|
.replace(/\*\*(.+?)\*\*/g, (_, x) => t.bold(x))
|
|
61
113
|
.replace(/`([^`]+)`/g, (_, x) => t.cyan(x))
|
|
62
114
|
.replace(/(?<![*\w])\*([^*]+)\*(?!\w)/g, (_, x) => t.italic(x));
|
|
63
115
|
for (const raw of md.split("\n")) {
|
|
116
|
+
// display math: $$ … $$ (one or many lines) and \[ … \]
|
|
117
|
+
if (math) {
|
|
118
|
+
const close = raw.match(/^(.*?)(\$\$|\\\])\s*$/);
|
|
119
|
+
if (close) {
|
|
120
|
+
math.push(close[1]);
|
|
121
|
+
out.push(...renderEquation(math.join("\n")));
|
|
122
|
+
math = null;
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
math.push(raw);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (fence === null) {
|
|
129
|
+
const one = raw.match(/^\s*(?:\$\$|\\\[)(.+?)(?:\$\$|\\\])\s*$/);
|
|
130
|
+
if (one) {
|
|
131
|
+
out.push(...renderEquation(one[1]));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const open = raw.match(/^\s*(\$\$|\\\[)(.*)$/);
|
|
135
|
+
if (open) {
|
|
136
|
+
math = open[2].trim() ? [open[2]] : [];
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
64
140
|
const fm = raw.match(/^```(\w*)/);
|
|
65
141
|
if (fm) {
|
|
66
142
|
if (fence === null) {
|
|
67
143
|
fence = fm[1] || "code";
|
|
68
|
-
|
|
69
|
-
out.push(" " + t.pink("◆ diagram ") + t.dim("(rendered in the markdown file)"));
|
|
144
|
+
buf = [];
|
|
70
145
|
}
|
|
71
|
-
else
|
|
146
|
+
else {
|
|
147
|
+
if (fence === "mermaid") {
|
|
148
|
+
const art = renderDiagram(buf.join("\n"));
|
|
149
|
+
out.push("", ...(art ? art.split("\n").map((l) => " " + l) : [" " + t.pink("◆ diagram ") + t.dim("(too wide for this terminal — open the markdown file)")]), "");
|
|
150
|
+
}
|
|
151
|
+
else if (fence === "svg" || fence === "plot")
|
|
152
|
+
out.push(...renderFigure(fence, buf.join("\n")));
|
|
72
153
|
fence = null;
|
|
154
|
+
}
|
|
73
155
|
continue;
|
|
74
156
|
}
|
|
75
|
-
if (fence === "mermaid")
|
|
157
|
+
if (fence === "mermaid" || fence === "svg" || fence === "plot") {
|
|
158
|
+
buf.push(raw);
|
|
76
159
|
continue;
|
|
160
|
+
}
|
|
77
161
|
if (fence !== null) {
|
|
78
162
|
out.push(" " + t.dim("│ ") + t.cyan(raw));
|
|
79
163
|
continue;
|
|
80
164
|
}
|
|
81
165
|
const h = raw.match(/^(#{1,4})\s+(.*)/);
|
|
82
166
|
if (h) {
|
|
83
|
-
out.push("", " " + (h[1].length <= 2 ? t.bold(t.accent(h[2])) : t.bold(h[2])));
|
|
167
|
+
out.push("", " " + (h[1].length <= 2 ? t.bold(t.accent(inline(h[2]))) : t.bold(inline(h[2]))));
|
|
84
168
|
continue;
|
|
85
169
|
}
|
|
86
170
|
const li = raw.match(/^(\s*)[-*]\s+(.*)/);
|
|
@@ -97,3 +181,5 @@ export function renderMd(md) {
|
|
|
97
181
|
}
|
|
98
182
|
return out.join("\n");
|
|
99
183
|
}
|
|
184
|
+
/** Plain-text math for prompts, options and flashcards. */
|
|
185
|
+
export const m = mathToText;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "learn-loops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A learning CLI that uses Claude Code / Codex as your tutor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@clack/prompts": "^1.8.1",
|
|
22
|
-
"
|
|
22
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
23
|
+
"beautiful-mermaid": "^1.1.3",
|
|
24
|
+
"mathjax-full": "^3.2.1",
|
|
25
|
+
"mathjs": "^15.2.0",
|
|
26
|
+
"picocolors": "^1.1.1",
|
|
27
|
+
"unicodeit": "^0.7.5"
|
|
23
28
|
},
|
|
24
29
|
"devDependencies": {
|
|
25
30
|
"@types/node": "^26.6.5",
|
|
@@ -39,4 +44,4 @@
|
|
|
39
44
|
"spaced-repetition",
|
|
40
45
|
"tutor"
|
|
41
46
|
]
|
|
42
|
-
}
|
|
47
|
+
}
|