learn-loops 0.4.2 → 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/README.md CHANGED
@@ -7,7 +7,7 @@ npm i -g learn-loops
7
7
  loops
8
8
  ```
9
9
 
10
- Requires Node 20+ and either the `claude` or `codex` CLI installed and logged in. If `loops` isn't found after install, add npm's global bin folder to your `PATH` (`npm prefix -g`, then `<that>/bin`).
10
+ Requires Node 20+ and either the `claude` or `codex` CLI installed and logged in (`npm i -g @anthropic-ai/claude-code` or `npm i -g @openai/codex`). If neither is found, `loops` says so and shows how to install one; if your saved tutor is missing but the other is present, it falls back to that one. If `loops` isn't found after install, add npm's global bin folder to your `PATH` (`npm prefix -g`, then `<that>/bin`).
11
11
 
12
12
  ## How it teaches
13
13
 
@@ -61,4 +61,10 @@ loops profile edit your learner profile
61
61
  ```
62
62
 
63
63
  Your profile and streak live in `~/.config/loops/`.
64
- Env: `LOOPS_HOME`, `LOOPS_PROVIDER=claude|codex`, `LOOPS_IMAGES`, `NO_COLOR`.
64
+ ### Billing safety
65
+
66
+ `loops` runs the CLIs under your subscription login and strips `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` and similar variables from their environment, so you aren't billed per token by accident. A CLI signed in with an API key (or Bedrock/Vertex) is refused unless you set `LOOPS_ALLOW_API_BILLING=1`. If your tutor fails (usage limit, outage), `loops` automatically tries the other one.
67
+
68
+ It **can't** see your account's "extra usage" (Claude) or credits (ChatGPT/Codex) settings, because neither CLI exposes them. If you don't want overage charges, turn those off in your account settings.
69
+
70
+ Env: `LOOPS_HOME`, `LOOPS_PROVIDER=claude|codex`, `LOOPS_ALLOW_API_BILLING=1`, `LOOPS_IMAGES`, `NO_COLOR`.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
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
+ import { PROVIDER_HELP, allStatuses, noProviderMessage, setNotifier } from "./llm.js";
4
5
  import { isDue } from "./srs.js";
5
6
  import { currentStreak, doneToday, loadStats, recordActivity, saveCheckin } from "./stats.js";
6
7
  import { banner, bar, must, t } from "./ui.js";
@@ -18,10 +19,13 @@ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as
18
19
  loops streak your streak
19
20
  loops map show a topic's map and prerequisite diagram
20
21
  loops topics list your topics and progress
22
+ loops delete delete a topic and all its notes and progress
21
23
  loops profile edit how you like to learn
22
24
  loops --help this help
23
25
 
24
- Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, NO_COLOR`;
26
+ To delete a course: run ${t.bold("loops delete")}, or choose "Delete a topic" from the menu.
27
+
28
+ Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, LOOPS_ALLOW_API_BILLING=1 (allow pay-per-token logins), NO_COLOR`;
25
29
  const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
26
30
  function progressOf(tp) {
27
31
  const areas = tp.state.map?.areas ?? [];
@@ -101,12 +105,15 @@ async function startScreen(profile, topics, rec) {
101
105
  }),
102
106
  { value: "__new", label: t.accent("+ Start something new") },
