@mnemahq/cli 0.3.1 → 0.7.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 +42 -1
- package/package.json +8 -4
- package/src/browser.mjs +93 -0
- package/src/cli.mjs +207 -55
- package/src/login.mjs +82 -46
- package/src/paging.mjs +50 -0
- package/src/read-commands.mjs +138 -58
- package/src/render/layout.mjs +117 -0
- package/src/render/pick.mjs +131 -0
- package/src/render/theme.mjs +255 -0
- package/src/render/wait.mjs +96 -0
- package/src/tui/app.mjs +89 -0
- package/src/tui/components/list.mjs +57 -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 +53 -0
- package/src/tui/store.mjs +65 -0
- package/src/tui-gate.mjs +79 -0
- package/src/util.mjs +78 -19
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's presentation primitives (t-628).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ COLOUR WAS UNCONDITIONAL, WHICH IS THE BUG UNDER THE BUG. util.mjs's `c`
|
|
5
|
+
* helper emitted `\x1b[Nm…\x1b[0m` with no isTTY check, no NO_COLOR, no
|
|
6
|
+
* FORCE_COLOR anywhere in the package — so `mnema tasks | less` and
|
|
7
|
+
* `mnema docs > out.txt` both contained raw escape bytes, and nothing in the CLI
|
|
8
|
+
* had any idea how wide the terminal was. Eleven separate hard-coded `padEnd`
|
|
9
|
+
* widths disagreed with each other because there was nothing to agree with.
|
|
10
|
+
*
|
|
11
|
+
* This is the one module that knows about the terminal. Everything above it asks
|
|
12
|
+
* questions ("how wide?", "truncate this") instead of writing escapes.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ THE ORDERING RULE THAT MUST NOT BREAK: pad and truncate the PLAIN string,
|
|
15
|
+
* then colour it. Alignment is correct in the current CLI only because every call
|
|
16
|
+
* site happens to do this by hand — `id.padEnd(8)` before `c.cyan(...)`. An
|
|
17
|
+
* escape sequence is zero columns wide but many characters long, so padding a
|
|
18
|
+
* coloured string pads by the wrong amount and the column silently drifts. Every
|
|
19
|
+
* helper here takes plain text and returns coloured text; none of them accepts
|
|
20
|
+
* pre-coloured input.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ⚠️ THREE SIGNALS, IN THIS ORDER, and the order is the convention every other
|
|
25
|
+
* CLI follows: an explicit FORCE_COLOR wins, then NO_COLOR (https://no-color.org
|
|
26
|
+
* — presence alone counts, whatever the value), then whether stdout is a real
|
|
27
|
+
* terminal. Reading isTTY first would make FORCE_COLOR useless in exactly the
|
|
28
|
+
* case people set it: piping into something that renders colour itself.
|
|
29
|
+
*/
|
|
30
|
+
function detectColour() {
|
|
31
|
+
const env = process.env;
|
|
32
|
+
// ⚠️ AN EMPTY STRING IS "UNSET", NOT "ON". `FORCE_COLOR=` in a shell sets the
|
|
33
|
+
// variable to '' — and reading that as force-on made `NO_COLOR=1 FORCE_COLOR= …`
|
|
34
|
+
// emit colour anyway, which is precisely the combination someone types when
|
|
35
|
+
// they are trying hard to turn it off. Caught by the verification run, not by
|
|
36
|
+
// reading the code.
|
|
37
|
+
const force = env.FORCE_COLOR;
|
|
38
|
+
if (force !== undefined && force !== '') {
|
|
39
|
+
return force !== '0' && force !== 'false';
|
|
40
|
+
}
|
|
41
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false;
|
|
42
|
+
if (env.TERM === 'dumb') return false;
|
|
43
|
+
return Boolean(process.stdout.isTTY);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let colourEnabled = detectColour();
|
|
47
|
+
|
|
48
|
+
/** Test seam. Production code never calls this. */
|
|
49
|
+
export function setColour(on) { colourEnabled = on; }
|
|
50
|
+
export function colourOn() { return colourEnabled; }
|
|
51
|
+
|
|
52
|
+
const wrap = (open) => (s) => (colourEnabled ? `\x1b[${open}m${s}\x1b[0m` : String(s));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Same six names the old `c` helper had, so the migration is a rename rather
|
|
56
|
+
* than a rewrite, plus the two the redesign needs.
|
|
57
|
+
*/
|
|
58
|
+
export const c = {
|
|
59
|
+
dim: wrap(2),
|
|
60
|
+
bold: wrap(1),
|
|
61
|
+
red: wrap(31),
|
|
62
|
+
green: wrap(32),
|
|
63
|
+
yellow: wrap(33),
|
|
64
|
+
cyan: wrap(36),
|
|
65
|
+
blue: wrap(34),
|
|
66
|
+
magenta: wrap(35),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Strip escapes — needed to measure anything that may already be coloured. */
|
|
70
|
+
export function strip(s) {
|
|
71
|
+
// eslint-disable-next-line no-control-regex
|
|
72
|
+
return String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How many terminal columns a string occupies.
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ NOT `.length`. The old `trunc()` measured UTF-16 code units, which is wrong
|
|
79
|
+
* three separate ways: a CJK ideograph is one unit but two columns, an emoji is
|
|
80
|
+
* two units and two columns, and a combining accent is one unit and zero columns.
|
|
81
|
+
* A list of Japanese doc titles came out visibly ragged.
|
|
82
|
+
*
|
|
83
|
+
* Uses Intl.Segmenter to walk graphemes so a family emoji or a flag counts once.
|
|
84
|
+
* It has been in Node since 16, and this package requires 18.
|
|
85
|
+
*/
|
|
86
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
87
|
+
|
|
88
|
+
export function displayWidth(s) {
|
|
89
|
+
const plain = strip(s);
|
|
90
|
+
let w = 0;
|
|
91
|
+
for (const { segment } of segmenter.segment(plain)) {
|
|
92
|
+
const cp = segment.codePointAt(0);
|
|
93
|
+
if (cp === undefined) continue;
|
|
94
|
+
// Zero-width: combining marks, ZWJ, variation selectors, control chars.
|
|
95
|
+
if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x0300 && cp <= 0x036f)) continue;
|
|
96
|
+
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) continue;
|
|
97
|
+
w += isWide(cp) ? 2 : 1;
|
|
98
|
+
}
|
|
99
|
+
return w;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** East Asian Wide + Fullwidth ranges, plus the emoji blocks that render double. */
|
|
103
|
+
function isWide(cp) {
|
|
104
|
+
return (
|
|
105
|
+
(cp >= 0x1100 && cp <= 0x115f) // Hangul Jamo
|
|
106
|
+
|| (cp >= 0x2e80 && cp <= 0xa4cf) // CJK radicals … Yi
|
|
107
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3) // Hangul syllables
|
|
108
|
+
|| (cp >= 0xf900 && cp <= 0xfaff) // CJK compatibility ideographs
|
|
109
|
+
|| (cp >= 0xfe30 && cp <= 0xfe6f) // CJK compatibility forms
|
|
110
|
+
|| (cp >= 0xff00 && cp <= 0xff60) // Fullwidth forms
|
|
111
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
112
|
+
|| (cp >= 0x1f300 && cp <= 0x1f64f) // emoji
|
|
113
|
+
|| (cp >= 0x1f900 && cp <= 0x1f9ff)
|
|
114
|
+
|| (cp >= 0x20000 && cp <= 0x3fffd) // CJK ext B+
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Truncate to `n` COLUMNS, with an ellipsis when it actually cut something.
|
|
120
|
+
*
|
|
121
|
+
* ⚠️ THE ELLIPSIS IS NOT DECORATION. `search` used to `slice(0, 140)` with no
|
|
122
|
+
* marker, so a preview that stopped mid-sentence was indistinguishable from a
|
|
123
|
+
* document that ended there. Everywhere that shortens text must say it did.
|
|
124
|
+
*/
|
|
125
|
+
export function truncate(s, n) {
|
|
126
|
+
const raw = String(s ?? '');
|
|
127
|
+
const plain = strip(raw);
|
|
128
|
+
if (n <= 0) return '';
|
|
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;
|
|
139
|
+
let out = '';
|
|
140
|
+
let w = 0;
|
|
141
|
+
for (const { segment } of segmenter.segment(plain)) {
|
|
142
|
+
const sw = displayWidth(segment);
|
|
143
|
+
if (w + sw > n - 1) break;
|
|
144
|
+
out += segment;
|
|
145
|
+
w += sw;
|
|
146
|
+
}
|
|
147
|
+
return `${out}…`;
|
|
148
|
+
}
|
|
149
|
+
|
|
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
|
+
*/
|
|
158
|
+
export function pad(s, n, align = 'left') {
|
|
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);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Usable terminal width.
|
|
167
|
+
*
|
|
168
|
+
* COLUMNS is honoured because that is how a user (and every test) says "pretend
|
|
169
|
+
* the terminal is this wide" for something that is not a TTY. Clamped low so a
|
|
170
|
+
* 20-column terminal degrades rather than producing negative padding, and high so
|
|
171
|
+
* a maximised 4K window does not stretch a two-column list across 400 characters.
|
|
172
|
+
*/
|
|
173
|
+
export function width() {
|
|
174
|
+
const raw = Number(process.env.COLUMNS) || process.stdout.columns || 80;
|
|
175
|
+
return Math.max(40, Math.min(raw, 120));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render aligned rows from `[{ cells: [...], style?: [...] }]`.
|
|
180
|
+
*
|
|
181
|
+
* ⭐ REPLACES ELEVEN HAND-PICKED padEnd WIDTHS. Column widths are measured from
|
|
182
|
+
* the content, then the LAST column absorbs whatever is left so a long title
|
|
183
|
+
* truncates instead of wrapping and destroying the alignment of every row under
|
|
184
|
+
* it. `style` is applied per cell AFTER padding, preserving the ordering rule at
|
|
185
|
+
* the top of this file.
|
|
186
|
+
*/
|
|
187
|
+
export function table(rows, { indent = 2, gap = 2, max = width() } = {}) {
|
|
188
|
+
if (!rows.length) return [];
|
|
189
|
+
const cols = Math.max(...rows.map((r) => r.cells.length));
|
|
190
|
+
const widths = [];
|
|
191
|
+
for (let i = 0; i < cols; i += 1) {
|
|
192
|
+
widths[i] = Math.max(...rows.map((r) => displayWidth(r.cells[i] ?? '')));
|
|
193
|
+
}
|
|
194
|
+
// Everything except the final column is fixed; the final column gets the rest.
|
|
195
|
+
const fixed = indent + widths.slice(0, -1).reduce((a, b) => a + b + gap, 0);
|
|
196
|
+
widths[cols - 1] = Math.max(8, max - fixed);
|
|
197
|
+
|
|
198
|
+
return rows.map((r) => {
|
|
199
|
+
const parts = r.cells.map((cell, i) => {
|
|
200
|
+
const isLast = i === cols - 1;
|
|
201
|
+
const text = isLast ? truncate(cell ?? '', widths[i]) : pad(cell ?? '', widths[i]);
|
|
202
|
+
const style = r.style?.[i];
|
|
203
|
+
// Trailing cell is not padded — a padded last column leaves invisible
|
|
204
|
+
// whitespace that shows up when someone copies a row out of the terminal.
|
|
205
|
+
const sized = isLast ? text : text;
|
|
206
|
+
return style ? style(sized) : sized;
|
|
207
|
+
});
|
|
208
|
+
return ' '.repeat(indent) + parts.join(' '.repeat(gap)).replace(/\s+$/, '');
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One heading grammar.
|
|
214
|
+
*
|
|
215
|
+
* ⚠️ THE OLD CLI HAD FOUR: product-prefixed ("Mnema status"), noun + count
|
|
216
|
+
* ("Server sessions (7)"), count-first ("12 task(s)"), and bare noun ("Pulse").
|
|
217
|
+
* Nothing was wrong with any one of them; having four made the output read as
|
|
218
|
+
* four different programs.
|
|
219
|
+
*/
|
|
220
|
+
export function section(title, meta) {
|
|
221
|
+
const left = c.bold(title);
|
|
222
|
+
if (!meta) return left;
|
|
223
|
+
const room = width() - displayWidth(title) - displayWidth(meta) - 2;
|
|
224
|
+
return room > 0 ? `${left}${' '.repeat(room)}${c.dim(meta)}` : `${left} ${c.dim(meta)}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A terminal hyperlink (OSC 8), degrading to plain text where unsupported.
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ NOT EMITTED WHEN COLOUR IS OFF. The same detection covers both: if stdout is
|
|
231
|
+
* a pipe, an OSC 8 sequence is corruption in the consumer's data, not a link.
|
|
232
|
+
*/
|
|
233
|
+
export function link(label, url) {
|
|
234
|
+
if (!colourEnabled) return `${label} (${url})`;
|
|
235
|
+
return `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
|
|
236
|
+
}
|
|
237
|
+
|
|
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
|
+
|
|
250
|
+
export const mark = {
|
|
251
|
+
ok: () => c.green(glyph.ok),
|
|
252
|
+
bad: () => c.red(glyph.bad),
|
|
253
|
+
warn: () => c.yellow(glyph.warn),
|
|
254
|
+
dot: () => c.dim(glyph.dot),
|
|
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,89 @@
|
|
|
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
|
+
|
|
22
|
+
const HOME = { name: 'briefing' };
|
|
23
|
+
|
|
24
|
+
export function App({ ctx }) {
|
|
25
|
+
const { exit } = useApp();
|
|
26
|
+
const [stack, setStack] = useState([HOME]);
|
|
27
|
+
const [fatal, setFatal] = useState(null);
|
|
28
|
+
|
|
29
|
+
// ⭐ THE AUTH TAKEOVER HAS TO BE WIRED, not merely rendered. Every screen fetches
|
|
30
|
+
// through this wrapper, so an AuthError anywhere flips the whole app to the
|
|
31
|
+
// signed-out state instead of each screen showing its own red line for the same
|
|
32
|
+
// single cause. Matched on constructor NAME: the SDK's errors can cross a module
|
|
33
|
+
// realm here (the TUI is loaded by a dynamic import) and instanceof is unreliable
|
|
34
|
+
// across realms.
|
|
35
|
+
const wrapped = useMemo(() => ({
|
|
36
|
+
...ctx,
|
|
37
|
+
call: async (fn) => {
|
|
38
|
+
try {
|
|
39
|
+
return await ctx.call(fn);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e?.constructor?.name === 'AuthError' || e?.code === 'unauthorized') {
|
|
42
|
+
setFatal({ kind: 'auth', message: e.message ?? 'That credential is not valid.' });
|
|
43
|
+
}
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}), [ctx]);
|
|
48
|
+
|
|
49
|
+
const top = stack[stack.length - 1];
|
|
50
|
+
const push = (screen) => setStack((s) => [...s, screen]);
|
|
51
|
+
const pop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
|
52
|
+
|
|
53
|
+
useInput((input, key) => {
|
|
54
|
+
if (input === 'q' || (key.ctrl && input === 'c')) { exit(); return; }
|
|
55
|
+
if (key.escape) { pop(); return; }
|
|
56
|
+
if (fatal?.kind === 'auth' && input === 'l') { exit(); return; }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (fatal?.kind === 'auth') {
|
|
60
|
+
return html`
|
|
61
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
62
|
+
<${Text} color="red" bold>Not signed in<//>
|
|
63
|
+
<${Text} dimColor>${fatal.message}<//>
|
|
64
|
+
<${Box} marginTop=${1}><${Text}>Press <${Text} bold>l<//> to quit and run <${Text} bold>mnema login<//><//><//>
|
|
65
|
+
<//>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const body = top.name === 'briefing'
|
|
69
|
+
? html`<${Briefing} ctx=${wrapped} focused=${true} onOpen=${(f) => push({ name: 'finding', finding: f })} />`
|
|
70
|
+
: html`<${Finding} finding=${top.finding} />`;
|
|
71
|
+
|
|
72
|
+
return html`
|
|
73
|
+
<${Box} flexDirection="column">
|
|
74
|
+
<${Box} paddingX=${1}>
|
|
75
|
+
<${Text} bold color="cyan">mnema<//>
|
|
76
|
+
<${Text} dimColor> ${ctx.workspaceName ?? ctx.workspaceId ?? ''}<//>
|
|
77
|
+
${ctx.plan ? html`<${Text} dimColor> ${ctx.plan}<//>` : null}
|
|
78
|
+
${ctx.edition === 'core' ? html`<${Text} dimColor> core<//>` : null}
|
|
79
|
+
<//>
|
|
80
|
+
${body}
|
|
81
|
+
<${Box} paddingX=${1} marginTop=${1}>
|
|
82
|
+
<${Text} dimColor>${top.name === 'briefing'
|
|
83
|
+
? '↑↓ move · enter open · r refresh · q quit'
|
|
84
|
+
: 'esc back · q quit'}<//>
|
|
85
|
+
<//>
|
|
86
|
+
<//>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { HOME };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One selectable list, used by every screen (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ ONE KEYBOARD MODEL, DEFINED ONCE. The fastest way to make a TUI feel
|
|
5
|
+
* unfinished is for two lists to disagree about whether k moves up.
|
|
6
|
+
*
|
|
7
|
+
* The visible window scrolls rather than the list growing without bound: a
|
|
8
|
+
* terminal has a fixed height, and a 40-row findings list drawn in full pushes the
|
|
9
|
+
* header and the caveat banner off the top — the same failure the login dot line
|
|
10
|
+
* had, where the progress indicator destroyed the information it decorated.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState } from 'react';
|
|
14
|
+
import { Box, Text, useInput } from 'ink';
|
|
15
|
+
import { html } from '../h.mjs';
|
|
16
|
+
|
|
17
|
+
export function List({ items, focused = true, height = 10, onSelect }) {
|
|
18
|
+
const [cursor, setCursor] = useState(0);
|
|
19
|
+
const [top, setTop] = useState(0);
|
|
20
|
+
|
|
21
|
+
const move = (delta) => {
|
|
22
|
+
const next = Math.min(items.length - 1, Math.max(0, cursor + delta));
|
|
23
|
+
setCursor(next);
|
|
24
|
+
if (next < top) setTop(next);
|
|
25
|
+
if (next >= top + height) setTop(next - height + 1);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
useInput((input, key) => {
|
|
29
|
+
if (key.upArrow || input === 'k') move(-1);
|
|
30
|
+
else if (key.downArrow || input === 'j') move(1);
|
|
31
|
+
else if (input === 'g') { setCursor(0); setTop(0); }
|
|
32
|
+
else if (input === 'G') { setCursor(items.length - 1); setTop(Math.max(0, items.length - height)); }
|
|
33
|
+
else if (key.return && onSelect) onSelect(cursor);
|
|
34
|
+
}, { isActive: Boolean(focused) && items.length > 0 });
|
|
35
|
+
|
|
36
|
+
const view = items.slice(top, top + height);
|
|
37
|
+
const leftWidth = Math.max(0, ...items.map((i) => (i.left ?? '').length));
|
|
38
|
+
|
|
39
|
+
return html`
|
|
40
|
+
<${Box} flexDirection="column">
|
|
41
|
+
${view.map((item, i) => {
|
|
42
|
+
const idx = top + i;
|
|
43
|
+
const on = idx === cursor && focused;
|
|
44
|
+
return html`
|
|
45
|
+
<${Box} key=${item.key ?? idx}>
|
|
46
|
+
<${Text} color=${on ? 'cyan' : undefined}>${on ? '❯ ' : ' '}<//>
|
|
47
|
+
${item.left !== undefined
|
|
48
|
+
? html`<${Text} dimColor>${item.left.padEnd(leftWidth)} <//>`
|
|
49
|
+
: null}
|
|
50
|
+
<${Text} bold=${on} dimColor=${!on}>${item.label}<//>
|
|
51
|
+
<//>`;
|
|
52
|
+
})}
|
|
53
|
+
${items.length > height
|
|
54
|
+
? html`<${Text} dimColor> ${cursor + 1}/${items.length}<//>`
|
|
55
|
+
: null}
|
|
56
|
+
<//>`;
|
|
57
|
+
}
|
package/src/tui/h.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSX-shaped syntax without a build step (t-632).
|
|
3
|
+
*
|
|
4
|
+
* htm bound to React.createElement gives tagged-template markup that parses at
|
|
5
|
+
* runtime and caches per call site, so `.mjs` files stay directly runnable:
|
|
6
|
+
*
|
|
7
|
+
* html`<${Box} flexDirection="column"><${Text}>hi<//><//>`
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ THIS IS THE LEAST-BAD OPTION, NOT A GOOD ONE. It reads worse than JSX, and
|
|
10
|
+
* every Ink example on the internet needs translating to it. The alternative was
|
|
11
|
+
* adding esbuild, which would end this package's no-build-step property — src/ is
|
|
12
|
+
* published verbatim, `files` needs no dist entry, and `node --check` over src is
|
|
13
|
+
* the whole typecheck. That property is worth more than nicer syntax.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ AND THE CLOSING TAG IS `<//>`, not `</Box>`. Getting it wrong is a runtime
|
|
16
|
+
* parse error rather than a compile error, which is the real cost of no build.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React from 'react';
|
|
20
|
+
import htm from 'htm';
|
|
21
|
+
|
|
22
|
+
export const html = htm.bind(React.createElement);
|
|
23
|
+
export { React };
|