@mnemahq/cli 0.4.0 → 0.9.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 +21 -1
- package/package.json +7 -3
- package/src/browser.mjs +93 -0
- package/src/cli.mjs +176 -52
- package/src/login.mjs +82 -46
- package/src/paging.mjs +50 -0
- package/src/read-commands.mjs +135 -70
- package/src/render/layout.mjs +117 -0
- package/src/render/mark-art.mjs +160 -0
- package/src/render/pick.mjs +131 -0
- package/src/render/theme.mjs +40 -12
- package/src/render/wait.mjs +96 -0
- package/src/tui/app.mjs +127 -0
- package/src/tui/components/header.mjs +66 -0
- package/src/tui/components/list.mjs +91 -0
- package/src/tui/h.mjs +23 -0
- package/src/tui/launch.mjs +61 -0
- package/src/tui/screens/briefing.mjs +74 -0
- package/src/tui/screens/finding.mjs +89 -0
- package/src/tui/screens/node.mjs +106 -0
- package/src/tui/store.mjs +65 -0
- package/src/tui-gate.mjs +79 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Mnema mark, drawn in a terminal (t-636).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THE TRICK IS '▀'. One character cell carries TWO vertical pixels: the
|
|
5
|
+
* foreground colour paints the upper half, the background colour the lower half.
|
|
6
|
+
* That doubles vertical resolution, which matters enormously because a terminal
|
|
7
|
+
* cell is roughly twice as tall as it is wide — without it, any mark comes out
|
|
8
|
+
* squashed to half height and reads as a smudge.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ RASTERISED FROM THE REAL GEOMETRY, NOT EYEBALLED. The mark is
|
|
11
|
+
* apps/web/public/favicon.svg and it is three shapes on a 100×100 field:
|
|
12
|
+
*
|
|
13
|
+
* left stem rect x=24 y=13 w=11 h=74 (full height, has the descender)
|
|
14
|
+
* right stem rect x=65 y=13 w=11 h=47 (stops where the arch meets it)
|
|
15
|
+
* arch M24,60 C24,80 76,80 76,60 L65,60 C65,73 35,73 35,60 Z
|
|
16
|
+
*
|
|
17
|
+
* Hand-drawing an ASCII approximation would drift from the brand the first time
|
|
18
|
+
* either changed, and nothing would catch it. Evaluating the actual geometry means
|
|
19
|
+
* the terminal mark IS the mark — and it costs about forty lines, no dependency,
|
|
20
|
+
* and no SVG parser.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const BLACK = [0x00, 0x00, 0x00];
|
|
24
|
+
const CREAM = [0xed, 0xe8, 0xdc];
|
|
25
|
+
|
|
26
|
+
// ── the geometry, straight from favicon.svg ──────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Cubic Bézier, flattened to points. */
|
|
29
|
+
function cubic(p0, p1, p2, p3, steps = 24) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (let i = 0; i <= steps; i += 1) {
|
|
32
|
+
const t = i / steps;
|
|
33
|
+
const u = 1 - t;
|
|
34
|
+
out.push([
|
|
35
|
+
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
|
|
36
|
+
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The arch as one closed polygon: the outer curve, across, then the inner curve
|
|
44
|
+
* back. It is a single subpath in the SVG and does not self-intersect, so
|
|
45
|
+
* even-odd and nonzero agree and a plain point-in-polygon test is exact.
|
|
46
|
+
*/
|
|
47
|
+
const ARCH = [
|
|
48
|
+
...cubic([24, 60], [24, 80], [76, 80], [76, 60]),
|
|
49
|
+
[65, 60],
|
|
50
|
+
...cubic([65, 60], [65, 73], [35, 73], [35, 60]),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function inPolygon(x, y, poly) {
|
|
54
|
+
let inside = false;
|
|
55
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
|
|
56
|
+
const [xi, yi] = poly[i];
|
|
57
|
+
const [xj, yj] = poly[j];
|
|
58
|
+
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
59
|
+
}
|
|
60
|
+
return inside;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const inRect = (x, y, rx, ry, w, h) => x >= rx && x < rx + w && y >= ry && y < ry + h;
|
|
64
|
+
|
|
65
|
+
/** Is this point on the cream part of the mark? */
|
|
66
|
+
function inMark(x, y) {
|
|
67
|
+
return inRect(x, y, 24, 13, 11, 74) // left stem
|
|
68
|
+
|| inRect(x, y, 65, 13, 11, 47) // right stem
|
|
69
|
+
|| inPolygon(x, y, ARCH); // arch
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── rasterising ──────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Coverage of one output pixel, 0..1, by supersampling.
|
|
76
|
+
*
|
|
77
|
+
* Antialiasing is not decoration here: at ~14 pixels across, a hard threshold
|
|
78
|
+
* turns the arch's curve into a staircase, and the two stems end up different
|
|
79
|
+
* widths depending on where the sample grid happens to land.
|
|
80
|
+
*/
|
|
81
|
+
function coverage(px, py, cols, pxRows, viewW, viewH, samples = 4) {
|
|
82
|
+
// ⚠️ CROPPED TO THE GLYPH, NOT THE ARTBOARD. The SVG's 100×100 field carries
|
|
83
|
+
// generous margins (the mark itself only occupies x 24..76, y 13..87), so
|
|
84
|
+
// rasterising the whole artboard spends a third of a small tile on emptiness
|
|
85
|
+
// and leaves the stems under a pixel wide. `view` is a square window centred on
|
|
86
|
+
// the glyph, which is what lets it stay legible at header size.
|
|
87
|
+
let hit = 0;
|
|
88
|
+
for (let sy = 0; sy < samples; sy += 1) {
|
|
89
|
+
for (let sx = 0; sx < samples; sx += 1) {
|
|
90
|
+
const x = 50 - viewW / 2 + ((px + (sx + 0.5) / samples) / cols) * viewW;
|
|
91
|
+
const y = 50 - viewH / 2 + ((py + (sy + 0.5) / samples) / pxRows) * viewH;
|
|
92
|
+
if (inMark(x, y)) hit += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return hit / (samples * samples);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The glyph's own bounding box, plus a little air. Cropping to THIS rather than to
|
|
99
|
+
// a square is what makes the tile compact: the mark is 52 wide and 74 tall, so a
|
|
100
|
+
// square tile would spend a third of its width on nothing.
|
|
101
|
+
const GLYPH_W = 52 + 10;
|
|
102
|
+
const GLYPH_H = 74 + 10;
|
|
103
|
+
|
|
104
|
+
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
|
|
105
|
+
|
|
106
|
+
/** Nearest xterm-256 index, for terminals without truecolour. */
|
|
107
|
+
function to256([r, g, b]) {
|
|
108
|
+
const q = (v) => (v < 48 ? 0 : v < 114 ? 1 : Math.min(5, Math.round((v - 35) / 40)));
|
|
109
|
+
return 16 + 36 * q(r) + 6 * q(g) + q(b);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* How much colour this terminal can actually show.
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ TRUECOLOUR IS NOT IMPLIED BY 256-COLOUR SUPPORT and there is no query for it
|
|
116
|
+
* that works everywhere — COLORTERM is the convention. Guessing wrong does not
|
|
117
|
+
* degrade gracefully: a 24-bit sequence on a terminal that cannot read it prints
|
|
118
|
+
* the escape as literal text across the screen.
|
|
119
|
+
*/
|
|
120
|
+
export function colourDepth(env = process.env) {
|
|
121
|
+
const ct = String(env.COLORTERM ?? '').toLowerCase();
|
|
122
|
+
if (ct.includes('truecolor') || ct.includes('24bit')) return 24;
|
|
123
|
+
if (/-256(color)?$/.test(String(env.TERM ?? ''))) return 8;
|
|
124
|
+
return 4;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The mark, as an array of lines ready to print.
|
|
129
|
+
*
|
|
130
|
+
* @param size pixels across; the output is `size` columns and `size/2` rows.
|
|
131
|
+
*/
|
|
132
|
+
export function markLines({ rows: wantRows = 5, depth = colourDepth(), tile = BLACK, ink = CREAM } = {}) {
|
|
133
|
+
// Below 24-bit there is no honest way to draw a two-tone antialiased mark, and a
|
|
134
|
+
// banded approximation looks like a rendering fault rather than a logo. The
|
|
135
|
+
// caller falls back to a wordmark.
|
|
136
|
+
if (depth < 8) return null;
|
|
137
|
+
|
|
138
|
+
// Half-blocks give SQUARE pixels — one cell is one column wide and two pixel
|
|
139
|
+
// rows tall — so the aspect ratio is preserved by matching the pixel grid to the
|
|
140
|
+
// glyph's box rather than by fudging either dimension.
|
|
141
|
+
const pxRows = wantRows * 2;
|
|
142
|
+
const cols = Math.max(3, Math.round(pxRows * (GLYPH_W / GLYPH_H)));
|
|
143
|
+
|
|
144
|
+
const rows = [];
|
|
145
|
+
for (let y = 0; y < pxRows; y += 2) {
|
|
146
|
+
let line = '';
|
|
147
|
+
for (let x = 0; x < cols; x += 1) {
|
|
148
|
+
const top = mix(tile, ink, coverage(x, y, cols, pxRows, GLYPH_W, GLYPH_H));
|
|
149
|
+
const bot = mix(tile, ink, coverage(x, y + 1, cols, pxRows, GLYPH_W, GLYPH_H));
|
|
150
|
+
line += depth >= 24
|
|
151
|
+
? `\x1b[38;2;${top.join(';')}m\x1b[48;2;${bot.join(';')}m▀`
|
|
152
|
+
: `\x1b[38;5;${to256(top)}m\x1b[48;5;${to256(bot)}m▀`;
|
|
153
|
+
}
|
|
154
|
+
rows.push(`${line}\x1b[0m`);
|
|
155
|
+
}
|
|
156
|
+
return rows;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Plain-text identity for terminals that cannot draw, and for pipes. */
|
|
160
|
+
export const WORDMARK = 'mnema';
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choose from a list without copying an id (t-631).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THE CLI KEPT ASKING FOR THINGS IT COULD OFFER. `mnema doc` with no argument
|
|
5
|
+
* printed "Usage: mnema doc <id>" — so the actual workflow was: run `mnema docs`,
|
|
6
|
+
* read a truncated title, select eight hex characters with the mouse, paste. Every
|
|
7
|
+
* id in this CLI is a UUID prefix that nobody can hold in their head, and the list
|
|
8
|
+
* command already knows all of them.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ RAW MODE IS THE DANGEROUS PART, and the danger is entirely in the exits. A
|
|
11
|
+
* process that leaves the terminal in raw mode with the cursor hidden hands back a
|
|
12
|
+
* shell where typing shows nothing and Ctrl-C does not work — the user's next move
|
|
13
|
+
* is to close the window. So there is exactly ONE restore path here, every exit
|
|
14
|
+
* goes through it, and it is also wired to SIGINT and to process exit. This is the
|
|
15
|
+
* single most damaging thing a terminal UI can get wrong, and it is worth more care
|
|
16
|
+
* than the feature itself.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { c, truncate, width } from './theme.mjs';
|
|
20
|
+
|
|
21
|
+
const HIDE = '\x1b[?25l';
|
|
22
|
+
const SHOW = '\x1b[?25h';
|
|
23
|
+
const CLEAR = '\x1b[K';
|
|
24
|
+
|
|
25
|
+
/** How many rows to show at once. Longer lists scroll within this window. */
|
|
26
|
+
const WINDOW = 10;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param items [{ label, hint }]
|
|
30
|
+
* @returns the chosen index, or null if cancelled / not interactive
|
|
31
|
+
*/
|
|
32
|
+
export function pick(items, { title = 'Select', stream = process.stderr, input = process.stdin } = {}) {
|
|
33
|
+
// Not interactive: there is nothing to drive the selection with. Returning null
|
|
34
|
+
// rather than hanging on a stdin that will never produce a keypress.
|
|
35
|
+
if (!items.length || !input.isTTY || !stream.isTTY || typeof input.setRawMode !== 'function') {
|
|
36
|
+
return Promise.resolve(null);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
let cursor = 0;
|
|
41
|
+
let top = 0;
|
|
42
|
+
let drawn = 0;
|
|
43
|
+
let finished = false;
|
|
44
|
+
|
|
45
|
+
const paint = () => {
|
|
46
|
+
// Move back over what we drew last time, clearing as we go.
|
|
47
|
+
if (drawn) stream.write(`\x1b[${drawn}A`);
|
|
48
|
+
const w = width();
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push(`${c.bold(title)} ${c.dim('↑↓ move · enter select · esc cancel')}`);
|
|
51
|
+
const view = items.slice(top, top + WINDOW);
|
|
52
|
+
for (let i = 0; i < view.length; i += 1) {
|
|
53
|
+
const idx = top + i;
|
|
54
|
+
const on = idx === cursor;
|
|
55
|
+
const label = truncate(view[i].label ?? '', w - 6);
|
|
56
|
+
// ⚠️ Colour AFTER truncation — the ordering rule from t-628. Truncating a
|
|
57
|
+
// coloured string cuts inside the escape and leaves the terminal stuck in
|
|
58
|
+
// whatever attribute was open.
|
|
59
|
+
lines.push(on ? `${c.cyan('❯')} ${c.bold(label)}` : ` ${c.dim(label)}`);
|
|
60
|
+
}
|
|
61
|
+
if (items.length > WINDOW) {
|
|
62
|
+
lines.push(c.dim(` ${cursor + 1}/${items.length}`));
|
|
63
|
+
}
|
|
64
|
+
stream.write(lines.map((l) => `${CLEAR}${l}`).join('\n') + '\n');
|
|
65
|
+
drawn = lines.length;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ⭐ ONE restore path. Every exit — enter, esc, Ctrl-C, an unexpected throw,
|
|
69
|
+
// the process dying — comes through here, and it is idempotent.
|
|
70
|
+
const restore = () => {
|
|
71
|
+
if (finished) return;
|
|
72
|
+
finished = true;
|
|
73
|
+
try { input.setRawMode(false); } catch { /* already closed */ }
|
|
74
|
+
input.pause();
|
|
75
|
+
input.removeListener('data', onData);
|
|
76
|
+
process.removeListener('SIGINT', onSigint);
|
|
77
|
+
process.removeListener('exit', restore);
|
|
78
|
+
if (drawn) stream.write(`\x1b[${drawn}A`);
|
|
79
|
+
for (let i = 0; i < drawn; i += 1) stream.write(`${CLEAR}\n`);
|
|
80
|
+
if (drawn) stream.write(`\x1b[${drawn}A`);
|
|
81
|
+
stream.write(SHOW);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function onSigint() {
|
|
85
|
+
restore();
|
|
86
|
+
// Exit the way an unhandled Ctrl-C would, so shells see the right status.
|
|
87
|
+
process.exit(130);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Named constants, because a literal control byte pasted into source is
|
|
91
|
+
// invisible in a diff and unreadable in a comparison.
|
|
92
|
+
const CTRL_C = '\u0003';
|
|
93
|
+
const CTRL_D = '\u0004';
|
|
94
|
+
const ESC = '\u001b';
|
|
95
|
+
const UP = '\u001b[A';
|
|
96
|
+
const DOWN = '\u001b[B';
|
|
97
|
+
const PGUP = '\u001b[5~';
|
|
98
|
+
const PGDN = '\u001b[6~';
|
|
99
|
+
|
|
100
|
+
function onData(buf) {
|
|
101
|
+
const s = buf.toString('utf8');
|
|
102
|
+
// ⚠️ In raw mode Ctrl-C is DATA, not a signal — nothing else handles it.
|
|
103
|
+
if (s === CTRL_C) { onSigint(); return; }
|
|
104
|
+
if (s === CTRL_D || s === ESC || s === 'q') { restore(); resolve(null); return; }
|
|
105
|
+
if (s === '\r' || s === '\n') { restore(); resolve(cursor); return; }
|
|
106
|
+
|
|
107
|
+
let moved = 0;
|
|
108
|
+
if (s === UP || s === 'k') moved = -1;
|
|
109
|
+
else if (s === DOWN || s === 'j') moved = 1;
|
|
110
|
+
else if (s === PGUP) moved = -WINDOW;
|
|
111
|
+
else if (s === PGDN) moved = WINDOW;
|
|
112
|
+
else if (s === 'g') { cursor = 0; top = 0; paint(); return; }
|
|
113
|
+
else if (s === 'G') { cursor = items.length - 1; top = Math.max(0, items.length - WINDOW); paint(); return; }
|
|
114
|
+
if (!moved) return;
|
|
115
|
+
|
|
116
|
+
cursor = Math.min(items.length - 1, Math.max(0, cursor + moved));
|
|
117
|
+
if (cursor < top) top = cursor;
|
|
118
|
+
if (cursor >= top + WINDOW) top = cursor - WINDOW + 1;
|
|
119
|
+
paint();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
stream.write(HIDE);
|
|
123
|
+
input.setRawMode(true);
|
|
124
|
+
input.resume();
|
|
125
|
+
input.setEncoding('utf8');
|
|
126
|
+
input.on('data', onData);
|
|
127
|
+
process.on('SIGINT', onSigint);
|
|
128
|
+
process.on('exit', restore);
|
|
129
|
+
paint();
|
|
130
|
+
});
|
|
131
|
+
}
|
package/src/render/theme.mjs
CHANGED
|
@@ -123,9 +123,19 @@ function isWide(cp) {
|
|
|
123
123
|
* document that ended there. Everywhere that shortens text must say it did.
|
|
124
124
|
*/
|
|
125
125
|
export function truncate(s, n) {
|
|
126
|
-
const
|
|
126
|
+
const raw = String(s ?? '');
|
|
127
|
+
const plain = strip(raw);
|
|
127
128
|
if (n <= 0) return '';
|
|
128
|
-
|
|
129
|
+
// ⭐ IF IT FITS, HAND BACK WHAT WE WERE GIVEN — escapes and all. Unconditionally
|
|
130
|
+
// returning the stripped string is why `c.bold(user_code)` rendered plain in the
|
|
131
|
+
// login block and `c.red('not linked')` renders plain in `status`: the caller
|
|
132
|
+
// coloured it, this measured it, and the colour was silently thrown away.
|
|
133
|
+
//
|
|
134
|
+
// ⚠️ WHEN IT MUST CUT, THE COLOUR STILL GOES. Slicing inside an SGR run leaves
|
|
135
|
+
// the terminal stuck in whatever attribute was open, which is worse than a plain
|
|
136
|
+
// string. So: preserved when whole, dropped when cut — and the fix for a long
|
|
137
|
+
// coloured value is to pass plain text plus a style function, as table() wants.
|
|
138
|
+
if (displayWidth(plain) <= n) return raw;
|
|
129
139
|
let out = '';
|
|
130
140
|
let w = 0;
|
|
131
141
|
for (const { segment } of segmenter.segment(plain)) {
|
|
@@ -137,12 +147,19 @@ export function truncate(s, n) {
|
|
|
137
147
|
return `${out}…`;
|
|
138
148
|
}
|
|
139
149
|
|
|
140
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Pad to `n` columns.
|
|
152
|
+
*
|
|
153
|
+
* Measures with escapes stripped and pads the ORIGINAL, so a pre-coloured cell
|
|
154
|
+
* keeps its colour and still lands in the right column. Padding is whitespace
|
|
155
|
+
* outside the SGR run, so there is nothing unsafe about it — unlike truncation,
|
|
156
|
+
* which has to cut and therefore cannot preserve an open attribute.
|
|
157
|
+
*/
|
|
141
158
|
export function pad(s, n, align = 'left') {
|
|
142
|
-
const
|
|
143
|
-
const gap = n - displayWidth(
|
|
144
|
-
if (gap <= 0) return
|
|
145
|
-
return align === 'right' ? ' '.repeat(gap) +
|
|
159
|
+
const raw = String(s ?? '');
|
|
160
|
+
const gap = n - displayWidth(raw);
|
|
161
|
+
if (gap <= 0) return raw;
|
|
162
|
+
return align === 'right' ? ' '.repeat(gap) + raw : raw + ' '.repeat(gap);
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
/**
|
|
@@ -218,10 +235,21 @@ export function link(label, url) {
|
|
|
218
235
|
return `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
|
|
219
236
|
}
|
|
220
237
|
|
|
221
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Status glyphs, used consistently rather than ad hoc per command.
|
|
240
|
+
*
|
|
241
|
+
* ⚠️ TWO FORMS ON PURPOSE. `mark.*` returns a COLOURED glyph for direct printing;
|
|
242
|
+
* `glyph.*` is the plain character, for anything that will be measured or padded.
|
|
243
|
+
* Passing `mark.ok()` into table() silently loses the colour — pad() strips escapes
|
|
244
|
+
* to measure, exactly as the ordering rule at the top of this file requires — so a
|
|
245
|
+
* table wants the plain glyph plus a style function, not a pre-coloured cell.
|
|
246
|
+
* I hit this myself the first time I wrote the doctor list.
|
|
247
|
+
*/
|
|
248
|
+
export const glyph = { ok: '✓', bad: '✗', warn: '!', dot: '·' };
|
|
249
|
+
|
|
222
250
|
export const mark = {
|
|
223
|
-
ok: () => c.green(
|
|
224
|
-
bad: () => c.red(
|
|
225
|
-
warn: () => c.yellow(
|
|
226
|
-
dot: () => c.dim(
|
|
251
|
+
ok: () => c.green(glyph.ok),
|
|
252
|
+
bad: () => c.red(glyph.bad),
|
|
253
|
+
warn: () => c.yellow(glyph.warn),
|
|
254
|
+
dot: () => c.dim(glyph.dot),
|
|
227
255
|
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Showing that something is still happening (t-631).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THE LOGIN GREW A DOT PER POLL. `mnema login` wrote one '.' every `interval`
|
|
5
|
+
* seconds — 5s by default against a code that lives 10 minutes, so up to 120 dots.
|
|
6
|
+
* They wrap. On an 80-column terminal the second line of dots pushes the
|
|
7
|
+
* `user_code` off the top, and the code is THE ONE THING the user has to read. A
|
|
8
|
+
* progress indicator that destroys the information it is decorating is worse than
|
|
9
|
+
* no progress indicator.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ AND IT COULD NOT CLEAN UP AFTER ITSELF. Success did:
|
|
12
|
+
*
|
|
13
|
+
* process.stdout.write('\r' + ' '.repeat(40) + '\r')
|
|
14
|
+
*
|
|
15
|
+
* Forty spaces, against a line that is 23 characters plus every dot written since.
|
|
16
|
+
* Past the fortieth character the residue simply stayed on screen. `\x1b[K` clears
|
|
17
|
+
* from the cursor to the end of the line and cannot be off by a count.
|
|
18
|
+
*
|
|
19
|
+
* A spinner redraws ONE line in place, so the code above it stays put however long
|
|
20
|
+
* the wait runs.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { c } from './theme.mjs';
|
|
24
|
+
|
|
25
|
+
const BRAILLE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
26
|
+
const ASCII = ['-', '\\', '|', '/'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* ⚠️ NOT EVERY TERMINAL HAS THE BRAILLE BLOCK. Where it is missing the frame
|
|
30
|
+
* renders as a replacement box that flickers once a tick, which looks broken
|
|
31
|
+
* rather than busy. Locale is the only signal available without querying the
|
|
32
|
+
* terminal, so an ASCII fallback is the honest default when it is not UTF-8.
|
|
33
|
+
*/
|
|
34
|
+
function frames() {
|
|
35
|
+
const enc = `${process.env.LC_ALL ?? ''}${process.env.LC_CTYPE ?? ''}${process.env.LANG ?? ''}`;
|
|
36
|
+
return /UTF-?8/i.test(enc) ? BRAILLE : ASCII;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CLEAR_LINE = '\r\x1b[K';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A single self-redrawing line.
|
|
43
|
+
*
|
|
44
|
+
* `stream` defaults to stderr so a spinner never lands in piped DATA — the same
|
|
45
|
+
* rule `doctor` needed in t-630, where "… checking connectivity" was being
|
|
46
|
+
* captured into `report.txt` as though it were part of the report.
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ NO-OP ON A NON-TTY, and that is not merely tidy. Writing \r frames into a log
|
|
49
|
+
* file produces one enormous unreadable line, and into a CI transcript, thousands.
|
|
50
|
+
*/
|
|
51
|
+
export function spinner(label, { stream = process.stderr, interval = 100 } = {}) {
|
|
52
|
+
const live = Boolean(stream.isTTY);
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
let i = 0;
|
|
55
|
+
let timer = null;
|
|
56
|
+
let text = label;
|
|
57
|
+
const f = frames();
|
|
58
|
+
|
|
59
|
+
const elapsed = () => {
|
|
60
|
+
const s = Math.round((Date.now() - startedAt) / 1000);
|
|
61
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const draw = () => {
|
|
65
|
+
stream.write(`${CLEAR_LINE} ${c.cyan(f[i % f.length])} ${text} ${c.dim(elapsed())}`);
|
|
66
|
+
i += 1;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (live) {
|
|
70
|
+
draw();
|
|
71
|
+
timer = setInterval(draw, interval);
|
|
72
|
+
// Do not hold the process open just to animate.
|
|
73
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
/** Change the message without losing the line or the elapsed clock. */
|
|
78
|
+
update(next) {
|
|
79
|
+
text = next;
|
|
80
|
+
if (live) draw();
|
|
81
|
+
},
|
|
82
|
+
/**
|
|
83
|
+
* Erase the line and optionally print something in its place.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ ALWAYS ERASES, even when it was never drawn, because a caller that has to
|
|
86
|
+
* remember whether the terminal was live will eventually forget.
|
|
87
|
+
*/
|
|
88
|
+
stop(finalLine) {
|
|
89
|
+
if (timer) clearInterval(timer);
|
|
90
|
+
if (live) stream.write(CLEAR_LINE);
|
|
91
|
+
if (finalLine) stream.write(`${finalLine}\n`);
|
|
92
|
+
},
|
|
93
|
+
elapsed,
|
|
94
|
+
live,
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/tui/app.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell: header, screen stack, key map, error panel (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ A STACK, NOT A ROUTER, and `esc` pops it. But the top-level screen keys
|
|
5
|
+
* REPLACE the root rather than pushing, so mashing keys cannot build a
|
|
6
|
+
* forty-deep stack that takes forty escapes to unwind.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ AUTH IS A TAKEOVER, NOT A PANEL. Every screen needs a credential, so an
|
|
9
|
+
* AuthError leaves nothing usable behind it. And the device flow deliberately does
|
|
10
|
+
* NOT run inside Ink: it needs its own stdout and a browser, and it would fight
|
|
11
|
+
* the render loop for the terminal. Pressing `l` quits cleanly and prints the
|
|
12
|
+
* command to run — which is honest about what is about to happen, rather than
|
|
13
|
+
* appearing to log in and then redrawing over the code the user has to read.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState, useMemo } from 'react';
|
|
17
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
18
|
+
import { html } from './h.mjs';
|
|
19
|
+
import { Briefing } from './screens/briefing.mjs';
|
|
20
|
+
import { Finding } from './screens/finding.mjs';
|
|
21
|
+
import { NodeScreen } from './screens/node.mjs';
|
|
22
|
+
import { Header } from './components/header.mjs';
|
|
23
|
+
|
|
24
|
+
const HOME = { name: 'briefing' };
|
|
25
|
+
|
|
26
|
+
export function App({ ctx }) {
|
|
27
|
+
const { exit } = useApp();
|
|
28
|
+
const [stack, setStack] = useState([HOME]);
|
|
29
|
+
const [fatal, setFatal] = useState(null);
|
|
30
|
+
|
|
31
|
+
// ⭐ THE AUTH TAKEOVER HAS TO BE WIRED, not merely rendered. Every screen fetches
|
|
32
|
+
// through this wrapper, so an AuthError anywhere flips the whole app to the
|
|
33
|
+
// signed-out state instead of each screen showing its own red line for the same
|
|
34
|
+
// single cause. Matched on constructor NAME: the SDK's errors can cross a module
|
|
35
|
+
// realm here (the TUI is loaded by a dynamic import) and instanceof is unreliable
|
|
36
|
+
// across realms.
|
|
37
|
+
const wrapped = useMemo(() => ({
|
|
38
|
+
...ctx,
|
|
39
|
+
call: async (fn) => {
|
|
40
|
+
try {
|
|
41
|
+
return await ctx.call(fn);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (e?.constructor?.name === 'AuthError' || e?.code === 'unauthorized') {
|
|
44
|
+
setFatal({ kind: 'auth', message: e.message ?? 'That credential is not valid.' });
|
|
45
|
+
}
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}), [ctx]);
|
|
50
|
+
|
|
51
|
+
const top = stack[stack.length - 1];
|
|
52
|
+
const push = (screen) => setStack((s) => [...s, screen]);
|
|
53
|
+
const pop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
|
54
|
+
|
|
55
|
+
useInput((input, key) => {
|
|
56
|
+
if (input === 'q' || (key.ctrl && input === 'c')) { exit(); return; }
|
|
57
|
+
if (key.escape) { pop(); return; }
|
|
58
|
+
if (fatal?.kind === 'auth' && input === 'l') { exit(); return; }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (fatal?.kind === 'auth') {
|
|
62
|
+
return html`
|
|
63
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
64
|
+
<${Text} color="red" bold>Not signed in<//>
|
|
65
|
+
<${Text} dimColor>${fatal.message}<//>
|
|
66
|
+
<${Box} marginTop=${1}><${Text}>Press <${Text} bold>l<//> to quit and run <${Text} bold>mnema login<//><//><//>
|
|
67
|
+
<//>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The trail is the stack's node screens, so the breadcrumb is derived rather
|
|
71
|
+
// than tracked separately — one source of truth for "where am I".
|
|
72
|
+
const trail = stack.filter((s) => s.name === 'node').map((s) => s.target.label ?? s.target.id);
|
|
73
|
+
|
|
74
|
+
let body;
|
|
75
|
+
if (top.name === 'briefing') {
|
|
76
|
+
body = html`<${Briefing} ctx=${wrapped} focused=${true} onOpen=${(f) => push({ name: 'finding', finding: f })} />`;
|
|
77
|
+
} else if (top.name === 'node') {
|
|
78
|
+
body = html`<${NodeScreen}
|
|
79
|
+
ctx=${wrapped}
|
|
80
|
+
target=${top.target}
|
|
81
|
+
trail=${trail}
|
|
82
|
+
focused=${true}
|
|
83
|
+
onOpen=${(n) => push({ name: 'node', target: n })}
|
|
84
|
+
/>`;
|
|
85
|
+
} else {
|
|
86
|
+
// ⭐ A FINDING IS NO LONGER A DEAD END. Most carry subjectNodeId, so `enter`
|
|
87
|
+
// walks from "276 merged PRs have no task" into the actual node it is about —
|
|
88
|
+
// which is the difference between a report and something you can investigate.
|
|
89
|
+
body = html`<${Finding}
|
|
90
|
+
finding=${top.finding}
|
|
91
|
+
focused=${true}
|
|
92
|
+
onOpenSubject=${(t) => push({ name: 'node', target: t })}
|
|
93
|
+
/>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return html`
|
|
97
|
+
<${Box} flexDirection="column">
|
|
98
|
+
<${Header}
|
|
99
|
+
version=${ctx.version ?? ''}
|
|
100
|
+
workspace=${ctx.workspaceName ?? ctx.workspaceId ?? ''}
|
|
101
|
+
plan=${ctx.plan}
|
|
102
|
+
edition=${ctx.edition}
|
|
103
|
+
cwd=${ctx.root}
|
|
104
|
+
/>
|
|
105
|
+
${body}
|
|
106
|
+
<${Box} paddingX=${1} marginTop=${1}>
|
|
107
|
+
<${Text} dimColor>${footerFor(top)}<//>
|
|
108
|
+
<//>
|
|
109
|
+
<//>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The keys that actually do something on THIS screen — never a generic legend. */
|
|
113
|
+
function footerFor(top) {
|
|
114
|
+
if (top.name === 'briefing') return '↑↓ move · enter open · r refresh · q quit';
|
|
115
|
+
if (top.name === 'node') return '↑↓ move · enter walk in · esc back · r refresh · q quit';
|
|
116
|
+
if (top.name === 'finding') {
|
|
117
|
+
// Only offer the key that works. A grouped finding has members to open; a
|
|
118
|
+
// single one is an aggregate with nothing beneath it, and advertising `enter`
|
|
119
|
+
// there teaches people the UI is unresponsive.
|
|
120
|
+
return top.finding?.members?.length
|
|
121
|
+
? '↑↓ move · enter open in graph · esc back · q quit'
|
|
122
|
+
: 'esc back · q quit';
|
|
123
|
+
}
|
|
124
|
+
return 'esc back · q quit';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { HOME };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity block (t-636).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ WHAT MAKES A TERMINAL APP LOOK LIKE A PRODUCT rather than a shell prompt is
|
|
5
|
+
* this exact thing: a mark, then who you are and where you are, before anything
|
|
6
|
+
* else. The old header was one line of text and read as output; this reads as an
|
|
7
|
+
* application you have opened.
|
|
8
|
+
*
|
|
9
|
+
* ░█░···░█░ Mnema 0.8.0
|
|
10
|
+
* ░█░···░█░ Mnema · team · a67e6584
|
|
11
|
+
* ░█▓░░░▓▓ ~/Projects/project-x
|
|
12
|
+
* ░█▓▓▓▓░
|
|
13
|
+
* ░█░
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ THE MARK IS NOT ALWAYS DRAWABLE, and the failure is ugly rather than subtle:
|
|
16
|
+
* a 24-bit escape on a terminal that cannot read it prints as literal text across
|
|
17
|
+
* the screen. markLines() returns null below 256 colours and the header falls back
|
|
18
|
+
* to the wordmark, which is a real state and not a theoretical one — TERM=xterm
|
|
19
|
+
* over a plain ssh hits it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Box, Text } from 'ink';
|
|
23
|
+
import { html } from '../h.mjs';
|
|
24
|
+
import { markLines, WORDMARK } from '../../render/mark-art.mjs';
|
|
25
|
+
|
|
26
|
+
/** Shorten a home-relative path the way a shell prompt would. */
|
|
27
|
+
function shortPath(cwd, home = process.env.HOME) {
|
|
28
|
+
if (!cwd) return '';
|
|
29
|
+
return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Header({ version, workspace, plan, edition, cwd, rows = 6 }) {
|
|
33
|
+
const art = markLines({ rows });
|
|
34
|
+
|
|
35
|
+
const facts = [workspace, plan, edition === 'core' ? 'core' : null].filter(Boolean).join(' · ');
|
|
36
|
+
|
|
37
|
+
const info = html`
|
|
38
|
+
<${Box} flexDirection="column">
|
|
39
|
+
<${Box}>
|
|
40
|
+
<${Text} bold>Mnema<//>
|
|
41
|
+
<${Text} dimColor> ${version}<//>
|
|
42
|
+
<//>
|
|
43
|
+
${facts ? html`<${Text} dimColor>${facts}<//>` : null}
|
|
44
|
+
${cwd ? html`<${Text} dimColor>${shortPath(cwd)}<//>` : null}
|
|
45
|
+
<//>`;
|
|
46
|
+
|
|
47
|
+
if (!art) {
|
|
48
|
+
// No mark: keep the same shape, just without the tile. Dropping the block
|
|
49
|
+
// entirely would make the app look different on different machines for a
|
|
50
|
+
// reason the user cannot see.
|
|
51
|
+
return html`
|
|
52
|
+
<${Box} paddingX=${1} paddingY=${0}>
|
|
53
|
+
<${Text} bold color="cyan">${WORDMARK}<//>
|
|
54
|
+
<${Text}> <//>
|
|
55
|
+
${info}
|
|
56
|
+
<//>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return html`
|
|
60
|
+
<${Box} paddingX=${1}>
|
|
61
|
+
<${Box} flexDirection="column" marginRight=${2}>
|
|
62
|
+
${art.map((line, i) => html`<${Text} key=${i}>${line}<//>`)}
|
|
63
|
+
<//>
|
|
64
|
+
${info}
|
|
65
|
+
<//>`;
|
|
66
|
+
}
|