103
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`" },
104
109
  ],
105
110
  }));
106
111
  if (choice === "__menu")
107
112
  return;
108
113
  if (choice === "__new")
109
114
  return dispatch("new", profile);
115
+ if (choice === "__delete")
116
+ return dispatch("delete", profile);
110
117
  const tp = recent[Number(choice)];
111
118
  if (tp.state.map && tp.state.phase === "survey" && !tp.state.progress[tp.state.map.areas[0].id]?.surveyed)
112
119
  printMap(tp);
@@ -133,7 +140,7 @@ async function menu(profile) {
133
140
  ...(due ? [{ value: "review", label: "Review flashcards", hint: `${due} due${star("review")}` }] : []),
134
141
  ...(cards >= 4 ? [{ value: "quiz", label: "Mixed quiz", hint: `interleaved, weakest first${star("quiz")}` }] : []),
135
142
  ...(topics.some((tp) => tp.state.mistakes?.length) ? [{ value: "mistakes", label: "Mistake journal" }] : []),
136
- ...(topics.length ? [{ value: "topics", label: "My topics" }] : []),
143
+ ...(topics.length ? [{ value: "topics", label: "My topics" }, { value: "delete", label: "Delete a topic" }] : []),
137
144
  ];
138
145
  // put the recommendation first
139
146
  opts.sort((a, b) => Number(b.value === rec) - Number(a.value === rec));
@@ -215,6 +222,17 @@ async function dispatch(action, profile, arg) {
215
222
  printMap(tp);
216
223
  break;
217
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
+ }
218
236
  case "profile":
219
237
  await onboard(profile);
220
238
  break;
@@ -231,14 +249,30 @@ async function main() {
231
249
  process.exit(1);
232
250
  }
233
251
  banner();
252
+ setNotifier((m) => p.log.warn(m));
253
+ const statuses = await allStatuses();
254
+ const usable = statuses.filter((x) => x.usable);
255
+ if (!usable.length) {
256
+ const installed = statuses.filter((x) => x.installed);
257
+ p.log.error(t.bad(installed.length ? `No usable tutor:\n${installed.map((x) => ` - ${x.reason}`).join("\n")}` : noProviderMessage()));
258
+ process.exit(1);
259
+ }
260
+ for (const x of statuses)
261
+ if (x.installed && !x.usable)
262
+ p.log.warn(x.reason);
234
263
  let profile = await loadProfile();
235
264
  if (!profile)
236
265
  profile = await onboard();
237
266
  if (process.env.LOOPS_PROVIDER === "claude" || process.env.LOOPS_PROVIDER === "codex")
238
267
  profile.provider = process.env.LOOPS_PROVIDER;
268
+ if (!usable.some((x) => x.name === profile.provider)) {
269
+ const fallback = usable[0].name;
270
+ p.log.warn(`Using ${PROVIDER_HELP[fallback].label} for this session instead of ${PROVIDER_HELP[profile.provider].label}.`);
271
+ profile.provider = fallback;
272
+ }
239
273
  if (!cmd)
240
274
  return menu(profile);
241
- 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))
242
276
  return dispatch(cmd, profile, rest.join(" ") || undefined);
243
277
  console.log(HELP);
244
278
  process.exit(1);
@@ -1,25 +1,28 @@
1
1
  import * as p from "@clack/prompts";
2
- import { execFileSync } from "node:child_process";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
4
+ import { PROVIDER_HELP, hasBinary as has, installedProviders } from "../llm.js";
5
5
  import { saveProfile } from "../store.js";
6
6
  import { must, t } from "../ui.js";
7
- const has = (bin) => {
8
- try {
9
- execFileSync("which", [bin], { stdio: "ignore" });
10
- return true;
11
- }
12
- catch {
13
- return false;
14
- }
15
- };
16
7
  export async function onboard(existing) {
17
8
  p.intro(t.bold(t.accent(existing ? " Edit your learner profile " : " Let's set up how YOU learn ")));
18
- if (!existing)
9
+ if (!existing) {
19
10
  p.note("Answer a few questions so every lesson, quiz and diagram fits you.\nYou can change any of this later with `loops profile`.", "Welcome");
11
+ const found = ["claude", "codex"].map((n) => `${has(n) ? t.ok("✓") : t.dim("✗")} ${PROVIDER_HELP[n].label}`).join(" ");
12
+ p.note([
13
+ `loops has no AI of its own. It looks for Claude Code or Codex on your computer and uses it as your tutor: ${found}`,
14
+ "",
15
+ "To avoid surprise charges:",
16
+ " • It only uses your subscription login. API-key / pay-per-token logins are refused,",
17
+ " and API-key environment variables are hidden from the tutor.",
18
+ " • If one tutor fails (e.g. usage limit), it switches to the other.",
19
+ " • It can't see your account's \"extra usage\" / credits setting. Turn that off in your",
20
+ " Claude or ChatGPT account settings if you don't want to pay beyond your plan.",
21
+ ].join("\n"), "Your tutor & billing");
22
+ }
20
23
  const d = existing;
21
24
  const name = must(await p.text({ message: "What should I call you?", initialValue: d?.name, validate: (v) => (v?.trim() ? undefined : "Required") }));
22
- const installed = ["claude", "codex"].filter(has);
25
+ const installed = installedProviders();
23
26
  const provider = must(await p.select({
24
27
  message: "Which AI tutor should power your lessons?",
25
28
  initialValue: d?.provider ?? installed[0] ?? "claude",
package/dist/llm.js CHANGED
@@ -1,16 +1,59 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
2
3
  import { mkdtemp, readFile, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
+ import { delimiter, join } from "node:path";
5
6
  export const TUTOR_SYSTEM = [
6
7
  "You are an exceptional tutor grounded in learning science: you activate prior knowledge,",
7
8
  "teach in small chunks, use concrete examples and diagrams, test retrieval rather than recognition,",
8
9
  "and correct misconceptions precisely. You never pad. You are accurate: if unsure, say so.",
9
10
  "You output exactly what is asked for, in the requested format, with no preamble.",
10
11
  ].join(" ");
11
- function run(cmd, args, input, timeoutMs) {
12
+ export const PROVIDER_HELP = {
13
+ claude: { label: "Claude Code", install: "npm install -g @anthropic-ai/claude-code", login: "claude" },
14
+ codex: { label: "Codex CLI", install: "npm install -g @openai/codex", login: "codex login" },
15
+ };
16
+ /** True if `bin` is an executable file in some PATH directory (honours PATHEXT on Windows). */
17
+ export function hasBinary(bin, env = process.env) {
18
+ const exts = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
19
+ for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
20
+ for (const ext of exts) {
21
+ try {
22
+ accessSync(join(dir, bin + ext), constants.X_OK);
23
+ return true;
24
+ }
25
+ catch { }
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+ export const installedProviders = () => ["claude", "codex"].filter((n) => hasBinary(n));
31
+ export function noProviderMessage() {
32
+ return [
33
+ "loops needs Claude Code or Codex installed to act as your tutor, and found neither on your PATH.",
34
+ "",
35
+ "Install one, sign in, then run loops again:",
36
+ ...Object.keys(PROVIDER_HELP).map((n) => ` ${PROVIDER_HELP[n].label}: ${PROVIDER_HELP[n].install} (then: ${PROVIDER_HELP[n].login})`),
37
+ "",
38
+ "Already installed in a non-standard place? Add its folder to your PATH.",
39
+ "Sign in with a subscription account: loops refuses API-key / pay-per-token logins unless LOOPS_ALLOW_API_BILLING=1.",
40
+ ].join("\n");
41
+ }
42
+ /** Credentials that make a CLI bill per token instead of using the user's subscription login. */
43
+ const METERED_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "OPENAI_API_KEY", "CODEX_API_KEY"];
44
+ export const allowMetered = (env = process.env) => env.LOOPS_ALLOW_API_BILLING === "1";
45
+ /** The environment child CLIs run in: pay-per-token credentials are removed unless the user opted in. */
46
+ export function childEnv(env = process.env) {
47
+ if (allowMetered(env))
48
+ return { ...env };
49
+ const out = { ...env };
50
+ for (const k of METERED_ENV)
51
+ delete out[k];
52
+ return out;
53
+ }
54
+ function run(cmd, args, input, timeoutMs, includeStderr = false) {
12
55
  return new Promise((resolve, reject) => {
13
- const child = spawn(cmd, args, { cwd: tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
56
+ const child = spawn(cmd, args, { cwd: tmpdir(), env: childEnv(), stdio: ["pipe", "pipe", "pipe"] });
14
57
  let out = "";
15
58
  let err = "";
16
59
  const timer = setTimeout(() => {
@@ -21,19 +64,20 @@ function run(cmd, args, input, timeoutMs) {
21
64
  child.stderr.on("data", (d) => (err += d));
22
65
  child.on("error", (e) => {
23
66
  clearTimeout(timer);
24
- reject(new Error(`Could not run "${cmd}" — is it installed and on your PATH? (${e.message})`));
67
+ const help = PROVIDER_HELP[cmd];
68
+ reject(new Error(`Could not run "${cmd}" — is it installed and on your PATH?${help ? ` Install: ${help.install}` : ""} (${e.message})`));
25
69
  });
26
70
  child.on("close", (code) => {
27
71
  clearTimeout(timer);
28
72
  if (code === 0)
29
- resolve(out);
73
+ resolve(includeStderr ? out + err : out);
30
74
  else
31
75
  reject(new Error(`${cmd} exited with ${code}: ${err.trim().split("\n").slice(-4).join("\n")}`));
32
76
  });
33
77
  child.stdin.end(input);
34
78
  });
35
79
  }
36
- export async function complete(provider, prompt, timeoutMs = 240_000) {
80
+ async function completeWith(provider, prompt, timeoutMs) {
37
81
  if (provider === "claude") {
38
82
  return run("claude", ["-p", "--tools", "", "--no-session-persistence", "--output-format", "text", "--system-prompt", TUTOR_SYSTEM], prompt, timeoutMs);
39
83
  }
@@ -47,6 +91,87 @@ export async function complete(provider, prompt, timeoutMs = 240_000) {
47
91
  await rm(dir, { recursive: true, force: true });
48
92
  }
49
93
  }
94
+ /** Classify `claude auth status` JSON. Anything that isn't a first-party subscription login bills per token. */
95
+ export function classifyClaude(raw) {
96
+ let j;
97
+ try {
98
+ j = JSON.parse(raw);
99
+ }
100
+ catch {
101
+ return "unknown";
102
+ }
103
+ if (j.loggedIn === false)
104
+ return "logged-out";
105
+ const method = String(j.authMethod ?? "");
106
+ if ((j.apiProvider && j.apiProvider !== "firstParty") || /api.?key|console/i.test(method))
107
+ return "metered";
108
+ return /claude\.ai|oauth|subscription/i.test(method) ? "subscription" : "unknown";
109
+ }
110
+ /** Classify `codex login status` output. */
111
+ export function classifyCodex(text) {
112
+ if (/not logged in/i.test(text))
113
+ return "logged-out";
114
+ if (/api key/i.test(text))
115
+ return "metered";
116
+ return /chatgpt/i.test(text) ? "subscription" : "unknown";
117
+ }
118
+ async function probe(name) {
119
+ const label = PROVIDER_HELP[name].label;
120
+ const bad = (billing, reason) => ({ name, installed: hasBinary(name), billing, usable: false, reason });
121
+ if (!hasBinary(name))
122
+ return bad("unknown", `${label} isn't installed (${PROVIDER_HELP[name].install})`);
123
+ let billing;
124
+ try {
125
+ billing = name === "claude" ? classifyClaude(await run("claude", ["auth", "status"], "", 20_000)) : classifyCodex(await run("codex", ["login", "status"], "", 20_000, true));
126
+ }
127
+ catch {
128
+ billing = "logged-out";
129
+ }
130
+ if (billing === "logged-out")
131
+ return bad(billing, `${label} isn't signed in (run: ${PROVIDER_HELP[name].login})`);
132
+ if (billing === "metered" && !allowMetered()) {
133
+ return bad(billing, `${label} is signed in with an API key / pay-per-token account, which would charge you per request. Sign in with your subscription instead, or set LOOPS_ALLOW_API_BILLING=1 to allow it`);
134
+ }
135
+ return { name, installed: true, billing, usable: true, reason: "" };
136
+ }
137
+ const statusCache = new Map();
138
+ export const providerStatus = (name) => {
139
+ if (!statusCache.has(name))
140
+ statusCache.set(name, probe(name));
141
+ return statusCache.get(name);
142
+ };
143
+ export const allStatuses = () => Promise.all(["claude", "codex"].map(providerStatus));
144
+ let notify = (m) => console.error(m);
145
+ export const setNotifier = (fn) => (notify = fn);
146
+ let sticky;
147
+ /**
148
+ * Run the prompt on the preferred tutor; if it can't be used or fails (usage limit, outage, signed out), fall back
149
+ * to the other one — but never to a CLI that would bill per token. Once a fallback works it's kept for the session.
150
+ */
151
+ export async function complete(preferred, prompt, timeoutMs = 240_000) {
152
+ const first = sticky ?? preferred;
153
+ const order = [first, first === "claude" ? "codex" : "claude"];
154
+ const problems = [];
155
+ for (const name of order) {
156
+ const st = await providerStatus(name);
157
+ if (!st.usable) {
158
+ problems.push(st.reason);
159
+ continue;
160
+ }
161
+ if (name !== first)
162
+ notify(`${PROVIDER_HELP[order[0]].label} didn't work, so I'm trying ${PROVIDER_HELP[name].label} instead.`);
163
+ try {
164
+ const out = await completeWith(name, prompt, timeoutMs);
165
+ if (name !== preferred)
166
+ sticky = name;
167
+ return out;
168
+ }
169
+ catch (e) {
170
+ problems.push(`${PROVIDER_HELP[name].label}: ${e.message}`);
171
+ }
172
+ }
173
+ throw new Error(problems.join("\n"));
174
+ }
50
175
  /**
51
176
  * Models often forget to double LaTeX backslashes inside JSON strings. `\theta` would silently parse as a
52
177
  * tab + "heta", `\nabla` as newline + "abla", `\frac` as form feed + "rac". Double the backslash of any
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.2",
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",