@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,91 @@
|
|
|
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
|
+
// ⭐ START ON A REAL ROW, NOT A HEADING. In the graph walker the very first row
|
|
19
|
+
// is always a type header ("rationale 97"), so a cursor initialised to 0 sits
|
|
20
|
+
// on something that cannot be opened — the user's first `enter` does nothing at
|
|
21
|
+
// all, which reads as a broken UI rather than a misplaced cursor. Caught by the
|
|
22
|
+
// test, not by looking at it.
|
|
23
|
+
const [cursor, setCursor] = useState(() => {
|
|
24
|
+
const i = items.findIndex((it) => !it.header);
|
|
25
|
+
return i === -1 ? 0 : i;
|
|
26
|
+
});
|
|
27
|
+
const [top, setTop] = useState(0);
|
|
28
|
+
|
|
29
|
+
// ⚠️ HEADERS ARE ROWS BUT NOT DESTINATIONS. The graph walker groups 201
|
|
30
|
+
// neighbours under type headings so the structure is visible, and a cursor that
|
|
31
|
+
// can land on "concept 46" would let you press enter on a heading — which has
|
|
32
|
+
// nothing to open. Movement skips them, in the direction you were already going.
|
|
33
|
+
const selectable = (i) => items[i] && !items[i].header;
|
|
34
|
+
|
|
35
|
+
const move = (delta) => {
|
|
36
|
+
const step = delta > 0 ? 1 : -1;
|
|
37
|
+
let next = cursor + delta;
|
|
38
|
+
while (next >= 0 && next < items.length && !selectable(next)) next += step;
|
|
39
|
+
// Ran off the end past a trailing header — fall back to the nearest selectable
|
|
40
|
+
// row rather than parking the cursor on a heading or losing it entirely.
|
|
41
|
+
if (next < 0 || next >= items.length) {
|
|
42
|
+
next = cursor;
|
|
43
|
+
let probe = delta > 0 ? items.length - 1 : 0;
|
|
44
|
+
while (probe >= 0 && probe < items.length && !selectable(probe)) probe += (delta > 0 ? -1 : 1);
|
|
45
|
+
if (selectable(probe)) next = probe;
|
|
46
|
+
}
|
|
47
|
+
setCursor(next);
|
|
48
|
+
if (next < top) setTop(next);
|
|
49
|
+
if (next >= top + height) setTop(next - height + 1);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const firstSelectable = items.findIndex((it) => !it.header);
|
|
53
|
+
|
|
54
|
+
useInput((input, key) => {
|
|
55
|
+
if (key.upArrow || input === 'k') move(-1);
|
|
56
|
+
else if (key.downArrow || input === 'j') move(1);
|
|
57
|
+
else if (input === 'g') { setCursor(Math.max(0, firstSelectable)); setTop(0); }
|
|
58
|
+
else if (input === 'G') { move(items.length); }
|
|
59
|
+
else if (key.return && onSelect && selectable(cursor)) onSelect(cursor);
|
|
60
|
+
}, { isActive: Boolean(focused) && items.some((i) => !i.header) });
|
|
61
|
+
|
|
62
|
+
const view = items.slice(top, top + height);
|
|
63
|
+
const leftWidth = Math.max(0, ...items.filter((i) => !i.header).map((i) => (i.left ?? '').length));
|
|
64
|
+
|
|
65
|
+
return html`
|
|
66
|
+
<${Box} flexDirection="column">
|
|
67
|
+
${view.map((item, i) => {
|
|
68
|
+
const idx = top + i;
|
|
69
|
+
const on = idx === cursor && focused;
|
|
70
|
+
if (item.header) {
|
|
71
|
+
return html`
|
|
72
|
+
<${Box} key=${item.key ?? idx} marginTop=${idx === top ? 0 : 1}>
|
|
73
|
+
<${Text}> <//>
|
|
74
|
+
<${Text} bold color="yellow">${item.label}<//>
|
|
75
|
+
${item.count !== undefined ? html`<${Text} dimColor> ${item.count}<//>` : null}
|
|
76
|
+
<//>`;
|
|
77
|
+
}
|
|
78
|
+
return html`
|
|
79
|
+
<${Box} key=${item.key ?? idx}>
|
|
80
|
+
<${Text} color=${on ? 'cyan' : undefined}>${on ? '❯ ' : ' '}<//>
|
|
81
|
+
${item.left !== undefined
|
|
82
|
+
? html`<${Text} dimColor>${item.left.padEnd(leftWidth)} <//>`
|
|
83
|
+
: null}
|
|
84
|
+
<${Text} bold=${on} dimColor=${!on}>${item.label}<//>
|
|
85
|
+
<//>`;
|
|
86
|
+
})}
|
|
87
|
+
${items.length > height
|
|
88
|
+
? html`<${Text} dimColor> ${cursor + 1}/${items.length}<//>`
|
|
89
|
+
: null}
|
|
90
|
+
<//>`;
|
|
91
|
+
}
|
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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One finding, and the way into the graph beneath it (t-632, corrected t-634).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ I BUILT THE FIRST VERSION AGAINST THE SDK'S `Finding` INTERFACE AND IT WAS
|
|
5
|
+
* THE WRONG SHAPE. That interface describes a MEMBER of a grouped finding; a
|
|
6
|
+
* briefing row is something else entirely. Checked against prod, a briefing row
|
|
7
|
+
* carries:
|
|
8
|
+
*
|
|
9
|
+
* id, grouped, kind, rule, headline, count, score, members, subjectKeys
|
|
10
|
+
*
|
|
11
|
+
* and NOT `subject`, `subjectNodeId`, `evidence`, `detail`, `severity` or
|
|
12
|
+
* `window` — every one of which the first version rendered. So every finding
|
|
13
|
+
* showed "No evidence rows came back with this finding", which reads as the engine
|
|
14
|
+
* returning nothing when in fact the screen was reading fields that were never
|
|
15
|
+
* there. A feature running clean and producing nothing, again, and it survived a
|
|
16
|
+
* PR because the tests used the shape I had invented rather than the shape prod
|
|
17
|
+
* sends.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ TWO GENUINELY DIFFERENT SHAPES, and conflating them is what caused it:
|
|
20
|
+
*
|
|
21
|
+
* grouped has `members[]`, and each member IS a full finding WITH a
|
|
22
|
+
* subjectNodeId — so this is the real drill-down: finding → the
|
|
23
|
+
* specific pair of contradicting decisions → that node in the graph.
|
|
24
|
+
* single is an aggregate over thousands of rows ("1,238 decisions have no
|
|
25
|
+
* task"). There is no one node it is about, and inventing a subject
|
|
26
|
+
* so the UI looks uniform would be worse than saying so.
|
|
27
|
+
*
|
|
28
|
+
* Live when this was written: 2 grouped (4 and 5 members), 7 single.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Box, Text } from 'ink';
|
|
32
|
+
import { html } from '../h.mjs';
|
|
33
|
+
import { List } from '../components/list.mjs';
|
|
34
|
+
|
|
35
|
+
const Row = ({ label, children }) => html`
|
|
36
|
+
<${Box}>
|
|
37
|
+
<${Text} dimColor>${String(label).padEnd(9)} <//>
|
|
38
|
+
<${Text}>${children}<//>
|
|
39
|
+
<//>`;
|
|
40
|
+
|
|
41
|
+
export function Finding({ finding, focused, onOpenSubject }) {
|
|
42
|
+
if (!finding) {
|
|
43
|
+
return html`<${Box} paddingX=${1}><${Text} dimColor>Nothing selected.<//><//>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const members = Array.isArray(finding.members) ? finding.members : [];
|
|
47
|
+
const pct = typeof finding.score === 'number' ? `${Math.round(finding.score * 100)}%` : null;
|
|
48
|
+
|
|
49
|
+
return html`
|
|
50
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
51
|
+
<${Text} bold>${finding.headline ?? '(no headline)'}<//>
|
|
52
|
+
|
|
53
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
54
|
+
${html`<${Row} label="kind">${finding.kind ?? '—'}<//>`}
|
|
55
|
+
${finding.rule ? html`<${Row} label="rule">${finding.rule}<//>` : null}
|
|
56
|
+
${pct ? html`<${Row} label="score">${pct}<//>` : null}
|
|
57
|
+
<//>
|
|
58
|
+
|
|
59
|
+
${members.length
|
|
60
|
+
? html`
|
|
61
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
62
|
+
<${Box}>
|
|
63
|
+
<${Text} bold>Occurrences<//>
|
|
64
|
+
<${Text} dimColor> ${members.length} · enter opens one in the graph<//>
|
|
65
|
+
<//>
|
|
66
|
+
<${List}
|
|
67
|
+
items=${members.map((m) => ({
|
|
68
|
+
key: m.id,
|
|
69
|
+
left: m.subject ?? '',
|
|
70
|
+
label: m.headline ?? '',
|
|
71
|
+
}))}
|
|
72
|
+
focused=${focused}
|
|
73
|
+
height=${8}
|
|
74
|
+
onSelect=${(i) => {
|
|
75
|
+
const m = members[i];
|
|
76
|
+
if (m?.subjectNodeId && onOpenSubject) {
|
|
77
|
+
onOpenSubject({ id: m.subjectNodeId, label: m.subject ?? undefined });
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
<//>`
|
|
82
|
+
: html`
|
|
83
|
+
<${Box} marginTop=${1} flexDirection="column">
|
|
84
|
+
<${Text} dimColor>An aggregate across the whole workspace, not one node —<//>
|
|
85
|
+
<${Text} dimColor>so there is nothing single to open from here.<//>
|
|
86
|
+
<${Text} dimColor>\`mnema briefing --json\` has the full row.<//>
|
|
87
|
+
<//>`}
|
|
88
|
+
<//>`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One graph node, and everything connected to it (t-634).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THIS IS THE SCREEN THE TUI WAS MISSING. The briefing ended at a finding and
|
|
5
|
+
* stopped, so the most interesting surface Mnema has — the graph — could only be
|
|
6
|
+
* reached by typing a node label into a one-shot command and reading a flat list.
|
|
7
|
+
* A community connects to concepts, concepts to rationales; the point is to walk
|
|
8
|
+
* that, not to print it.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ GROUPED BY TYPE, IN ONE LIST, and that shape is a decision rather than a
|
|
11
|
+
* default. A real hub returns 201 neighbours across 8 types — concept 46,
|
|
12
|
+
* rationale 97, gap 37, decision 6, issue 10, and so on. A flat list of 201 is
|
|
13
|
+
* unusable; a type picker that then opens a second list makes every node two
|
|
14
|
+
* keystrokes further away and hides the interesting fact that there are 97
|
|
15
|
+
* rationales here. Headers in a single scrolling list show the structure AND keep
|
|
16
|
+
* every node one keystroke away.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ AND WE WALK BY ID, NOT BY LABEL. traverse() accepts both — verified against
|
|
19
|
+
* prod — but re-resolving a label could land on a different node that happens to
|
|
20
|
+
* share a name, which in a knowledge graph is not a hypothetical.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Box, Text, useInput } from 'ink';
|
|
24
|
+
import { html } from '../h.mjs';
|
|
25
|
+
import { useResource, ago } from '../store.mjs';
|
|
26
|
+
import { List } from '../components/list.mjs';
|
|
27
|
+
|
|
28
|
+
/** Most numerous first: the shape of a neighbourhood is itself information. */
|
|
29
|
+
function group(nodes, centreId) {
|
|
30
|
+
const byType = new Map();
|
|
31
|
+
for (const n of nodes) {
|
|
32
|
+
if (n.id === centreId) continue;
|
|
33
|
+
if (!byType.has(n.type)) byType.set(n.type, []);
|
|
34
|
+
byType.get(n.type).push(n);
|
|
35
|
+
}
|
|
36
|
+
const rows = [];
|
|
37
|
+
for (const [type, list] of [...byType.entries()].sort((a, b) => b[1].length - a[1].length)) {
|
|
38
|
+
rows.push({ header: true, key: `h:${type}`, label: type, count: list.length });
|
|
39
|
+
for (const n of list) rows.push({ key: n.id, label: n.label ?? '(unlabelled)', node: n });
|
|
40
|
+
}
|
|
41
|
+
return rows;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function NodeScreen({ ctx, target, focused, onOpen, trail = [] }) {
|
|
45
|
+
const key = target.id ?? target.label;
|
|
46
|
+
const { status, data, error, elapsed, at, reload } = useResource(`node:${key}`, () =>
|
|
47
|
+
ctx.call((m) => m.graph.traverse(key)));
|
|
48
|
+
|
|
49
|
+
useInput((input) => { if (input === 'r') reload(); }, { isActive: Boolean(focused) });
|
|
50
|
+
|
|
51
|
+
if (status === 'loading') {
|
|
52
|
+
return html`<${Box} flexDirection="column" paddingX=${1}>
|
|
53
|
+
${trail.length ? html`<${Text} dimColor>${trail.join(' › ')}<//>` : null}
|
|
54
|
+
<${Text} dimColor>Walking to ${target.label ?? key}… ${elapsed > 1 ? `${elapsed}s` : ''}<//>
|
|
55
|
+
<//>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (error && !data) {
|
|
59
|
+
return html`<${Box} flexDirection="column" paddingX=${1}>
|
|
60
|
+
<${Text} color="red">${error.message ?? String(error)}<//>
|
|
61
|
+
<${Text} dimColor>r to retry · esc to go back<//>
|
|
62
|
+
<//>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const g = data ?? { nodes: [], edges: [] };
|
|
66
|
+
// The centre is the node we asked for. Match on id when we have one, and fall
|
|
67
|
+
// back to the label only when we arrived here by name from the command line.
|
|
68
|
+
const centre = g.nodes.find((n) => n.id === target.id)
|
|
69
|
+
?? g.nodes.find((n) => n.label === target.label)
|
|
70
|
+
?? { label: target.label ?? key, type: '?' };
|
|
71
|
+
const rows = group(g.nodes, centre.id);
|
|
72
|
+
const neighbours = rows.filter((r) => !r.header).length;
|
|
73
|
+
|
|
74
|
+
return html`
|
|
75
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
76
|
+
${trail.length > 1
|
|
77
|
+
? html`<${Text} dimColor>${trail.slice(0, -1).join(' › ')} ›<//>`
|
|
78
|
+
: null}
|
|
79
|
+
|
|
80
|
+
<${Box}>
|
|
81
|
+
<${Text} bold>${centre.label}<//>
|
|
82
|
+
<${Text} dimColor> ${centre.type}<//>
|
|
83
|
+
${centre.isGodNode ? html`<${Text} color="yellow"> ★ load-bearing<//>` : null}
|
|
84
|
+
<//>
|
|
85
|
+
|
|
86
|
+
${centre.summary
|
|
87
|
+
? html`<${Box} marginTop=${1}><${Text} dimColor>${centre.summary}<//><//>`
|
|
88
|
+
: null}
|
|
89
|
+
|
|
90
|
+
<${Box} marginTop=${1}>
|
|
91
|
+
<${Text} bold>Connected<//>
|
|
92
|
+
<${Text} dimColor> ${neighbours} node${neighbours === 1 ? '' : 's'} · ${g.edges.length} edge${g.edges.length === 1 ? '' : 's'} · ${ago(at)}<//>
|
|
93
|
+
<//>
|
|
94
|
+
|
|
95
|
+
${neighbours === 0
|
|
96
|
+
? html`<${Text} dimColor> Nothing is connected to this node yet.<//>`
|
|
97
|
+
: html`<${List}
|
|
98
|
+
items=${rows}
|
|
99
|
+
focused=${focused}
|
|
100
|
+
height=${12}
|
|
101
|
+
onSelect=${(i) => onOpen(rows[i].node)}
|
|
102
|
+
/>`}
|
|
103
|
+
|
|
104
|
+
${error ? html`<${Box} marginTop=${1}><${Text} color="yellow">refresh failed — ${error.message ?? String(error)}<//><//>` : null}
|
|
105
|
+
<//>`;
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|
package/src/tui-gate.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether to open the interactive UI, and why not when not (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THIS FILE MUST NEVER IMPORT INK. It is loaded on every single invocation of
|
|
5
|
+
* `mnema`, including `mnema --version` from a shell prompt and the capture hook.
|
|
6
|
+
* `import('ink')` costs ~140ms measured, against a ~60ms `mnema --version`, so a
|
|
7
|
+
* static import here would make every one-shot several times slower for a feature
|
|
8
|
+
* almost no invocation uses. The decision has to be cheap; only the answer "yes"
|
|
9
|
+
* is allowed to be expensive.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ AND `node --check` WILL NOT CATCH A MISTAKE HERE. It parses without resolving,
|
|
12
|
+
* so `import { Box } from 'ink'` at the top of this file is syntactically fine and
|
|
13
|
+
* nothing goes red until a user's `mnema status` crashes or crawls. That is what
|
|
14
|
+
* test/no-ink-in-oneshot.test.mjs is for.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** ink 6 requires Node 20 — the same floor @mnemahq/sdk already declares. */
|
|
18
|
+
export const MIN_NODE = 20;
|
|
19
|
+
|
|
20
|
+
const nodeMajor = () => Number(process.versions.node.split('.')[0]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @returns {{ok: boolean, reason?: string, detail?: string}}
|
|
24
|
+
*/
|
|
25
|
+
export function tuiEligibility(flags = {}, env = process.env, out = process.stdout, inp = process.stdin) {
|
|
26
|
+
// An explicit no wins over everything, including MNEMA_TUI=always.
|
|
27
|
+
if (flags['no-tui'] || env.MNEMA_TUI === 'never' || env.MNEMA_NO_TUI) {
|
|
28
|
+
return { ok: false, reason: 'opted-out' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ⚠️ THE NODE CHECK IS NOT OVERRIDABLE. MNEMA_TUI=always exists so tests can
|
|
32
|
+
// exercise the positive path without a pty; it must not be able to force a
|
|
33
|
+
// runtime that cannot load the library into trying.
|
|
34
|
+
if (nodeMajor() < MIN_NODE) {
|
|
35
|
+
return { ok: false, reason: 'node', detail: `v${process.versions.node}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ⚠️ RAW MODE IS A HARD REQUIREMENT OF INK, so this is not overridable either.
|
|
39
|
+
// Found by running `MNEMA_TUI=always mnema` through a pipe: Ink starts, cannot
|
|
40
|
+
// put stdin in raw mode, and renders its own internal fallback — which emits a
|
|
41
|
+
// React "two children with the same key" warning straight into the user's output
|
|
42
|
+
// and leaves a half-drawn screen that responds to nothing. I spent a while
|
|
43
|
+
// hunting that key in MY components before proving it came from Ink's internals:
|
|
44
|
+
// with a raw-mode-capable stdin the same tree renders clean.
|
|
45
|
+
//
|
|
46
|
+
// A UI that cannot receive a keypress is not a UI, so refuse rather than start.
|
|
47
|
+
if (typeof inp.setRawMode !== 'function') {
|
|
48
|
+
return { ok: false, reason: 'no-raw-mode' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (env.MNEMA_TUI === 'always') return { ok: true };
|
|
52
|
+
|
|
53
|
+
if (!out.isTTY || !inp.isTTY) return { ok: false, reason: 'not-a-tty' };
|
|
54
|
+
if (env.TERM === 'dumb') return { ok: false, reason: 'dumb-terminal' };
|
|
55
|
+
// Ink degrades to line-by-line under CI, but we would rather not start at all.
|
|
56
|
+
if (env.CI) return { ok: false, reason: 'ci' };
|
|
57
|
+
|
|
58
|
+
return { ok: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What to tell the user, and on which stream.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ `not-a-tty` SAYS NOTHING AT ALL. Piping `mnema` to read its help is a
|
|
65
|
+
* perfectly good thing to do, and a nag in that case is noise in someone's data.
|
|
66
|
+
* The node case does explain itself, because "I ran mnema and got help" deserves
|
|
67
|
+
* a reason — but on stderr, so `mnema | cat` still gets clean help on stdout.
|
|
68
|
+
*/
|
|
69
|
+
export function explainUnavailable(reason, detail) {
|
|
70
|
+
if (reason === 'node') {
|
|
71
|
+
return [
|
|
72
|
+
`The interactive UI needs Node ${MIN_NODE} or newer — you are on ${detail}.`,
|
|
73
|
+
'Everything below works on your version. For the UI: nvm install 22',
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
if (reason === 'dumb-terminal') return ['This terminal reports TERM=dumb, so the interactive UI is off.'];
|
|
77
|
+
if (reason === 'no-raw-mode') return ['This terminal cannot enter raw mode, so the interactive UI cannot read keys.'];
|
|
78
|
+
return [];
|
|
79
|
+
}
|