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.
@@ -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
- /** Lightweight terminal markdown renderer. Mermaid blocks are summarised, not drawn. */
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
- const inline = (s) => s
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
- if (fence === "mermaid")
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.1.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
- "picocolors": "^1.1.1"
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
+ }