@mnemahq/cli 0.4.0 → 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 +21 -1
- package/package.json +7 -3
- package/src/browser.mjs +93 -0
- package/src/cli.mjs +160 -49
- 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/pick.mjs +131 -0
- package/src/render/theme.mjs +40 -12
- 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/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,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 };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lazy boundary (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THIS MODULE IS ONLY EVER REACHED THROUGH `await import()`. That is the whole
|
|
5
|
+
* design: one seam in cli.mjs, so that inside src/tui/** the imports can be
|
|
6
|
+
* ordinary static ones rather than dozens of dynamic imports scattered through
|
|
7
|
+
* components. Nothing outside src/tui/ may import anything in it statically.
|
|
8
|
+
*
|
|
9
|
+
* Measured, on this machine:
|
|
10
|
+
*
|
|
11
|
+
* import('ink') 136–141 ms
|
|
12
|
+
* mnema --version 59–65 ms
|
|
13
|
+
*
|
|
14
|
+
* So a static import would roughly triple the cost of every one-shot command —
|
|
15
|
+
* and `mnema` runs from capture hooks and shell prompts, not just by hand.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ A TUI THAT CRASHES WITHOUT RESTORING THE TERMINAL IS WORSE THAN NO TUI. Ink
|
|
18
|
+
* takes the alternate screen and hides the cursor; if the process dies without
|
|
19
|
+
* unmounting, the user gets back a shell with an invisible cursor and no echo. So
|
|
20
|
+
* unmount runs from an uncaughtException handler as well as from the normal exit,
|
|
21
|
+
* and the cursor is shown again explicitly rather than trusting the library to do
|
|
22
|
+
* it on the way out of an abnormal exit.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { render } from 'ink';
|
|
26
|
+
import { html } from './h.mjs';
|
|
27
|
+
import { App } from './app.mjs';
|
|
28
|
+
|
|
29
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
30
|
+
|
|
31
|
+
export async function startTui(ctx) {
|
|
32
|
+
const instance = render(html`<${App} ctx=${ctx} />`, {
|
|
33
|
+
// Ink's own exitOnCtrlC would leave our own key handling out of the loop.
|
|
34
|
+
exitOnCtrlC: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const restore = () => {
|
|
38
|
+
try { instance.unmount(); } catch { /* already gone */ }
|
|
39
|
+
try { process.stdout.write(SHOW_CURSOR); } catch { /* stream closed */ }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const onFatal = (err) => {
|
|
43
|
+
restore();
|
|
44
|
+
// Print AFTER restoring, or the message lands on the alternate screen and
|
|
45
|
+
// vanishes with it — the user sees a crash with no text.
|
|
46
|
+
process.stderr.write(`\nmnema: ${err?.stack ?? err}\n`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
process.on('uncaughtException', onFatal);
|
|
51
|
+
process.on('unhandledRejection', onFatal);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await instance.waitUntilExit();
|
|
55
|
+
} finally {
|
|
56
|
+
process.off('uncaughtException', onFatal);
|
|
57
|
+
process.off('unhandledRejection', onFatal);
|
|
58
|
+
try { process.stdout.write(SHOW_CURSOR); } catch { /* stream closed */ }
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The home screen: what deserves attention (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE COVERAGE CAVEATS RENDER ABOVE THE LIST, and that is not a layout
|
|
5
|
+
* preference. read-commands.mjs:198 prints them first on purpose: a short findings
|
|
6
|
+
* list has three quite different causes and only one of them is good news. On a
|
|
7
|
+
* core build six of the seven finding families cannot produce anything at all, and
|
|
8
|
+
* `coverage.notice` is the only thing that says so. Put the banner under the list,
|
|
9
|
+
* where it is off-screen on a short terminal, and the reader concludes "all clear"
|
|
10
|
+
* from a list that is short because the engine never ran.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Box, Text, useInput } from 'ink';
|
|
14
|
+
import { html } from '../h.mjs';
|
|
15
|
+
import { useResource, ago } from '../store.mjs';
|
|
16
|
+
import { List } from '../components/list.mjs';
|
|
17
|
+
|
|
18
|
+
export function Briefing({ ctx, onOpen, focused }) {
|
|
19
|
+
const { status, data, error, elapsed, at, reload } = useResource('briefing', () =>
|
|
20
|
+
ctx.call((m) => m.findings.briefing()));
|
|
21
|
+
|
|
22
|
+
// Refresh is explicit. See store.mjs: a list that reorders under a moving cursor
|
|
23
|
+
// means pressing enter on row three opens what is now row four.
|
|
24
|
+
useInput((input) => { if (input === 'r') reload(); }, { isActive: Boolean(focused) });
|
|
25
|
+
|
|
26
|
+
if (status === 'loading') {
|
|
27
|
+
return html`<${Box} paddingX=${1}>
|
|
28
|
+
<${Text} dimColor>Loading the briefing… ${elapsed > 1 ? `${elapsed}s` : ''}<//>
|
|
29
|
+
<//>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (error && !data) {
|
|
33
|
+
return html`<${Box} flexDirection="column" paddingX=${1}>
|
|
34
|
+
<${Text} color="red">${error.message ?? String(error)}<//>
|
|
35
|
+
<${Text} dimColor>r to retry · q to quit<//>
|
|
36
|
+
<//>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const b = data ?? {};
|
|
40
|
+
const p = b.pulse ?? {};
|
|
41
|
+
const findings = b.findings ?? [];
|
|
42
|
+
|
|
43
|
+
return html`
|
|
44
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
45
|
+
<${Box} gap=${3}>
|
|
46
|
+
<${Text}><${Text} dimColor>projects </>${p.activeProjects ?? 0}<//>
|
|
47
|
+
<${Text}><${Text} dimColor>problems </><${Text} color=${(p.problemsInActiveProjects ?? 0) > 0 ? 'yellow' : undefined}>${p.problemsInActiveProjects ?? 0}<//><//>
|
|
48
|
+
<${Text}><${Text} dimColor>spend 7d </>$${p.cost7dUsd ?? 0}<//>
|
|
49
|
+
<${Text} dimColor>${ago(at)}${status === 'refreshing' ? ' · refreshing' : ''}<//>
|
|
50
|
+
<//>
|
|
51
|
+
|
|
52
|
+
${b.neverComputed ? html`<${Box} marginTop=${1}><${Text} color="yellow">! The findings engine has never run for this workspace.<//><//>` : null}
|
|
53
|
+
${b.coverage?.degraded ? html`<${Box} marginTop=${1}><${Text} color="yellow">! ${b.coverage.notice}<//><//>` : null}
|
|
54
|
+
|
|
55
|
+
<${Box} marginTop=${1}>
|
|
56
|
+
<${Text} bold>Findings<//>
|
|
57
|
+
<${Text} dimColor> ${findings.length}<//>
|
|
58
|
+
<//>
|
|
59
|
+
|
|
60
|
+
${findings.length === 0
|
|
61
|
+
? html`<${Text} dimColor> Nothing surfaced.<//>`
|
|
62
|
+
: html`<${List}
|
|
63
|
+
items=${findings.map((f) => ({
|
|
64
|
+
key: f.id ?? f.headline,
|
|
65
|
+
left: String(f.kind ?? ''),
|
|
66
|
+
label: `${f.headline ?? ''}${f.grouped ? ` ×${f.count}` : ''}`,
|
|
67
|
+
}))}
|
|
68
|
+
focused=${focused}
|
|
69
|
+
onSelect=${(i) => onOpen(findings[i])}
|
|
70
|
+
/>`}
|
|
71
|
+
|
|
72
|
+
${error ? html`<${Box} marginTop=${1}><${Text} color="yellow">refresh failed — ${error.message ?? String(error)}<//><//>` : null}
|
|
73
|
+
<//>`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One finding, in full (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE HEADLINE IS A SUMMARY, AND A SUMMARY OF A FINDING IS EXACTLY WHERE A
|
|
5
|
+
* NUMBER LOSES ITS CAVEATS. "0 of 1238 decisions turned into work" reads as an
|
|
6
|
+
* indictment until you know the window is 90 days and the engine only sees
|
|
7
|
+
* decisions that were recorded. So the detail view shows what the engine actually
|
|
8
|
+
* had — subject, evidence, window — rather than restating the headline larger.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Box, Text } from 'ink';
|
|
12
|
+
import { html } from '../h.mjs';
|
|
13
|
+
|
|
14
|
+
const Row = ({ label, children }) => html`
|
|
15
|
+
<${Box}>
|
|
16
|
+
<${Text} dimColor>${String(label).padEnd(10)} <//>
|
|
17
|
+
<${Text}>${children}<//>
|
|
18
|
+
<//>`;
|
|
19
|
+
|
|
20
|
+
export function Finding({ finding }) {
|
|
21
|
+
if (!finding) {
|
|
22
|
+
return html`<${Box} paddingX=${1}><${Text} dimColor>Nothing selected.<//><//>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const evidence = finding.evidence ?? finding.subjects ?? [];
|
|
26
|
+
|
|
27
|
+
return html`
|
|
28
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
29
|
+
<${Text} bold>${finding.headline ?? '(no headline)'}<//>
|
|
30
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
31
|
+
${html`<${Row} label="kind">${finding.kind ?? '—'}<//>`}
|
|
32
|
+
${finding.grouped ? html`<${Row} label="grouped">${finding.count} occurrences<//>` : null}
|
|
33
|
+
${finding.severity ? html`<${Row} label="severity">${finding.severity}<//>` : null}
|
|
34
|
+
${finding.window ? html`<${Row} label="window">${finding.window}<//>` : null}
|
|
35
|
+
<//>
|
|
36
|
+
|
|
37
|
+
${finding.detail
|
|
38
|
+
? html`<${Box} marginTop=${1}><${Text} dimColor>${finding.detail}<//><//>`
|
|
39
|
+
: null}
|
|
40
|
+
|
|
41
|
+
${Array.isArray(evidence) && evidence.length
|
|
42
|
+
? html`
|
|
43
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
44
|
+
<${Text} bold>Evidence<//>
|
|
45
|
+
${evidence.slice(0, 8).map((e, i) => html`
|
|
46
|
+
<${Text} key=${i} dimColor> ${typeof e === 'string' ? e : (e.label ?? e.title ?? JSON.stringify(e))}<//>`)}
|
|
47
|
+
${evidence.length > 8
|
|
48
|
+
? html`<${Text} dimColor> … ${evidence.length - 8} more — mnema briefing --json<//>`
|
|
49
|
+
: null}
|
|
50
|
+
<//>`
|
|
51
|
+
: html`<${Box} marginTop=${1}><${Text} dimColor>No evidence rows came back with this finding.<//><//>`}
|
|
52
|
+
<//>`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetching, for the TUI (t-632).
|
|
3
|
+
*
|
|
4
|
+
* Hand-rolled rather than a data library: the requirements are small, and a
|
|
5
|
+
* dependency here would land on the lazy side of the boundary but still be
|
|
6
|
+
* installed for every user of the package.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ THE ELAPSED CLOCK ONLY TICKS WHILE LOADING. An interval that runs whenever
|
|
9
|
+
* the component is mounted re-renders the whole tree ten times a second forever,
|
|
10
|
+
* which on a terminal means a visible flicker and a pegged CPU on an idle screen.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ AND IT DOES NOT POLL. A list that reorders itself under a moving cursor is
|
|
13
|
+
* hostile — you press enter on what was row three and open what is now row four.
|
|
14
|
+
* Load on mount, show how stale it is, refresh on `r`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
18
|
+
|
|
19
|
+
export function useResource(key, fetcher) {
|
|
20
|
+
const [state, setState] = useState({ status: 'loading', data: null, error: null, at: null });
|
|
21
|
+
const [elapsed, setElapsed] = useState(0);
|
|
22
|
+
const [nonce, setNonce] = useState(0);
|
|
23
|
+
const alive = useRef(true);
|
|
24
|
+
|
|
25
|
+
useEffect(() => () => { alive.current = false; }, []);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
setState((s) => ({ ...s, status: s.data ? 'refreshing' : 'loading', error: null }));
|
|
30
|
+
setElapsed(0);
|
|
31
|
+
Promise.resolve()
|
|
32
|
+
.then(fetcher)
|
|
33
|
+
.then((data) => {
|
|
34
|
+
if (cancelled || !alive.current) return;
|
|
35
|
+
setState({ status: 'ready', data, error: null, at: Date.now() });
|
|
36
|
+
})
|
|
37
|
+
.catch((error) => {
|
|
38
|
+
if (cancelled || !alive.current) return;
|
|
39
|
+
// ⚠️ KEEP THE OLD DATA. A failed refresh should not blank a screen the
|
|
40
|
+
// user is reading; it should say the refresh failed.
|
|
41
|
+
setState((s) => ({ status: 'error', data: s.data, error, at: s.at }));
|
|
42
|
+
});
|
|
43
|
+
return () => { cancelled = true; };
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
}, [key, nonce]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (state.status !== 'loading' && state.status !== 'refreshing') return undefined;
|
|
49
|
+
const t = setInterval(() => setElapsed((n) => n + 1), 1000);
|
|
50
|
+
return () => clearInterval(t);
|
|
51
|
+
}, [state.status]);
|
|
52
|
+
|
|
53
|
+
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
|
54
|
+
return { ...state, elapsed, reload };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** "3m ago" — so a screen that is not polling still says how old it is. */
|
|
58
|
+
export function ago(at) {
|
|
59
|
+
if (!at) return '';
|
|
60
|
+
const s = Math.floor((Date.now() - at) / 1000);
|
|
61
|
+
if (s < 5) return 'just now';
|
|
62
|
+
if (s < 60) return `${s}s ago`;
|
|
63
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
64
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
65
|
+
}
|