memro-cli 0.1.1 → 0.1.5

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/src/ui.js ADDED
@@ -0,0 +1,131 @@
1
+ // Terminal output for memro-cli: one visual language for the whole flow.
2
+ //
3
+ // ┌ Memro connect api.memro.co
4
+ // │
5
+ // ◇ Where should Memro be set up?
6
+ // │ This project · ~/work/app
7
+ // │
8
+ // └ Done.
9
+ //
10
+ // Every line is cut to the terminal width, so a frame that is redrawn always
11
+ // takes exactly as many rows as it has lines (wrapped lines are what made
12
+ // redraws drift and repeat titles).
13
+
14
+ const ESC = "\x1b[";
15
+
16
+ /** Colors are used only on a terminal, and never with NO_COLOR set. */
17
+ export function useColor(stream = process.stdout) {
18
+ return Boolean(stream.isTTY) && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb";
19
+ }
20
+
21
+ const paint = (open, close) => (s) => (colorOn ? `${ESC}${open}m${s}${ESC}${close}m` : String(s));
22
+ let colorOn = useColor();
23
+ export function setColor(on) { colorOn = on; }
24
+
25
+ export const c = {
26
+ dim: paint("2", "22"),
27
+ bold: paint("1", "22"),
28
+ accent: paint("38;5;141", "39"),
29
+ green: paint("32", "39"),
30
+ yellow: paint("33", "39"),
31
+ red: paint("31", "39"),
32
+ gray: paint("90", "39"),
33
+ };
34
+
35
+ export const S = {
36
+ start: "┌",
37
+ bar: "│",
38
+ end: "└",
39
+ active: "◆",
40
+ done: "◇",
41
+ cancel: "■",
42
+ info: "●",
43
+ warn: "▲",
44
+ ok: "✔",
45
+ radioOn: "●",
46
+ radioOff: "○",
47
+ checkOn: "◉",
48
+ checkOff: "○",
49
+ cursor: "›",
50
+ };
51
+
52
+ export const bar = () => c.gray(S.bar);
53
+
54
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
55
+
56
+ /** Printable width of a string: ANSI codes take no columns. */
57
+ export const visibleLength = (s) => [...String(s).replace(ANSI, "")].length;
58
+
59
+ /**
60
+ * Cut a line to `width` columns, keeping its ANSI codes and ending with "…"
61
+ * when something was cut. Colors are reset at the cut so they don't bleed.
62
+ */
63
+ export function fit(line, width) {
64
+ if (!width || visibleLength(line) <= width) return line;
65
+ const max = Math.max(1, width - 1);
66
+ let out = "";
67
+ let cols = 0;
68
+ for (let i = 0; i < line.length;) {
69
+ ANSI.lastIndex = i;
70
+ const m = ANSI.exec(line);
71
+ if (m && m.index === i) { out += m[0]; i += m[0].length; continue; }
72
+ const ch = String.fromCodePoint(line.codePointAt(i));
73
+ if (cols >= max) break;
74
+ out += ch;
75
+ cols++;
76
+ i += ch.length;
77
+ }
78
+ return out + "…" + (colorOn ? `${ESC}0m` : "");
79
+ }
80
+
81
+ /** Columns available on `stream`, or 0 when unknown (no cutting). */
82
+ export const columns = (stream = process.stdout) => (stream.isTTY && stream.columns) || 0;
83
+
84
+ /**
85
+ * A region of the terminal that can be redrawn in place. Lines are cut to
86
+ * the width, so moving up `rows` always lands on the first line.
87
+ */
88
+ export function liveFrame(output = process.stdout) {
89
+ let rows = 0;
90
+ // Up to the frame's first row, column 1 (ESC[G, not "\r": some terminals
91
+ // translate an output CR into a newline), then clear to the end.
92
+ const clear = () => {
93
+ if (rows) output.write(`${ESC}${rows}A${ESC}G${ESC}0J`);
94
+ rows = 0;
95
+ };
96
+ return {
97
+ draw(lines) {
98
+ const width = columns(output);
99
+ clear();
100
+ output.write(lines.map((l) => fit(l, width)).join("\n") + "\n");
101
+ rows = lines.length;
102
+ },
103
+ clear,
104
+ };
105
+ }
106
+
107
+ // --- Plain output, in the same style ------------------------------------------
108
+
109
+ const out = (s = "") => process.stdout.write(fit(s, columns()) + "\n");
110
+
111
+ export const intro = (title, detail = "") =>
112
+ out(`${c.gray(S.start)} ${c.bold(title)}${detail ? " " + c.dim(detail) : ""}`);
113
+ export const gap = () => out(bar());
114
+ /** A line inside the current section. */
115
+ export const line = (s = "") => out(s ? `${bar()} ${s}` : bar());
116
+ /** A finished step: its question and the answer under it. */
117
+ export function answered(title, answer) {
118
+ out(`${c.green(S.done)} ${title}`);
119
+ if (answer) out(`${bar()} ${c.dim(answer)}`);
120
+ gap();
121
+ }
122
+ export const info = (s) => out(`${c.accent(S.info)} ${s}`);
123
+ export const warn = (s) => out(`${c.yellow(S.warn)} ${s}`);
124
+ export const ok = (label, detail = "") => out(`${bar()} ${c.green(S.ok)} ${label}${detail ? " " + c.dim(detail) : ""}`);
125
+ export const fail = (label, detail = "") => out(`${bar()} ${c.yellow("!")} ${label}${detail ? " " + c.dim(detail) : ""}`);
126
+ export const outro = (s, ...more) => {
127
+ out(`${c.gray(S.end)} ${s}`);
128
+ for (const m of more) out(` ${c.dim(m)}`);
129
+ out();
130
+ };
131
+ export const cancelled = (s = "Cancelled. Nothing changed.") => outro(c.dim(s));