@mnemahq/cli 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Mnema CLI — connect a repo to your Mnema workspace: install session capture, sweep past sessions, and search from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -493,7 +493,9 @@ async function cmdUninstall(flags) {
493
493
  function help() {
494
494
  console.log(`mnema ${VERSION} — connect a repo to your Mnema workspace
495
495
 
496
- Usage: mnema <command> [options]
496
+ Usage: mnema [command] [options]
497
+
498
+ mnema Open the interactive briefing (a terminal, Node 20+)
497
499
 
498
500
  Commands:
499
501
  login Sign in (opens a browser; tokens go to your OS keychain)
@@ -512,13 +514,23 @@ Read your workspace:
512
514
  tasks List tasks [--status --project --limit]
513
515
  next The next task to pick up, with a ready-made branch name
514
516
  docs List documents [--limit]
515
- doc <id> Print one document as markdown
517
+ doc [id] Print one document as markdown; no id opens a picker
516
518
  projects List projects
517
519
  briefing What deserves attention — pulse, deltas, findings
518
520
 
519
521
  Ask the knowledge graph (paid feature):
520
522
  ask "q" A cited answer, with the confidence it deserves
521
- graph <a> [b] Neighbourhood of a, or the shortest path from a to b
523
+ graph Neighbourhood of a node, or the path between two
524
+
525
+ Examples:
526
+ mnema the interactive briefing
527
+ mnema tasks --status in_progress
528
+ mnema doc pick a document from a list
529
+ mnema ask "why did we drop the queue?"
530
+ mnema graph "Workspace Security & Management"
531
+
532
+ ⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before
533
+ mnema sees it, and zsh reports a parse error that looks like a broken CLI.
522
534
 
523
535
  Options:
524
536
  --workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
@@ -560,6 +572,7 @@ async function openTuiOrHelp(flags) {
560
572
  const ctx = resolveContext(flags);
561
573
  return startTui({
562
574
  ...ctx,
575
+ version: VERSION,
563
576
  call: (fn) => call(ctx, fn),
564
577
  });
565
578
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The Mnema mark, drawn in a terminal (t-636).
3
+ *
4
+ * ⭐ THE TRICK IS '▀'. One character cell carries TWO vertical pixels: the
5
+ * foreground colour paints the upper half, the background colour the lower half.
6
+ * That doubles vertical resolution, which matters enormously because a terminal
7
+ * cell is roughly twice as tall as it is wide — without it, any mark comes out
8
+ * squashed to half height and reads as a smudge.
9
+ *
10
+ * ⚠️ RASTERISED FROM THE REAL GEOMETRY, NOT EYEBALLED. The mark is
11
+ * apps/web/public/favicon.svg and it is three shapes on a 100×100 field:
12
+ *
13
+ * left stem rect x=24 y=13 w=11 h=74 (full height, has the descender)
14
+ * right stem rect x=65 y=13 w=11 h=47 (stops where the arch meets it)
15
+ * arch M24,60 C24,80 76,80 76,60 L65,60 C65,73 35,73 35,60 Z
16
+ *
17
+ * Hand-drawing an ASCII approximation would drift from the brand the first time
18
+ * either changed, and nothing would catch it. Evaluating the actual geometry means
19
+ * the terminal mark IS the mark — and it costs about forty lines, no dependency,
20
+ * and no SVG parser.
21
+ */
22
+
23
+ const BLACK = [0x00, 0x00, 0x00];
24
+ const CREAM = [0xed, 0xe8, 0xdc];
25
+
26
+ // ── the geometry, straight from favicon.svg ──────────────────────────────────
27
+
28
+ /** Cubic Bézier, flattened to points. */
29
+ function cubic(p0, p1, p2, p3, steps = 24) {
30
+ const out = [];
31
+ for (let i = 0; i <= steps; i += 1) {
32
+ const t = i / steps;
33
+ const u = 1 - t;
34
+ out.push([
35
+ u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
36
+ u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
37
+ ]);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * The arch as one closed polygon: the outer curve, across, then the inner curve
44
+ * back. It is a single subpath in the SVG and does not self-intersect, so
45
+ * even-odd and nonzero agree and a plain point-in-polygon test is exact.
46
+ */
47
+ const ARCH = [
48
+ ...cubic([24, 60], [24, 80], [76, 80], [76, 60]),
49
+ [65, 60],
50
+ ...cubic([65, 60], [65, 73], [35, 73], [35, 60]),
51
+ ];
52
+
53
+ function inPolygon(x, y, poly) {
54
+ let inside = false;
55
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
56
+ const [xi, yi] = poly[i];
57
+ const [xj, yj] = poly[j];
58
+ if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
59
+ }
60
+ return inside;
61
+ }
62
+
63
+ const inRect = (x, y, rx, ry, w, h) => x >= rx && x < rx + w && y >= ry && y < ry + h;
64
+
65
+ /** Is this point on the cream part of the mark? */
66
+ function inMark(x, y) {
67
+ return inRect(x, y, 24, 13, 11, 74) // left stem
68
+ || inRect(x, y, 65, 13, 11, 47) // right stem
69
+ || inPolygon(x, y, ARCH); // arch
70
+ }
71
+
72
+ // ── rasterising ──────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Coverage of one output pixel, 0..1, by supersampling.
76
+ *
77
+ * Antialiasing is not decoration here: at ~14 pixels across, a hard threshold
78
+ * turns the arch's curve into a staircase, and the two stems end up different
79
+ * widths depending on where the sample grid happens to land.
80
+ */
81
+ function coverage(px, py, cols, pxRows, viewW, viewH, samples = 4) {
82
+ // ⚠️ CROPPED TO THE GLYPH, NOT THE ARTBOARD. The SVG's 100×100 field carries
83
+ // generous margins (the mark itself only occupies x 24..76, y 13..87), so
84
+ // rasterising the whole artboard spends a third of a small tile on emptiness
85
+ // and leaves the stems under a pixel wide. `view` is a square window centred on
86
+ // the glyph, which is what lets it stay legible at header size.
87
+ let hit = 0;
88
+ for (let sy = 0; sy < samples; sy += 1) {
89
+ for (let sx = 0; sx < samples; sx += 1) {
90
+ const x = 50 - viewW / 2 + ((px + (sx + 0.5) / samples) / cols) * viewW;
91
+ const y = 50 - viewH / 2 + ((py + (sy + 0.5) / samples) / pxRows) * viewH;
92
+ if (inMark(x, y)) hit += 1;
93
+ }
94
+ }
95
+ return hit / (samples * samples);
96
+ }
97
+
98
+ // The glyph's own bounding box, plus a little air. Cropping to THIS rather than to
99
+ // a square is what makes the tile compact: the mark is 52 wide and 74 tall, so a
100
+ // square tile would spend a third of its width on nothing.
101
+ const GLYPH_W = 52 + 10;
102
+ const GLYPH_H = 74 + 10;
103
+
104
+ const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
105
+
106
+ /** Nearest xterm-256 index, for terminals without truecolour. */
107
+ function to256([r, g, b]) {
108
+ const q = (v) => (v < 48 ? 0 : v < 114 ? 1 : Math.min(5, Math.round((v - 35) / 40)));
109
+ return 16 + 36 * q(r) + 6 * q(g) + q(b);
110
+ }
111
+
112
+ /**
113
+ * How much colour this terminal can actually show.
114
+ *
115
+ * ⚠️ TRUECOLOUR IS NOT IMPLIED BY 256-COLOUR SUPPORT and there is no query for it
116
+ * that works everywhere — COLORTERM is the convention. Guessing wrong does not
117
+ * degrade gracefully: a 24-bit sequence on a terminal that cannot read it prints
118
+ * the escape as literal text across the screen.
119
+ */
120
+ export function colourDepth(env = process.env) {
121
+ const ct = String(env.COLORTERM ?? '').toLowerCase();
122
+ if (ct.includes('truecolor') || ct.includes('24bit')) return 24;
123
+ if (/-256(color)?$/.test(String(env.TERM ?? ''))) return 8;
124
+ return 4;
125
+ }
126
+
127
+ /**
128
+ * The mark, as an array of lines ready to print.
129
+ *
130
+ * @param size pixels across; the output is `size` columns and `size/2` rows.
131
+ */
132
+ export function markLines({ rows: wantRows = 5, depth = colourDepth(), tile = BLACK, ink = CREAM } = {}) {
133
+ // Below 24-bit there is no honest way to draw a two-tone antialiased mark, and a
134
+ // banded approximation looks like a rendering fault rather than a logo. The
135
+ // caller falls back to a wordmark.
136
+ if (depth < 8) return null;
137
+
138
+ // Half-blocks give SQUARE pixels — one cell is one column wide and two pixel
139
+ // rows tall — so the aspect ratio is preserved by matching the pixel grid to the
140
+ // glyph's box rather than by fudging either dimension.
141
+ const pxRows = wantRows * 2;
142
+ const cols = Math.max(3, Math.round(pxRows * (GLYPH_W / GLYPH_H)));
143
+
144
+ const rows = [];
145
+ for (let y = 0; y < pxRows; y += 2) {
146
+ let line = '';
147
+ for (let x = 0; x < cols; x += 1) {
148
+ const top = mix(tile, ink, coverage(x, y, cols, pxRows, GLYPH_W, GLYPH_H));
149
+ const bot = mix(tile, ink, coverage(x, y + 1, cols, pxRows, GLYPH_W, GLYPH_H));
150
+ line += depth >= 24
151
+ ? `\x1b[38;2;${top.join(';')}m\x1b[48;2;${bot.join(';')}m▀`
152
+ : `\x1b[38;5;${to256(top)}m\x1b[48;5;${to256(bot)}m▀`;
153
+ }
154
+ rows.push(`${line}\x1b[0m`);
155
+ }
156
+ return rows;
157
+ }
158
+
159
+ /** Plain-text identity for terminals that cannot draw, and for pipes. */
160
+ export const WORDMARK = 'mnema';
package/src/tui/app.mjs CHANGED
@@ -18,6 +18,8 @@ import { Box, Text, useApp, useInput } from 'ink';
18
18
  import { html } from './h.mjs';
19
19
  import { Briefing } from './screens/briefing.mjs';
20
20
  import { Finding } from './screens/finding.mjs';
21
+ import { NodeScreen } from './screens/node.mjs';
22
+ import { Header } from './components/header.mjs';
21
23
 
22
24
  const HOME = { name: 'briefing' };
23
25
 
@@ -65,25 +67,61 @@ export function App({ ctx }) {
65
67
  <//>`;
66
68
  }
67
69
 
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} />`;
70
+ // The trail is the stack's node screens, so the breadcrumb is derived rather
71
+ // than tracked separately one source of truth for "where am I".
72
+ const trail = stack.filter((s) => s.name === 'node').map((s) => s.target.label ?? s.target.id);
73
+
74
+ let body;
75
+ if (top.name === 'briefing') {
76
+ body = html`<${Briefing} ctx=${wrapped} focused=${true} onOpen=${(f) => push({ name: 'finding', finding: f })} />`;
77
+ } else if (top.name === 'node') {
78
+ body = html`<${NodeScreen}
79
+ ctx=${wrapped}
80
+ target=${top.target}
81
+ trail=${trail}
82
+ focused=${true}
83
+ onOpen=${(n) => push({ name: 'node', target: n })}
84
+ />`;
85
+ } else {
86
+ // ⭐ A FINDING IS NO LONGER A DEAD END. Most carry subjectNodeId, so `enter`
87
+ // walks from "276 merged PRs have no task" into the actual node it is about —
88
+ // which is the difference between a report and something you can investigate.
89
+ body = html`<${Finding}
90
+ finding=${top.finding}
91
+ focused=${true}
92
+ onOpenSubject=${(t) => push({ name: 'node', target: t })}
93
+ />`;
94
+ }
71
95
 
72
96
  return html`
73
97
  <${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
- <//>
98
+ <${Header}
99
+ version=${ctx.version ?? ''}
100
+ workspace=${ctx.workspaceName ?? ctx.workspaceId ?? ''}
101
+ plan=${ctx.plan}
102
+ edition=${ctx.edition}
103
+ cwd=${ctx.root}
104
+ />
80
105
  ${body}
81
106
  <${Box} paddingX=${1} marginTop=${1}>
82
- <${Text} dimColor>${top.name === 'briefing'
83
- ? '↑↓ move · enter open · r refresh · q quit'
84
- : 'esc back · q quit'}<//>
107
+ <${Text} dimColor>${footerFor(top)}<//>
85
108
  <//>
86
109
  <//>`;
87
110
  }
88
111
 
112
+ /** The keys that actually do something on THIS screen — never a generic legend. */
113
+ function footerFor(top) {
114
+ if (top.name === 'briefing') return '↑↓ move · enter open · r refresh · q quit';
115
+ if (top.name === 'node') return '↑↓ move · enter walk in · esc back · r refresh · q quit';
116
+ if (top.name === 'finding') {
117
+ // Only offer the key that works. A grouped finding has members to open; a
118
+ // single one is an aggregate with nothing beneath it, and advertising `enter`
119
+ // there teaches people the UI is unresponsive.
120
+ return top.finding?.members?.length
121
+ ? '↑↓ move · enter open in graph · esc back · q quit'
122
+ : 'esc back · q quit';
123
+ }
124
+ return 'esc back · q quit';
125
+ }
126
+
89
127
  export { HOME };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The identity block (t-636).
3
+ *
4
+ * ⭐ WHAT MAKES A TERMINAL APP LOOK LIKE A PRODUCT rather than a shell prompt is
5
+ * this exact thing: a mark, then who you are and where you are, before anything
6
+ * else. The old header was one line of text and read as output; this reads as an
7
+ * application you have opened.
8
+ *
9
+ * ░█░···░█░ Mnema 0.8.0
10
+ * ░█░···░█░ Mnema · team · a67e6584
11
+ * ░█▓░░░▓▓ ~/Projects/project-x
12
+ * ░█▓▓▓▓░
13
+ * ░█░
14
+ *
15
+ * ⚠️ THE MARK IS NOT ALWAYS DRAWABLE, and the failure is ugly rather than subtle:
16
+ * a 24-bit escape on a terminal that cannot read it prints as literal text across
17
+ * the screen. markLines() returns null below 256 colours and the header falls back
18
+ * to the wordmark, which is a real state and not a theoretical one — TERM=xterm
19
+ * over a plain ssh hits it.
20
+ */
21
+
22
+ import { Box, Text } from 'ink';
23
+ import { html } from '../h.mjs';
24
+ import { markLines, WORDMARK } from '../../render/mark-art.mjs';
25
+
26
+ /** Shorten a home-relative path the way a shell prompt would. */
27
+ function shortPath(cwd, home = process.env.HOME) {
28
+ if (!cwd) return '';
29
+ return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
30
+ }
31
+
32
+ export function Header({ version, workspace, plan, edition, cwd, rows = 6 }) {
33
+ const art = markLines({ rows });
34
+
35
+ const facts = [workspace, plan, edition === 'core' ? 'core' : null].filter(Boolean).join(' · ');
36
+
37
+ const info = html`
38
+ <${Box} flexDirection="column">
39
+ <${Box}>
40
+ <${Text} bold>Mnema<//>
41
+ <${Text} dimColor> ${version}<//>
42
+ <//>
43
+ ${facts ? html`<${Text} dimColor>${facts}<//>` : null}
44
+ ${cwd ? html`<${Text} dimColor>${shortPath(cwd)}<//>` : null}
45
+ <//>`;
46
+
47
+ if (!art) {
48
+ // No mark: keep the same shape, just without the tile. Dropping the block
49
+ // entirely would make the app look different on different machines for a
50
+ // reason the user cannot see.
51
+ return html`
52
+ <${Box} paddingX=${1} paddingY=${0}>
53
+ <${Text} bold color="cyan">${WORDMARK}<//>
54
+ <${Text}> <//>
55
+ ${info}
56
+ <//>`;
57
+ }
58
+
59
+ return html`
60
+ <${Box} paddingX=${1}>
61
+ <${Box} flexDirection="column" marginRight=${2}>
62
+ ${art.map((line, i) => html`<${Text} key=${i}>${line}<//>`)}
63
+ <//>
64
+ ${info}
65
+ <//>`;
66
+ }
@@ -15,32 +15,66 @@ import { Box, Text, useInput } from 'ink';
15
15
  import { html } from '../h.mjs';
16
16
 
17
17
  export function List({ items, focused = true, height = 10, onSelect }) {
18
- const [cursor, setCursor] = useState(0);
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
+ });
19
27
  const [top, setTop] = useState(0);
20
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
+
21
35
  const move = (delta) => {
22
- const next = Math.min(items.length - 1, Math.max(0, cursor + 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
+ }
23
47
  setCursor(next);
24
48
  if (next < top) setTop(next);
25
49
  if (next >= top + height) setTop(next - height + 1);
26
50
  };
27
51
 
52
+ const firstSelectable = items.findIndex((it) => !it.header);
53
+
28
54
  useInput((input, key) => {
29
55
  if (key.upArrow || input === 'k') move(-1);
30
56
  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 });
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) });
35
61
 
36
62
  const view = items.slice(top, top + height);
37
- const leftWidth = Math.max(0, ...items.map((i) => (i.left ?? '').length));
63
+ const leftWidth = Math.max(0, ...items.filter((i) => !i.header).map((i) => (i.left ?? '').length));
38
64
 
39
65
  return html`
40
66
  <${Box} flexDirection="column">
41
67
  ${view.map((item, i) => {
42
68
  const idx = top + i;
43
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
+ }
44
78
  return html`
45
79
  <${Box} key=${item.key ?? idx}>
46
80
  <${Text} color=${on ? 'cyan' : undefined}>${on ? '❯ ' : ' '}<//>
@@ -1,53 +1,89 @@
1
1
  /**
2
- * One finding, in full (t-632).
2
+ * One finding, and the way into the graph beneath it (t-632, corrected t-634).
3
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.
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.
9
29
  */
10
30
 
11
31
  import { Box, Text } from 'ink';
12
32
  import { html } from '../h.mjs';
33
+ import { List } from '../components/list.mjs';
13
34
 
14
35
  const Row = ({ label, children }) => html`
15
36
  <${Box}>
16
- <${Text} dimColor>${String(label).padEnd(10)} <//>
37
+ <${Text} dimColor>${String(label).padEnd(9)} <//>
17
38
  <${Text}>${children}<//>
18
39
  <//>`;
19
40
 
20
- export function Finding({ finding }) {
41
+ export function Finding({ finding, focused, onOpenSubject }) {
21
42
  if (!finding) {
22
43
  return html`<${Box} paddingX=${1}><${Text} dimColor>Nothing selected.<//><//>`;
23
44
  }
24
45
 
25
- const evidence = finding.evidence ?? finding.subjects ?? [];
46
+ const members = Array.isArray(finding.members) ? finding.members : [];
47
+ const pct = typeof finding.score === 'number' ? `${Math.round(finding.score * 100)}%` : null;
26
48
 
27
49
  return html`
28
50
  <${Box} flexDirection="column" paddingX=${1}>
29
51
  <${Text} bold>${finding.headline ?? '(no headline)'}<//>
52
+
30
53
  <${Box} flexDirection="column" marginTop=${1}>
31
54
  ${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}
55
+ ${finding.rule ? html`<${Row} label="rule">${finding.rule}<//>` : null}
56
+ ${pct ? html`<${Row} label="score">${pct}<//>` : null}
35
57
  <//>
36
58
 
37
- ${finding.detail
38
- ? html`<${Box} marginTop=${1}><${Text} dimColor>${finding.detail}<//><//>`
39
- : null}
40
-
41
- ${Array.isArray(evidence) && evidence.length
59
+ ${members.length
42
60
  ? html`
43
61
  <${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}
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
+ />
50
81
  <//>`
51
- : html`<${Box} marginTop=${1}><${Text} dimColor>No evidence rows came back with this finding.<//><//>`}
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
+ <//>`}
52
88
  <//>`;
53
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
+ }