@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.
@@ -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
+ }
@@ -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
+ }