@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 +1 -1
- package/src/cli.mjs +16 -3
- package/src/render/mark-art.mjs +160 -0
- package/src/tui/app.mjs +50 -12
- package/src/tui/components/header.mjs +66 -0
- package/src/tui/components/list.mjs +41 -7
- package/src/tui/screens/finding.mjs +60 -24
- package/src/tui/screens/node.mjs +106 -0
package/package.json
CHANGED
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
|
|
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
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
<${
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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') {
|
|
33
|
-
else if (key.return && onSelect) onSelect(cursor);
|
|
34
|
-
}, { isActive: Boolean(focused) && items.
|
|
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,
|
|
2
|
+
* One finding, and the way into the graph beneath it (t-632, corrected t-634).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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(
|
|
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
|
|
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.
|
|
33
|
-
${
|
|
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
|
-
${
|
|
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
|
-
<${
|
|
45
|
-
|
|
46
|
-
<${Text}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
+
}
|