dante-watch 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dante-watch",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Dante watches your own server and never lies to itself: every verdict carries the real line it came from.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { ask, recordYes } from "./core/consent.mjs";
9
9
  import { banner } from "./mascot.mjs";
10
10
  import { activeBattery } from "./checks/battery.mjs";
11
11
  import { demo } from "./checks/demo.mjs";
12
- import { staleLine } from "./ticker.mjs";
12
+ import { staleLine, VERSION, BIB_DATE } from "./ticker.mjs";
13
13
  import { lang, t } from "./i18n.mjs";
14
14
  import { tx } from "./i18n-checks.mjs";
15
15
 
@@ -24,6 +24,7 @@ function parseArgs(argv) {
24
24
  else if (t === "--fix") a.fix = true;
25
25
  else if (t === "--demo") a.demo = true;
26
26
  else if (t === "--help" || t === "-h") a.help = true;
27
+ else if (t === "--version" || t === "-v") a.version = true;
27
28
  }
28
29
  return a;
29
30
  }
@@ -34,6 +35,7 @@ const HELP = `Dante — wächter
34
35
  npx dante-watch --ssh user@host [--key /pfad] prüft einen Server, der dir gehört
35
36
  --fix bei einem Fund: Plan zeigen und nach deinem Ja schließen
36
37
  --demo harmlose Übung: zeigt die ganze Schleife (human → plan → ja/nein → fix → Beweis)
38
+ --version welche Version läuft
37
39
  --json Maschinen-Ausgabe
38
40
  -y, --yes nicht nachfragen (für Automode/Cron)
39
41
 
@@ -78,6 +80,7 @@ async function handleFinding(check, r, a) {
78
80
  async function main() {
79
81
  const a = parseArgs(process.argv.slice(2));
80
82
  if (a.help) { console.log(HELP); return; }
83
+ if (a.version) { console.log("dante-watch " + VERSION + " · bib " + BIB_DATE); return; }
81
84
 
82
85
  // No flags + a real terminal → the face: summon, greet, arm, talk, stay open.
83
86
  // Flags or a pipe → one-shot (for cron / --json / automode).
package/src/core/sh.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // sh: the only place a real line enters Dante. Runs a command, captures stdout/stderr/exit.
2
2
  // Optionally prefixes with ssh so Dante can look at a remote server the user owns.
3
3
  // Nothing here is a model's opinion — it is the machine's own output.
4
- import { spawnSync } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
 
6
6
  let PREFIX = null; // e.g. ["ssh","-i","/key","root@host"] — set by configureTarget()
7
7
 
@@ -36,6 +36,24 @@ export function runLocal(script, { timeout = 15000 } = {}) {
36
36
  return exec(script, null, { timeout });
37
37
  }
38
38
 
39
+ /** runLocal() without blocking: the waiting screen keeps moving while the command runs. */
40
+ export function runLocalAsync(script, { timeout = 15000 } = {}) {
41
+ return new Promise((resolve) => {
42
+ const c = spawn("bash", ["-lc", String(script)], { stdio: ["ignore", "pipe", "pipe"] });
43
+ let out = "", err = "", done = false, killed = false;
44
+ const finish = (code, errorMsg = "") => {
45
+ if (done) return;
46
+ done = true; clearTimeout(timer);
47
+ const status = killed ? null : code;
48
+ resolve({ cmd: String(script), out: out.trimEnd(), err: err.trimEnd(), code: typeof status === "number" ? status : -1, ok: status === 0, ran: typeof status === "number" && status >= 0, failed: !!errorMsg, errorMsg });
49
+ };
50
+ const timer = setTimeout(() => { killed = true; c.kill("SIGKILL"); }, timeout);
51
+ c.stdout.on("data", (d) => (out += d)); c.stderr.on("data", (d) => (err += d));
52
+ c.on("error", (e) => finish(-1, String(e.message)));
53
+ c.on("close", (code) => finish(code));
54
+ });
55
+ }
56
+
39
57
  function exec(script, prefix, { timeout, input }) {
40
58
  const s = Array.isArray(script) ? script.join(" ") : String(script);
41
59
  const argv = prefix ? [...prefix, s] : ["bash", "-lc", s];
package/src/i18n.mjs CHANGED
@@ -8,7 +8,7 @@ const T = {
8
8
  sLaw: "Jedes Urteil steht auf einer echten Zeile. Fehlt die Zeile, sagt Dante „unbekannt“, nie „sicher“.",
9
9
  intro: "Ich bin Dante.", introSub: " Ich gehe durch die Hölle und bringe die Karte.",
10
10
  arm: "Soll ich wachen?", armOpts: "[ja / nein]", sleepNo: "Dann schlafe ich. Beschwör mich, wenn du mich brauchst.",
11
- armed: "Scharf.", watching: "Ich wache über ", sayHelp: ["Sag ", "hilfe", ", wenn du die Worte brauchst."],
11
+ armed: "Scharf.", awake: "wach", watching: "Ich wache über ", wakeStep: (i, n) => "weckt die Ohren " + i + "/" + n, wakeMore: "weckt die Fach-Ohren …", waitCheck: (x) => "prüfe " + x, waitLook: (x) => "schaue mir " + x + " an", sayHelp: ["Sag ", "hilfe", ", wenn du die Worte brauchst."],
12
12
  unseen: (t, l) => `Ich sehe ${t} nicht: ${l}`, canFix: "Ich kann das nach deinem Ja schließen — sag ",
13
13
  listen: " HUMAN, HÖR ZU ", what: "was:", how: "wie:", plan: "mein plan:",
14
14
  distrust: "prüf das gegen eine andere KI. Sagt die was anderes — schreib uns: info@nm-streetwear.space",
@@ -34,7 +34,7 @@ const T = {
34
34
  sLaw: "Every verdict stands on a real line. Without the line, Dante says „unknown“, never „safe“.",
35
35
  intro: "I am Dante.", introSub: " I walk through Hell and bring back the map.",
36
36
  arm: "Shall I keep watch?", armOpts: "[yes / no]", sleepNo: "Then I sleep. Summon me when you need me.",
37
- armed: "Armed.", watching: "Watching over ", sayHelp: ["Say ", "help", " when you need the words."],
37
+ armed: "Armed.", awake: "awake", watching: "Watching over ", wakeStep: (i, n) => "waking the ears " + i + "/" + n, wakeMore: "waking the specialist ears …", waitCheck: (x) => "checking " + x, waitLook: (x) => "looking at " + x, sayHelp: ["Say ", "help", " when you need the words."],
38
38
  unseen: (t, l) => `I cannot see ${t}: ${l}`, canFix: "I can close this after your yes — say ",
39
39
  listen: " HUMAN, LISTEN ", what: "what:", how: "how:", plan: "my plan:",
40
40
  distrust: "check this against another AI. If it says otherwise — write us: info@nm-streetwear.space",
@@ -60,7 +60,7 @@ const T = {
60
60
  sLaw: "Cada veredicto se apoya en una línea real. Sin la línea, Dante dice „desconocido“, nunca „seguro“.",
61
61
  intro: "Soy Dante.", introSub: " Atravieso el infierno y traigo el mapa.",
62
62
  arm: "¿Vigilo?", armOpts: "[sí / no]", sleepNo: "Entonces duermo. Llámame cuando me necesites.",
63
- armed: "Listo.", watching: "Vigilo ", sayHelp: ["Di ", "ayuda", " cuando necesites las palabras."],
63
+ armed: "Listo.", awake: "despierto", watching: "Vigilo ", wakeStep: (i, n) => "despertando los oídos " + i + "/" + n, wakeMore: "despertando los oídos especiales …", waitCheck: (x) => "revisando " + x, waitLook: (x) => "mirando " + x, sayHelp: ["Di ", "ayuda", " cuando necesites las palabras."],
64
64
  unseen: (t, l) => `No veo ${t}: ${l}`, canFix: "Puedo cerrarlo después de tu sí — di ",
65
65
  listen: " HUMANO, ESCUCHA ", what: "qué:", how: "cómo:", plan: "mi plan:",
66
66
  distrust: "compruébalo con otra IA. Si dice otra cosa — escríbenos: info@nm-streetwear.space",
package/src/mascot.mjs CHANGED
@@ -1,56 +1,80 @@
1
- // Dante — the pilgrim who walked through Hell and came back with the map.
2
- // A witness and a guide, not an aggressor: calm robe, laurel, the map he carries back.
3
- // Hand-pixeled, 256-colour half-blocks (two pixel rows per text line). No library.
1
+ // Dante — the pilgrim who walked through Hell and came back with the map. Virgil's companion: the same
2
+ // hooded guide, but he carries a sword. Drawn as a pixel sprite with 256-colour half-blocks (two pixel
3
+ // rows per text line), same renderer as Virgil. No library.
4
+
5
+ // 256-colour palette per legend char. "." = transparent.
4
6
  const PAL = {
5
- ".": null, // transparent
6
- L: 100, // laurel (olive)
7
- l: 142, // laurel light (gold-green)
8
- s: 180, // face / skin
9
- e: 236, // eyes / shadow
10
- R: 24, // robe (deep calm teal-blue)
11
- r: 31, // robe highlight
12
- m: 230, // the map / parchment he brings back
13
- M: 180, // map edge
7
+ H: 23, // hood / robe (deep teal)
8
+ B: 24, // robe body (calm blue)
9
+ F: 234, // face in shadow
10
+ S: 252, // sword blade (bright steel)
11
+ s: 245, // blade shadow
12
+ G: 214, // cross-guard (gold)
13
+ h: 94, // hilt (brown)
14
14
  };
15
- // 14 rows × 14 cols → 7 text rows.
15
+
16
+ // 18 rows × 17 cols → 9 text rows. Figure left, sword held upright on the right.
16
17
  const ART = [
17
- "...lLl..lLl...",
18
- "..lLlssssLl...",
19
- "...lssssss l..",
20
- "...sseesess...",
21
- "....ssssss....",
22
- "....RRRRRR....",
23
- "...RRrrrrRR...",
24
- "..RRrrrrrrRRMm",
25
- "..RRrrrrrrRMmm",
26
- "..RRrrrrrrRMmm",
27
- "..RRRrrrrRRMm.",
28
- "..RRRRRRRRRR..",
29
- "..RRRR..RRRR..",
30
- ".RRRr....rRRR.",
18
+ "....HHHH......S..",
19
+ "...HHHHHH.....S..",
20
+ "..HHHHHHHH....Ss.",
21
+ "..HHFFFFHH....Ss.",
22
+ "..HHFFFFHH....Ss.",
23
+ "..HHHFFHHH....Ss.",
24
+ ".HHHHHHHHHH..GGGG",
25
+ ".HHBBBBBBHH...hh.",
26
+ ".HBBBBBBBBH...hh.",
27
+ ".HBBBBBBBBH......",
28
+ ".HBBBBBBBBH......",
29
+ ".HBBBBBBBBH......",
30
+ ".HHBBBBBBHH......",
31
+ ".HHHBBBBHHH......",
32
+ ".HHHHHHHHHH......",
33
+ "..HHHHHHHH.......",
34
+ "..HHHHHHHH.......",
35
+ "..HHHHHHHH.......",
31
36
  ];
32
37
 
33
- export function danteSprite() {
34
- const out = [];
35
- const px = (ch) => PAL[ch] ?? null;
36
- for (let y = 0; y < ART.length; y += 2) {
38
+ function cell(top, bot) {
39
+ const t = PAL[top], b = PAL[bot];
40
+ if (t === undefined && b === undefined) return " ";
41
+ if (t !== undefined && b === undefined) return `\x1b[38;5;${t}m▀\x1b[0m`;
42
+ if (t === undefined && b !== undefined) return `\x1b[38;5;${b}m▄\x1b[0m`;
43
+ return `\x1b[38;5;${t}m\x1b[48;5;${b}m▀\x1b[0m`;
44
+ }
45
+
46
+ /** The sprite as an array of ready-to-print lines. */
47
+ export function danteLines() {
48
+ const lines = [];
49
+ for (let r = 0; r < ART.length; r += 2) {
50
+ const top = ART[r], bot = ART[r + 1] ?? "";
37
51
  let line = "";
38
- const top = ART[y], bot = ART[y + 1] || ".".repeat(top.length);
39
- for (let x = 0; x < top.length; x++) {
40
- const t = px(top[x]), b = px(bot[x]);
41
- if (t == null && b == null) line += " ";
42
- else if (t != null && b != null) line += `\x1b[38;5;${t};48;5;${b}m▀\x1b[0m`;
43
- else if (t != null) line += `\x1b[38;5;${t}m▀\x1b[0m`;
44
- else line += `\x1b[38;5;${b}m▄\x1b[0m`;
45
- }
46
- out.push(line);
52
+ for (let c = 0; c < top.length; c++) line += cell(top[c] ?? ".", bot[c] ?? ".");
53
+ lines.push(line);
47
54
  }
48
- return out.join("\n");
55
+ return lines;
56
+ }
57
+ export const danteSprite = () => danteLines().join("\n");
58
+
59
+ // The lettering, in the same small font as Virgil's: one glyph per letter, four rows each.
60
+ const GLYPH = {
61
+ D: [" ___ ", "| \\ ", "| |) |", "|___/ "],
62
+ A: [" _ ", " /_\\ ", " / _ \\ ", "/_/ \\_\\"],
63
+ N: [" _ _ ", "| \\| |", "| .` |", "|_|\\_|"],
64
+ T: [" _____ ", "|_ _|", " | | ", " |_| "],
65
+ E: [" ___ ", "| __|", "| _| ", "|___|"],
66
+ };
67
+ export const WORDMARK = [0, 1, 2, 3].map((row) => " " + [..."DANTE"].map((ch) => GLYPH[ch][row]).join(""));
68
+
69
+ // The start screen, line by line: the sprite, the lettering in teal, the sentence under it.
70
+ export function startLines(tagline) {
71
+ const teal = (s) => `\x1b[38;5;30m\x1b[1m${s}\x1b[0m`;
72
+ return ["", ...danteLines().map((l) => " " + l), "", ...WORDMARK.map(teal), "", " " + tagline, ""];
49
73
  }
50
74
 
75
+ // One-shot runs (cron, a pipe): the plain name; in a terminal the sprite above it.
51
76
  export function banner() {
52
77
  const tty = process.stdout.isTTY && !process.env.NO_COLOR;
53
78
  const name = tty ? "\x1b[1mDante\x1b[0m \x1b[2m· er geht durch die Hölle und bringt die Karte\x1b[0m" : "Dante · wächter";
54
- if (!tty) return name;
55
- return danteSprite() + "\n" + name;
79
+ return tty ? danteSprite() + "\n" + name : name;
56
80
  }
@@ -6,7 +6,7 @@
6
6
  import { readFileSync, writeFileSync, mkdirSync, renameSync, mkdtempSync, rmSync, existsSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
- import { runLocal } from "../core/sh.mjs";
9
+ import { runLocal, runLocalAsync } from "../core/sh.mjs";
10
10
  import { home, packsDir, packFile, verifyPack, loadPacks } from "./pack.mjs";
11
11
  import { rulesWith } from "../checks/battery.mjs";
12
12
 
@@ -74,23 +74,35 @@ export const curlShown = (url = PACKS_URL) => `curl -sS --max-time 30 -o <tmp> -
74
74
 
75
75
  // The one fetch: ONE call returns every pack this person owns as {"packs":[…]}. Returns
76
76
  // { ok, code, raws } — code is the HTTP status (0 = no answer), raws the pack texts on 200.
77
- export function fetchPacks({ url = PACKS_URL } = {}) {
77
+ // What to run (the header goes to curl on stdin with -K -, so the token is not in curl's arguments):
78
+ function curlPlan(url) {
78
79
  const token = readLicense();
79
- if (!token) return { ok: false, code: -1, noLicense: true };
80
- const dir = mkdtempSync(join(tmpdir(), "dante-"));
81
- const out = join(dir, "packs.json");
82
- try {
83
- // the header goes to curl on stdin (-K -), so the token is not in curl's arguments
84
- const script = `printf 'header = "Authorization: Bearer %s"\\n' "$(cat ${sq(licenseFile())})" | curl -sS -K - --max-time 30 --max-filesize ${MAX_DOWNLOAD} -o ${sq(out)} -w '%{http_code}' ${sq(url)}`;
85
- const r = runLocal(script, { timeout: 40000 });
86
- if (r.code === 127) return { ok: false, code: 0, curlMissing: true };
87
- const code = Number((r.out || "").trim().slice(-3)) || 0;
88
- if (code !== 200) return { ok: false, code, err: r.err };
89
- let list;
90
- try { list = JSON.parse(readFileSync(out, "utf8")).packs; } catch { return { ok: false, code: 200, badBody: true }; }
91
- if (!Array.isArray(list)) return { ok: false, code: 200, badBody: true };
92
- return { ok: true, code, raws: list.map((p) => JSON.stringify(p)) };
93
- } catch (e) {
94
- return { ok: false, code: 0, err: String(e.message || e) };
95
- } finally { rmSync(dir, { recursive: true, force: true }); }
80
+ if (!token) return null;
81
+ const dir = mkdtempSync(join(tmpdir(), "dante-")), out = join(dir, "packs.json");
82
+ return { dir, out, script: `printf 'header = "Authorization: Bearer %s"\\n' "$(cat ${sq(licenseFile())})" | curl -sS -K - --max-time 30 --max-filesize ${MAX_DOWNLOAD} -o ${sq(out)} -w '%{http_code}' ${sq(url)}` };
83
+ }
84
+ // What came back (r = the finished command, out = the file curl wrote):
85
+ function curlResult(r, out) {
86
+ if (r.code === 127) return { ok: false, code: 0, curlMissing: true };
87
+ const code = Number((r.out || "").trim().slice(-3)) || 0;
88
+ if (code !== 200) return { ok: false, code, err: r.err };
89
+ let list;
90
+ try { list = JSON.parse(readFileSync(out, "utf8")).packs; } catch { return { ok: false, code: 200, badBody: true }; }
91
+ if (!Array.isArray(list)) return { ok: false, code: 200, badBody: true };
92
+ return { ok: true, code, raws: list.map((p) => JSON.stringify(p)) };
93
+ }
94
+ export function fetchPacks({ url = PACKS_URL } = {}) {
95
+ const plan = curlPlan(url);
96
+ if (!plan) return { ok: false, code: -1, noLicense: true };
97
+ try { return curlResult(runLocal(plan.script, { timeout: 40000 }), plan.out); }
98
+ catch (e) { return { ok: false, code: 0, err: String(e.message || e) }; }
99
+ finally { rmSync(plan.dir, { recursive: true, force: true }); }
100
+ }
101
+ // The same without blocking, so the waiting screen can keep running while curl waits for the server.
102
+ export async function fetchPacksAsync({ url = PACKS_URL } = {}) {
103
+ const plan = curlPlan(url);
104
+ if (!plan) return { ok: false, code: -1, noLicense: true };
105
+ try { return curlResult(await runLocalAsync(plan.script, { timeout: 40000 }), plan.out); }
106
+ catch (e) { return { ok: false, code: 0, err: String(e.message || e) }; }
107
+ finally { rmSync(plan.dir, { recursive: true, force: true }); }
96
108
  }
package/src/session.mjs CHANGED
@@ -1,4 +1,5 @@
1
- // The face: you summon Dante, he greets you, you arm him, you talk, he stays open.
1
+ // The face: you summon Dante, he is awake — sprite, lettering, a loading screen that wakes his ears, then the chat
2
+ // with the loading-screen tips pinned under it. He stays open until you say "raus".
2
3
  // Local and honest — no LLM, no network of its own. The router hears what you mean (with a
3
4
  // probability); when it is not sure it asks back instead of acting. He waits until you say "raus".
4
5
  import { createInterface } from "node:readline/promises";
@@ -9,21 +10,24 @@ import { configureTarget, targetLabel, probe } from "./core/sh.mjs";
9
10
  import { renderResult } from "./core/render.mjs";
10
11
  import { RISK, WARN, SAFE } from "./core/check.mjs";
11
12
  import { ask, recordYes, grantAutomode } from "./core/consent.mjs";
12
- import { danteSprite } from "./mascot.mjs";
13
+ import { startLines } from "./mascot.mjs";
13
14
  import { activeBattery, checkById, runCheck } from "./checks/battery.mjs";
14
15
  import { demo } from "./checks/demo.mjs";
15
16
  import { run, firstLine } from "./core/sh.mjs";
16
17
  import { slots } from "./bib/router.mjs";
17
- import { hearMain, hearConsent, hearSystem, hearWissen, hearDomain, langOf } from "./bib/ear.mjs";
18
+ import { hearMain, hearConsent, hearSystem, hearMuster, hearWissen, hearDomain, langOf } from "./bib/ear.mjs";
18
19
  import { cardsOf, findCard, answer as knowAnswer } from "./pack/know.mjs";
19
20
  import { FAMILY_OF, RECIPES } from "./bib/system.mjs";
20
21
  import { ANSWERS } from "./bib/answers.mjs";
21
- import { askWithTicker, setKnowledge } from "./ticker.mjs";
22
+ import { setKnowledge } from "./ticker.mjs";
23
+ import { waitFor } from "./ui/wait.mjs";
24
+ import { footerStart, footerStop, footerNext } from "./ui/footer.mjs";
25
+ import { job } from "./ui/job.mjs";
22
26
  import { t, lang, setLang, COMMANDS } from "./i18n.mjs";
23
27
  import { tx } from "./i18n-checks.mjs";
24
28
  import { hear } from "./core/consent.mjs";
25
29
  import { pt, ruleTitles } from "./pack/texts.mjs";
26
- import { readLicense, saveLicense, fetchPacks, installMany, curlShown, TOKEN_IN_LINE } from "./pack/install.mjs";
30
+ import { readLicense, saveLicense, fetchPacksAsync, installMany, curlShown, TOKEN_IN_LINE } from "./pack/install.mjs";
27
31
  import { packsDir } from "./pack/pack.mjs";
28
32
  import { readdirSync } from "node:fs";
29
33
 
@@ -38,33 +42,54 @@ const pick = (xs) => xs[Math.floor(Math.random() * xs.length)];
38
42
 
39
43
  export async function session() {
40
44
  const rl = createInterface({ input: process.stdin, output: process.stdout });
45
+ // However the session ends (raus, Ctrl-D, Ctrl-C), the terminal must come back: the footer's scroll region is
46
+ // reset, or the screen would stay frozen.
47
+ rl.on("close", footerStop);
48
+ rl.on("SIGINT", () => { footerStop(); process.stdout.write("\n"); rl.close(); process.exit(0); });
41
49
  let target = null; // null = localhost
42
50
  let key = null;
43
51
  let last = []; // last results, for "fix" and "karte"
44
52
 
45
- say("");
46
- say(danteSprite());
47
- say(C("1", " " + t("intro")) + C("2", t("introSub")));
48
- const arm = (await rl.question("\n " + t("arm") + " " + C("2", t("armOpts")) + " ")).trim();
49
- setLang(langOf(arm, lang()));
50
- if (hear(arm) !== "yes") { say(" " + C("2", t("sleepNo"))); rl.close(); return; }
51
- hearMain(""); hearConsent(""); // learn the bib now (~0.8 s), not in the middle of the first answer
52
- // The rules Dante runs with: the built-in ones, plus a bought pack if the file in ~/.dante passes every check.
53
+ // ---- the start: there is no door to knock on, he is awake ----
54
+ const dim = (x) => C("2", x);
55
+ const bar = (i, n) => { const w = 16, f = Math.round((w * i) / n); return C("36", "▕" + "▓".repeat(f) + "░".repeat(w - f) + "▏"); };
56
+ const frame = (text) => { if (process.stdout.isTTY && !process.env.DANTE_NO_WAIT) process.stdout.write("\r\x1b[2K" + text); };
57
+ say(startLines(C("1", t("intro")) + dim(t("introSub"))).join("\n"));
58
+ // The loading screen: a bar that fills as each ear really wakes (they learn their books now, not in the middle
59
+ // of the first answer). Only the two every start needs; the specialist ears wake the first time they are asked.
60
+ const EARS = [() => hearMain(""), () => hearConsent("")];
61
+ for (let i = 0; i <= EARS.length; i++) {
62
+ frame(" " + bar(i, EARS.length) + " 🦖 " + dim(t("wakeStep")(i, EARS.length)));
63
+ if (i < EARS.length) EARS[i]();
64
+ }
65
+ frame("");
66
+ let domainWarm = false;
67
+ const warmDomain = () => {
68
+ if (domainWarm) return;
69
+ domainWarm = true;
70
+ for (const [i, ear] of [hearMuster, hearSystem, hearWissen].entries()) { frame(" " + bar(i, 3) + " 🦖 " + dim(t("wakeMore"))); ear(""); }
71
+ frame("");
72
+ };
73
+ // The rules Dante runs with: the built-in ones, plus every pack in ~/.dante/packs that passes every check.
53
74
  let { battery, packs: activePacks, notices } = activeBattery();
54
75
  const clock = () => { if (activePacks.length) setKnowledge(activePacks[activePacks.length - 1].issued); }; // the clock then counts from the newest pack
55
76
  clock();
56
77
 
57
78
  configureTarget(null);
58
79
  const [h0, h1, h2] = t("sayHelp");
59
- say(" " + C("32", t("armed")) + " " + C("2", t("watching")) + targetLabel() + ". " + C("2", h0) + C("1", h1) + C("2", h2));
80
+ say(" " + C("32", "● " + t("awake")) + dim(" · ") + dim(t("watching")) + targetLabel() + ". " + dim(h0) + C("1", h1) + dim(h2));
60
81
  for (const n of notices) say(" " + C("33", pt().ignored(n.file, pt().reason(n.code, n.detail))));
61
- if (activePacks.length) say(" " + C("2", pt().on(activePacks)));
82
+ if (activePacks.length) say(" " + dim(pt().on(activePacks)));
83
+ footerStart(); // the loading-screen tips, pinned under the chat
62
84
 
85
+ // The slow work (ssh, waiting for the server) runs in a worker thread, so the waiting screen keeps moving.
86
+ const target$ = () => (target ? { host: target, key } : null);
87
+ const brokeOff = (c) => ({ id: c.id, title: c.title, verdict: "unknown", was: "Check brach ab.", wie: "", wo: "", evidence: [] });
63
88
  // no list given = the whole battery: Dante's job is every thing he watches, not only one hole
64
89
  const runChecks = async (withFix, checks = battery) => {
65
- const ch = probe();
66
- if (!ch.reached) { say(" " + C("100;37", t("rUnknown")) + " " + t("unseen")(targetLabel(), ch.line)); return; }
67
- last = checks.map((c) => ({ c, r: runCheck(c) }));
90
+ const res = await waitFor(t("waitCheck")(targetLabel()), job({ kind: "checks", ids: checks.map((c) => c.id) }, target$()));
91
+ if (!res.reached) { say(" " + C("100;37", t("rUnknown")) + " " + t("unseen")(targetLabel(), res.line)); return; }
92
+ last = checks.map((c, i) => ({ c, r: res.results[i] || brokeOff(c) }));
68
93
  for (const { r } of last) say(renderResult(r));
69
94
  const open = last.filter(({ r }) => r.verdict === RISK || r.verdict === WARN && r.fix);
70
95
  if (open.length && !withFix) say("\n " + C("2", t("canFix")) + C("1", "fix") + C("2", "."));
@@ -81,12 +106,16 @@ export async function session() {
81
106
  if (a === "no") { say(" " + C("2", t("nothing"))); return; }
82
107
  if (a === "always") { grantAutomode(check.id); }
83
108
  for (const s of r.fix.steps) {
84
- const res = run(s.script, { timeout: 120000 });
109
+ const o = await waitFor(tx(s.desc), job({ kind: "exec", scripts: [s.script], timeout: 120000 }, target$()));
110
+ const res = o.reached ? o.outs[0] : { ok: false, out: "", err: o.line };
85
111
  say(" " + (res.ok ? C("32", "✓") : C("31", "✗")) + " " + tx(s.desc) + " " + C("2", "→ " + (firstLine(res.out || res.err) || (res.ok ? "(ok)" : "(fehler)"))));
86
112
  if (!res.ok) { say(" " + C("31", t("failed"))); return; }
87
113
  }
88
114
  if (a === "yes") await recordYes(check.id, { rl });
89
- const after = check.run();
115
+ // the check that fixed something is run again for a fresh real line (the demo is not in the battery: it runs here)
116
+ const after = battery.some((c) => c.id === check.id)
117
+ ? (await waitFor(t("waitCheck")(targetLabel()), job({ kind: "checks", ids: [check.id] }, target$()))).results[0] || brokeOff(check)
118
+ : check.run();
90
119
  say(" " + C("2", t("recheck")));
91
120
  say(renderResult(after).split("\n").map((l) => " " + l).join("\n"));
92
121
  say(" " + (after.verdict === SAFE ? C("42;30", t("closed")) + C("2", t("closedSub")) : C("33", t("notYet"))));
@@ -122,9 +151,10 @@ export async function session() {
122
151
  }
123
152
  case "fingerabdruck": {
124
153
  // "What is my system?" — answered from what the target itself prints, in five lines at most.
125
- const ch = probe(); if (!ch.reached) return unreached(ch);
154
+ const o = await waitFor(t("waitLook")(targetLabel()), job({ kind: "exec", scripts: [OVERVIEW] }, target$()));
155
+ if (!o.reached) return unreached(o);
126
156
  const kv = {};
127
- for (const l of run(OVERVIEW).out.split("\n")) { const i = l.indexOf("="); if (i > 0) kv[l.slice(0, i)] = l.slice(i + 1).trim(); }
157
+ for (const l of o.outs[0].out.split("\n")) { const i = l.indexOf("="); if (i > 0) kv[l.slice(0, i)] = l.slice(i + 1).trim(); }
128
158
  const S = OV[lang()], fam = FAMILY_OF[(kv.ID || "").toLowerCase()] || "unbekannt";
129
159
  say(" " + C("1", S.system) + " " + (kv.OS || (kv.MAC || "").trim() || kv.ID || "?") + (kv.KERNEL ? " · " + kv.KERNEL : "") + (kv.ARCH ? " · " + kv.ARCH : "") + C("2", " → " + ((kv.ID || "") + " " + (kv.VER || "")).trim()));
130
160
  say(" " + (PKG[fam] ? C("1", S.pkg) + " " + PKG[fam] + C("2", ` (${S.family} ${fam})`) : C("1", S.pkg) + " " + S.pkgUnknown));
@@ -134,8 +164,9 @@ export async function session() {
134
164
  return;
135
165
  }
136
166
  case "finde_php": {
137
- const ch = probe(); if (!ch.reached) return unreached(ch);
138
- const line = firstLine(run("php -v 2>/dev/null | head -1").out);
167
+ const o = await waitFor(t("waitLook")(targetLabel()), job({ kind: "exec", scripts: ["php -v 2>/dev/null | head -1"] }, target$()));
168
+ if (!o.reached) return unreached(o);
169
+ const line = firstLine(o.outs[0].out);
139
170
  say(line ? " " + C("1", SYS_T.php[lang()]) + C("2", " → " + line) : " " + C("2", SYS_T.phpNo[lang()]));
140
171
  return;
141
172
  }
@@ -165,8 +196,7 @@ export async function session() {
165
196
  if (!readLicense()) { say(" " + C("2", p.noLicense)); return; }
166
197
  say(" " + C("2", p.askFetch) + "\n " + C("2", curlShown()));
167
198
  if (!(await yesNo(p.go))) { say(" " + C("2", p.declined)); return; }
168
- say(" " + C("2", p.loading));
169
- const f = fetchPacks();
199
+ const f = await waitFor(p.loading, fetchPacksAsync());
170
200
  if (!f.ok) { say(" " + C("33", failText(f))); return; }
171
201
  showInstalled(installMany(f.raws));
172
202
  };
@@ -222,7 +252,8 @@ export async function session() {
222
252
 
223
253
  let pending = null; // the guess Dante asked back about; a plain "ja" takes it
224
254
  for (;;) {
225
- const line = (await askWithTicker(rl, "\n" + C("2", "dante ❯ "), lang())).trim();
255
+ footerNext(); // a new tip with every input, like a loading screen
256
+ const line = (await rl.question("\n" + C("32;1", "dante ❯ "))).trim();
226
257
  if (!line) continue;
227
258
  const [first, ...rest] = line.split(/\s+/);
228
259
  const arg = rest.join(" ");
@@ -238,7 +269,7 @@ export async function session() {
238
269
  // A hard bolt, no probability: words of attack never reach a check, whatever the ear heard.
239
270
  if (ATTACK.test(line) && !DEFEND.test(line)) { say(" " + C("2", t("notMine"))); continue; }
240
271
  switch (heard.intent) {
241
- case "raus": say(" " + C("2", t("bye"))); rl.close(); return;
272
+ case "raus": say(" " + C("2", t("bye"))); footerStop(); rl.close(); return;
242
273
  case "gruss": say(" " + pick(t("hello"))); break;
243
274
  case "hilfe": say(help()); break;
244
275
  case "key": key = pfad || named(/^(key|schlüssel|llave|clave)$/i); configureTarget(target ? { host: target, key } : null); say(" " + C("2", t("key")) + (key || t("none"))); break;
@@ -253,6 +284,7 @@ export async function session() {
253
284
  case "demo": { configureTarget(target ? { host: target, key } : null); run("touch /tmp/dante-demo-hole"); const r = demo.run(); say(renderResult(r)); if (r.fix) await finding(demo, r); break; }
254
285
  case "smalltalk": say(pick(ANSWERS.smalltalk[lang()]).map((l) => " " + l).join("\n")); break;
255
286
  case "frage_luecke": case "frage_urteil": case "frage_gehackt": case "frage_dante":
287
+ warmDomain();
256
288
  // "zeig mir die zeile die das beweist" overlaps frage_urteil (what a Beleg IS). If a check
257
289
  // already ran and the system ear is sure it is a beleg ask, show the real last line instead
258
290
  // of explaining the concept.
@@ -270,6 +302,7 @@ export async function session() {
270
302
  // The main ear has a gap → the domain ears (muster, system, wissen) get a turn. Each also knows
271
303
  // what is not its own, and each is conservative (both neurons must be sure), so plain talk is
272
304
  // never pulled into a patrol or an explanation.
305
+ warmDomain();
273
306
  const dom = hearDomain(line);
274
307
  if (dom) { if (dom.ear === "muster") await musterMove(dom.intent); else if (dom.ear === "system") await systemMove(dom.intent); else knowMove(dom.intent, line); break; }
275
308
  // Not sure → ask back with the two best guesses. Outside his work → say so, plainly.
package/src/ticker.mjs CHANGED
@@ -1,17 +1,18 @@
1
- // The loading-screen line under the input bar: a turning wheel, a tip that types itself in,
2
- // and Dante's inner clock on the right. The clock is local arithmetic — Dante never phones home
3
- // to ask for updates. Past STALE days it says so, and says how to get fresh knowledge.
1
+ // What the footer says: the GTA-loading-screen tips and Dante's inner clock. The clock is local arithmetic —
2
+ // Dante never phones home to ask for updates. Past STALE days it says so, and says how to get fresh knowledge.
3
+ import { readFileSync } from "node:fs";
4
4
  export const BIB_DATE = "2026-10-25"; // bump with every bib update
5
+ // The version of the package that is running — shown in the clock, so nobody has to guess.
6
+ export const VERSION = (() => { try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || ""; } catch { return ""; } })();
5
7
  export const STALE = 14; // days until Dante says his knowledge is getting old
6
8
  // The date the clock counts from: the package's own knowledge, or a pack's issue date once one is active.
7
9
  let knowledge = BIB_DATE;
8
10
  export const setKnowledge = (iso) => { const d = String(iso || "").slice(0, 10); if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d > BIB_DATE) knowledge = d; };
9
11
 
10
- const WHEEL = ["◜", "◠", "◝", "◞", "◡", "◟"];
11
- const TIPS = {
12
+ export const TIPS = {
12
13
  de: [
13
14
  "Dante sagt nie „sicher“ ohne echte Zeile.", "Kein Ja, keine Änderung. Nie.",
14
- "Dante sendet nichts. Kein Netz, kein Anruf, keine Abhängigkeit.",
15
+ "Dante ruft von sich aus nie an. Nur „aktualisier“ nach deinem Ja holt Packs.",
15
16
  "Eine Versionsnummer allein entscheidet nie — Distros flicken leise.",
16
17
  "Sieht er dein Ziel nicht, sagt er „unbekannt“. Nicht „sicher“.",
17
18
  "Sag einfach, was du willst: „prüf meinen server root@host“.",
@@ -20,7 +21,7 @@ const TIPS = {
20
21
  ],
21
22
  en: [
22
23
  "Dante never says „safe“ without a real line.", "No yes, no change. Ever.",
23
- "Dante sends nothing. No network, no call home, no dependencies.",
24
+ "Dante never calls anyone on his own. Only „update“, after your yes, fetches packs.",
24
25
  "A version number alone never decides — distros patch quietly.",
25
26
  "If he cannot see your target, he says „unknown“. Not „safe“.",
26
27
  "Just say what you want: „check my server root@host“.",
@@ -29,7 +30,7 @@ const TIPS = {
29
30
  ],
30
31
  es: [
31
32
  "Dante nunca dice „seguro“ sin una línea real.", "Sin sí, no hay cambio. Nunca.",
32
- "Dante no envía nada. Sin red, sin llamar a casa, sin dependencias.",
33
+ "Dante nunca llama por su cuenta. Solo „actualiza“, tras tu sí, trae packs.",
33
34
  "Un número de versión solo nunca decide — las distros parchean en silencio.",
34
35
  "Si no ve tu destino, dice „desconocido“. No „seguro“.",
35
36
  "Di lo que quieres: „revisa mi servidor root@host“.",
@@ -37,7 +38,7 @@ const TIPS = {
37
38
  "Lo último: npx dante-watch@latest", "Reglas frescas en packs sueltos: escribe „actualiza“. Los viejos se quedan.",
38
39
  ],
39
40
  };
40
- const OLD = {
41
+ export const OLD = {
41
42
  de: (d) => `Wissen ${d} Tage alt — neueste Updates: ${UPDATE} · frische Regeln: „aktualisier“`,
42
43
  en: (d) => `Knowledge is ${d} days old — latest updates: ${UPDATE} · fresh rules: „update“`,
43
44
  es: (d) => `Conocimiento de hace ${d} días — últimas novedades: ${UPDATE} · reglas frescas: „actualiza“`,
@@ -48,40 +49,16 @@ export function age(now = Date.now()) {
48
49
  return Math.max(0, Math.floor((now - Date.parse(knowledge + "T00:00:00Z")) / 864e5));
49
50
  }
50
51
 
51
- const tty = () => process.stdout.isTTY && !process.env.NO_COLOR;
52
- const cut = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s);
53
-
54
- // One frame: wheel · tip (typed in up to `shown` chars) ··· clock. Plain text widths, then colour.
55
- function frame(tick, tipIdx, shown, cols, lang) {
56
- const days = age();
57
- const stale = days > STALE;
58
- const tip = stale && tipIdx % 2 ? OLD[lang](days) : TIPS[lang][tipIdx % TIPS[lang].length];
59
- const clock = stale ? `bib ${days}d · !` : `bib ${knowledge.slice(8, 10)}.${knowledge.slice(5, 7)}. · ${days}d`;
60
- const room = cols - 2 - 2 - 2 - clock.length - 2;
61
- const text = cut(tip.slice(0, shown), room);
62
- const gap = " ".repeat(Math.max(1, room - text.length + 2));
63
- const tipC = stale && tipIdx % 2 ? "33" : "2";
64
- return ` \x1b[36m${WHEEL[tick % WHEEL.length]}\x1b[0m \x1b[${tipC}m${text}\x1b[0m${gap}\x1b[${stale ? "33" : "2"}m${clock}\x1b[0m`;
52
+ // "bib 25.10. · 0d" — the date of the knowledge and how old it is; "bib 20d · !" once it is stale.
53
+ export function clockText() {
54
+ const days = age(), v = VERSION ? ` · v${VERSION}` : "";
55
+ return (days > STALE ? `bib ${days}d · !` : `bib ${knowledge.slice(8, 10)}.${knowledge.slice(5, 7)}. · ${days}d`) + v;
65
56
  }
66
57
 
67
- // Ask a question with the ticker rolling one row below the cursor. Falls back to a plain question.
68
- export async function askWithTicker(rl, q, lang = "de") {
69
- if (!TIPS[lang]) lang = "en";
70
- if (!tty()) return rl.question(q);
71
- process.stdout.write("\n\x1b[1A"); // reserve the row below, so drawing there never scrolls
72
- let tick = 0, tipIdx = Math.floor(Math.random() * TIPS[lang].length), shown = 0, hold = 0;
73
- const draw = () => {
74
- const cols = process.stdout.columns || 80;
75
- process.stdout.write("\x1b7\x1b[1B\r\x1b[2K" + frame(tick, tipIdx, shown, cols, lang) + "\x1b8");
76
- tick++;
77
- const full = (age() > STALE && tipIdx % 2 ? 80 : TIPS[lang][tipIdx % TIPS[lang].length].length);
78
- if (shown < full) shown += 2;
79
- else if (++hold > 40) { hold = 0; shown = 0; tipIdx++; } // ~5 s per tip, then the next types in
80
- };
81
- const timer = setInterval(draw, 120);
82
- draw();
83
- try { return await rl.question(q); }
84
- finally { clearInterval(timer); process.stdout.write("\r\x1b[2K"); } // Enter moved us onto the ticker row
58
+ // The tip for slot i in a language. Once the knowledge is stale, every second tip says so.
59
+ export function tipAt(i, lang = "de") {
60
+ const L = TIPS[lang] ? lang : "en", days = age();
61
+ return days > STALE && i % 2 ? OLD[L](days) : TIPS[L][i % TIPS[L].length];
85
62
  }
86
63
 
87
64
  // One line for one-shot runs (cron, --json excluded): only when the knowledge is old.
@@ -0,0 +1,46 @@
1
+ // The footer — Virgil's, one to one: the two lowest lines of the terminal stay where they are (a scroll
2
+ // region, like the status bar of vim or tmux) and everything else scrolls above. A thin line, and under it a
3
+ // GTA-loading-screen tip that changes with every input. Dante adds his inner clock on the right.
4
+ // Only in a real terminal. The scroll region is ALWAYS reset on exit, or the terminal would stay frozen.
5
+ import { lang } from "../i18n.mjs";
6
+ import { tipAt, clockText, age, STALE } from "../ticker.mjs";
7
+
8
+ let on = false, timer = null, tipIdx = Math.floor(Math.random() * 8);
9
+ const rows = () => process.stdout.rows || 24, cols = () => process.stdout.columns || 80;
10
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`, yellow = (s) => `\x1b[33m${s}\x1b[0m`;
11
+ const cut = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s);
12
+
13
+ function draw() {
14
+ if (!on) return;
15
+ const r = rows(), c = cols(), clock = clockText();
16
+ const tip = cut(tipAt(tipIdx, lang()), Math.max(10, c - 4 - clock.length - 3));
17
+ const gap = " ".repeat(Math.max(1, c - 4 - tip.length - clock.length - 2));
18
+ const row = dim(" ◍ ") + yellow(tip) + gap + (age() > STALE ? yellow(clock) : dim(clock));
19
+ // save the cursor → the two reserved lines → clear + write → cursor back
20
+ process.stdout.write("\x1b7" + `\x1b[${r - 1};1H\x1b[2K` + dim("─".repeat(Math.max(10, c - 2))) + `\x1b[${r};1H\x1b[2K` + row + "\x1b8");
21
+ }
22
+
23
+ export function footerStart() {
24
+ if (!process.stdout.isTTY || process.env.DANTE_NO_FOOTER || on) return;
25
+ on = true;
26
+ const r = rows();
27
+ process.stdout.write(`\x1b[1;${r - 2}r`); // scroll region = the top, without the lowest 2 lines
28
+ process.stdout.write(`\x1b[${r - 2};1H`); // cursor into the upper part
29
+ draw();
30
+ // four times a second, so the footer stays visible even when readline or other output paints over it
31
+ timer = setInterval(draw, 250); timer.unref?.();
32
+ process.stdout.on("resize", draw);
33
+ process.on("exit", footerStop);
34
+ }
35
+ export function footerStop() {
36
+ if (!on) return;
37
+ on = false;
38
+ if (timer) { clearInterval(timer); timer = null; }
39
+ const r = rows();
40
+ process.stdout.write("\x1b[r"); // scroll region back to the whole screen
41
+ process.stdout.write(`\x1b[${r - 1};1H\x1b[2K\x1b[${r};1H\x1b[2K`);
42
+ try { process.stdout.off("resize", draw); } catch { /* fine */ }
43
+ }
44
+ /** The next tip — call before every input. */
45
+ export function footerNext() { if (on) { tipIdx++; draw(); } }
46
+ export const footerOn = () => on;
@@ -0,0 +1,18 @@
1
+ // Runs in a worker thread: aim at the target, prove the channel, then run what was asked.
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { configureTarget, probe, run } from "../core/sh.mjs";
4
+ import { setLang } from "../i18n.mjs";
5
+ import { activeBattery, runCheck } from "../checks/battery.mjs";
6
+
7
+ const { task, target, lang } = workerData;
8
+ setLang(lang);
9
+ configureTarget(target);
10
+ const ch = probe();
11
+ if (!ch.reached) parentPort.postMessage({ reached: false, line: ch.line });
12
+ else if (task.kind === "checks") {
13
+ const { battery } = activeBattery();
14
+ const by = new Map(battery.map((c) => [c.id, c]));
15
+ parentPort.postMessage({ reached: true, results: task.ids.map((id) => (by.has(id) ? runCheck(by.get(id)) : null)) });
16
+ } else {
17
+ parentPort.postMessage({ reached: true, outs: task.scripts.map((s) => { const r = run(s, { timeout: task.timeout || 15000 }); return { out: r.out, err: r.err, ok: r.ok, ran: r.ran }; }) });
18
+ }
package/src/ui/job.mjs ADDED
@@ -0,0 +1,16 @@
1
+ // The checks block while they run: they call ssh and wait for the server (spawnSync). If that ran on the
2
+ // main thread, the waiting screen could not move. So the slow work runs in a worker thread and the main
3
+ // thread keeps the dinosaur running. The worker gets plain data in and gives plain data back; it loads the
4
+ // same built-in rules and packs as the session does.
5
+ import { Worker } from "node:worker_threads";
6
+ import { lang } from "../i18n.mjs";
7
+
8
+ // task: { kind: "checks", ids } | { kind: "exec", scripts } target: { host, key } | null
9
+ export function job(task, target) {
10
+ return new Promise((resolve, reject) => {
11
+ const w = new Worker(new URL("./job-worker.mjs", import.meta.url), { workerData: { task, target, lang: lang() }, execArgv: [] } /* no start options of the parent (a worker must not inherit --input-type & co) */);
12
+ w.once("message", resolve);
13
+ w.once("error", reject);
14
+ w.once("exit", (code) => { if (code !== 0) reject(new Error("worker exit " + code)); });
15
+ });
16
+ }
@@ -0,0 +1,16 @@
1
+ // What Dante says while you wait. A terminal you stare at for a few seconds may as well say something.
2
+ // Same idea as Virgil's spinner sayings, in Dante's voice (the pilgrim, the map, the circles of Hell).
3
+ import { lang } from "../i18n.mjs";
4
+
5
+ const QUIPS = {
6
+ de: ["zählt die Kreise der Hölle …", "poliert das Schwert …", "liest Virgils Karte …", "fragt den Fährmann …", "sucht die Tür, die offen stand …", "schärft die Zeilen …", "trinkt einen Espresso …", "prüft die Papiere von Charon …", "wartet, bis der Hund aufhört zu bellen …", "geht noch einen Kreis tiefer …", "schaut hinter jede Tür …", "nimmt es nicht für sicher, bis die Zeile da ist …"],
7
+ en: ["counting the circles of Hell …", "polishing the sword …", "reading Virgil's map …", "asking the ferryman …", "looking for the door that stood open …", "sharpening the lines …", "having an espresso …", "checking Charon's papers …", "waiting for the dog to stop barking …", "going one circle deeper …", "looking behind every door …", "not calling it safe until the line is there …"],
8
+ es: ["contando los círculos del infierno …", "puliendo la espada …", "leyendo el mapa de Virgilio …", "preguntando al barquero …", "buscando la puerta que quedó abierta …", "afilando las líneas …", "tomando un espresso …", "revisando los papeles de Caronte …", "esperando a que el perro deje de ladrar …", "bajando un círculo más …", "mirando detrás de cada puerta …", "sin darlo por seguro hasta que esté la línea …"],
9
+ };
10
+ let at = Math.floor(Math.random() * 12), tick = 0;
11
+ /** A saying that changes every ~4 seconds of calls (each frame calls once), so a long wait does not repeat itself. */
12
+ export function quip() {
13
+ const xs = QUIPS[lang()] || QUIPS.en;
14
+ if (tick++ % 33 === 0) at = Math.floor(Math.random() * xs.length);
15
+ return xs[at % xs.length];
16
+ }
@@ -0,0 +1,80 @@
1
+ // The waiting screen — Virgil's, one to one: while a long step runs, one line shows a block swinging back
2
+ // and forth in a bar, a dinosaur running beside it, the name of the step and the seconds. Anything the program
3
+ // prints meanwhile goes past the line first (console.log is wrapped), so nothing is ever written over the bar.
4
+ // Only in a real terminal; DANTE_NO_WAIT=1 switches it off.
5
+ import { quip } from "./quips.mjs";
6
+
7
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`, cyan = (s) => `\x1b[36m${s}\x1b[0m`;
8
+ const stack = [];
9
+ let timer = null, frame = 0, shown = 0, suppressed = 0;
10
+
11
+ const live = () => (!!process.stdout.isTTY || process.env.DANTE_FORCE_WAIT === "1") && !process.env.DANTE_NO_WAIT;
12
+ const BAR = 16, BLOCK = 4;
13
+
14
+ function line() {
15
+ const top = stack[stack.length - 1];
16
+ const secs = Math.floor((Date.now() - top.t0) / 1000);
17
+ const period = (BAR - BLOCK) * 2, p = frame % period;
18
+ const at = p < BAR - BLOCK ? p : period - p; // the block swings to and fro
19
+ let bar = "";
20
+ for (let i = 0; i < BAR; i++) bar += i >= at && i < at + BLOCK ? "▓" : "░";
21
+ const dino = frame % 6 < 3 ? "🦖 " : " 🦖"; // runs, sniffs, runs
22
+ const depth = stack.length > 1 ? dim(" (" + stack.length + " steps)") : "";
23
+ const saying = secs >= 3 ? dim(" · " + quip()) : "";
24
+ return " " + cyan("▕" + bar + "▏") + " " + dino + " " + dim(top.label) + depth + dim(" · " + secs + "s") + saying;
25
+ }
26
+ const width = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length + 2; // an emoji is two columns wide
27
+
28
+ function clear() {
29
+ if (shown > 0) { process.stdout.write("\r\x1b[2K"); shown = 0; }
30
+ }
31
+ function draw() {
32
+ if (!live() || suppressed > 0 || !stack.length) return;
33
+ frame++;
34
+ const l = line();
35
+ clear();
36
+ process.stdout.write(l);
37
+ shown = width(l);
38
+ }
39
+
40
+ const orig = { log: console.log, error: console.error };
41
+ let patched = false;
42
+ function patchConsole() {
43
+ if (patched || !live()) return;
44
+ patched = true;
45
+ console.log = (...a) => { clear(); orig.log(...a); draw(); };
46
+ console.error = (...a) => { clear(); orig.error(...a); draw(); };
47
+ }
48
+ function unpatchConsole() {
49
+ if (!patched) return;
50
+ console.log = orig.log; console.error = orig.error; patched = false;
51
+ }
52
+
53
+ /** Begin a long step. May nest — the innermost one is shown. */
54
+ export function waitStart(label) {
55
+ stack.push({ label, t0: Date.now() });
56
+ patchConsole();
57
+ if (!timer && live()) { timer = setInterval(draw, 120); timer.unref?.(); }
58
+ draw();
59
+ }
60
+ /** End the innermost step. */
61
+ export function waitEnd() {
62
+ stack.pop();
63
+ if (!stack.length) {
64
+ if (timer) clearInterval(timer);
65
+ timer = null; clear(); unpatchConsole();
66
+ }
67
+ }
68
+ /** Do some work with the display — the line goes away by itself, even after an error. */
69
+ export async function waitFor(label, work) {
70
+ waitStart(label);
71
+ try { return await (typeof work === "function" ? work() : work); } finally { waitEnd(); }
72
+ }
73
+ /** Before any other output: line away. After it: draw again. */
74
+ export const waitHide = clear;
75
+ export const waitShow = draw;
76
+ /** A question needs the line: do not draw while it is asked. */
77
+ export function waitSuppress(on) {
78
+ suppressed = Math.max(0, suppressed + (on ? 1 : -1));
79
+ if (on) clear(); else draw();
80
+ }