@mnemahq/cli 0.9.0 → 0.11.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 +2 -2
- package/src/cli.mjs +62 -5
- package/src/tui/app.mjs +49 -9
- package/src/tui/launch.mjs +8 -2
- package/src/tui/screens/docs.mjs +101 -0
- package/src/tui/screens/graph-home.mjs +61 -0
- package/src/tui/screens/tasks.mjs +73 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemahq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"htm": "^3.1.1",
|
|
35
35
|
"ink": "^6.8.0",
|
|
36
36
|
"react": "^19.2.7",
|
|
37
|
-
"@mnemahq/sdk": "0.
|
|
37
|
+
"@mnemahq/sdk": "0.4.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node -e \"process.exit(0)\"",
|
package/src/cli.mjs
CHANGED
|
@@ -510,6 +510,9 @@ Commands:
|
|
|
510
510
|
uninstall Remove hooks and stored secrets
|
|
511
511
|
tui Open the interactive briefing explicitly (Node 20+, a terminal)
|
|
512
512
|
|
|
513
|
+
On a terminal, the read commands below open a NAVIGABLE view — arrow keys to move,
|
|
514
|
+
enter to open, esc to go back. Piped or with --json they print as they always have.
|
|
515
|
+
|
|
513
516
|
Read your workspace:
|
|
514
517
|
tasks List tasks [--status --project --limit]
|
|
515
518
|
next The next task to pick up, with a ready-made branch name
|
|
@@ -520,7 +523,7 @@ Read your workspace:
|
|
|
520
523
|
|
|
521
524
|
Ask the knowledge graph (paid feature):
|
|
522
525
|
ask "q" A cited answer, with the confidence it deserves
|
|
523
|
-
graph
|
|
526
|
+
graph Walk a node's neighbours; graph <a> <b> prints the path between two
|
|
524
527
|
|
|
525
528
|
Examples:
|
|
526
529
|
mnema the interactive briefing
|
|
@@ -565,6 +568,49 @@ writes plain text.
|
|
|
565
568
|
* because `node --check` parses without resolving and would never notice a stray
|
|
566
569
|
* `import { Box } from 'ink'` in read-commands.mjs.
|
|
567
570
|
*/
|
|
571
|
+
/**
|
|
572
|
+
* ⭐ THE COMMAND YOU TYPED SHOULD DO THE THING YOU NAMED.
|
|
573
|
+
*
|
|
574
|
+
* `mnema graph "Workspace Security & Management"` printed 25 rows and "… 176
|
|
575
|
+
* more" and stopped. The navigable walker existed, but it lived behind bare
|
|
576
|
+
* `mnema` and a `g` keybinding — so the obvious command gave a static dump and
|
|
577
|
+
* the answer was "run a different command and press a key". Nobody does that.
|
|
578
|
+
* They type the thing they want.
|
|
579
|
+
*
|
|
580
|
+
* ⚠️ THE ONE-SHOT PATH IS UNCHANGED. --json, a pipe, no TTY, --no-tui, CI: all
|
|
581
|
+
* still print exactly what they printed before, byte for byte. Scripts are the
|
|
582
|
+
* contract. This only changes what a HUMAN AT A TERMINAL gets.
|
|
583
|
+
*
|
|
584
|
+
* @returns true if the interactive view took over.
|
|
585
|
+
*/
|
|
586
|
+
/**
|
|
587
|
+
* Which screen a read command should open on, or null to stay one-shot.
|
|
588
|
+
*
|
|
589
|
+
* Exported so the ROUTING is testable without spawning ink — the decision is the
|
|
590
|
+
* part that was wrong, and a test that re-implements it in the test file proves
|
|
591
|
+
* nothing about this function.
|
|
592
|
+
*/
|
|
593
|
+
export function screenFor(cmd, flags, rest = []) {
|
|
594
|
+
if (flags.json) return null;
|
|
595
|
+
if (cmd === 'graph') {
|
|
596
|
+
// `graph <a> <b>` is a path query — one answer, not a place to walk.
|
|
597
|
+
if (rest.length > 1) return null;
|
|
598
|
+
return rest[0] ? { name: 'node', target: { label: rest[0] } } : { name: 'graph' };
|
|
599
|
+
}
|
|
600
|
+
if (cmd === 'tasks' || cmd === 'docs' || cmd === 'briefing') return { name: cmd };
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function maybeInteractive(flags, screen) {
|
|
605
|
+
if (!screen) return false;
|
|
606
|
+
if (flags.json) return false;
|
|
607
|
+
if (!tuiEligibility(flags).ok) return false;
|
|
608
|
+
const { startTui } = await import('./tui/launch.mjs');
|
|
609
|
+
const ctx = resolveContext(flags);
|
|
610
|
+
await startTui({ ...ctx, version: VERSION, call: (fn) => call(ctx, fn) }, screen);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
|
|
568
614
|
async function openTuiOrHelp(flags) {
|
|
569
615
|
const gate = tuiEligibility(flags);
|
|
570
616
|
if (gate.ok) {
|
|
@@ -612,14 +658,25 @@ export async function run(argv) {
|
|
|
612
658
|
|
|
613
659
|
// Reads. Each resolves context once and hands the SDK client to a wrapper;
|
|
614
660
|
// none of them knows a URL or an envelope key (§A2, t-623).
|
|
615
|
-
case 'tasks':
|
|
661
|
+
case 'tasks':
|
|
662
|
+
if (await maybeInteractive(flags, screenFor('tasks', flags))) return 0;
|
|
663
|
+
return cmdTasks(flags, resolveContext(flags));
|
|
616
664
|
case 'next': return cmdNext(flags, resolveContext(flags));
|
|
617
|
-
case 'docs':
|
|
665
|
+
case 'docs':
|
|
666
|
+
if (await maybeInteractive(flags, screenFor('docs', flags))) return 0;
|
|
667
|
+
return cmdDocs(flags, resolveContext(flags));
|
|
618
668
|
case 'doc': return cmdDoc(flags, resolveContext(flags), rest);
|
|
619
669
|
case 'projects': return cmdProjects(flags, resolveContext(flags));
|
|
620
670
|
case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
|
|
621
|
-
case 'graph':
|
|
622
|
-
|
|
671
|
+
case 'graph':
|
|
672
|
+
// With a node named, open the walker AT it. With no argument, open the hub
|
|
673
|
+
// list. `graph <a> <b>` is a path query — a single answer, not a place to
|
|
674
|
+
// walk — so that one stays one-shot.
|
|
675
|
+
if (await maybeInteractive(flags, screenFor('graph', flags, rest))) return 0;
|
|
676
|
+
return cmdGraph(flags, resolveContext(flags), rest);
|
|
677
|
+
case 'briefing':
|
|
678
|
+
if (await maybeInteractive(flags, screenFor('briefing', flags))) return 0;
|
|
679
|
+
return cmdBriefing(flags, resolveContext(flags));
|
|
623
680
|
case 'tui':
|
|
624
681
|
// An EXPLICIT request that cannot be honoured fails loudly (exit 1); the
|
|
625
682
|
// implicit one below degrades to help and exits 0. That asymmetry is
|
package/src/tui/app.mjs
CHANGED
|
@@ -20,12 +20,15 @@ import { Briefing } from './screens/briefing.mjs';
|
|
|
20
20
|
import { Finding } from './screens/finding.mjs';
|
|
21
21
|
import { NodeScreen } from './screens/node.mjs';
|
|
22
22
|
import { Header } from './components/header.mjs';
|
|
23
|
+
import { GraphHome } from './screens/graph-home.mjs';
|
|
24
|
+
import { Tasks, Task } from './screens/tasks.mjs';
|
|
25
|
+
import { Docs, Doc } from './screens/docs.mjs';
|
|
23
26
|
|
|
24
27
|
const HOME = { name: 'briefing' };
|
|
25
28
|
|
|
26
|
-
export function App({ ctx }) {
|
|
29
|
+
export function App({ ctx, initial }) {
|
|
27
30
|
const { exit } = useApp();
|
|
28
|
-
const [stack, setStack] = useState([HOME]);
|
|
31
|
+
const [stack, setStack] = useState([initial ?? HOME]);
|
|
29
32
|
const [fatal, setFatal] = useState(null);
|
|
30
33
|
|
|
31
34
|
// ⭐ THE AUTH TAKEOVER HAS TO BE WIRED, not merely rendered. Every screen fetches
|
|
@@ -52,10 +55,22 @@ export function App({ ctx }) {
|
|
|
52
55
|
const push = (screen) => setStack((s) => [...s, screen]);
|
|
53
56
|
const pop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
|
54
57
|
|
|
58
|
+
// ⭐ ONE KEY TO EACH PLACE, FROM ANYWHERE. The previous build could only reach
|
|
59
|
+
// the graph by drilling through a GROUPED finding — and only 2 of 9 live
|
|
60
|
+
// findings are grouped, so most of the briefing was a dead end and tasks and
|
|
61
|
+
// docs still meant quitting and re-running with arguments.
|
|
62
|
+
//
|
|
63
|
+
// ⚠️ THESE REPLACE THE ROOT rather than pushing, so mashing g t d g cannot
|
|
64
|
+
// build a forty-deep stack that takes forty escapes to unwind. `esc` then means
|
|
65
|
+
// "back one step within a section", which is what it looks like it means.
|
|
55
66
|
useInput((input, key) => {
|
|
56
67
|
if (input === 'q' || (key.ctrl && input === 'c')) { exit(); return; }
|
|
57
68
|
if (key.escape) { pop(); return; }
|
|
58
69
|
if (fatal?.kind === 'auth' && input === 'l') { exit(); return; }
|
|
70
|
+
if (input === 'b') { setStack([HOME]); return; }
|
|
71
|
+
if (input === 'g') { setStack([{ name: 'graph' }]); return; }
|
|
72
|
+
if (input === 't') { setStack([{ name: 'tasks' }]); return; }
|
|
73
|
+
if (input === 'd') { setStack([{ name: 'docs' }]); return; }
|
|
59
74
|
});
|
|
60
75
|
|
|
61
76
|
if (fatal?.kind === 'auth') {
|
|
@@ -72,7 +87,20 @@ export function App({ ctx }) {
|
|
|
72
87
|
const trail = stack.filter((s) => s.name === 'node').map((s) => s.target.label ?? s.target.id);
|
|
73
88
|
|
|
74
89
|
let body;
|
|
75
|
-
if (top.name === '
|
|
90
|
+
if (top.name === 'graph') {
|
|
91
|
+
body = html`<${GraphHome} ctx=${wrapped} focused=${true}
|
|
92
|
+
onOpen=${(n) => push({ name: 'node', target: n })} />`;
|
|
93
|
+
} else if (top.name === 'tasks') {
|
|
94
|
+
body = html`<${Tasks} ctx=${wrapped} focused=${true}
|
|
95
|
+
onOpen=${(t) => push({ name: 'task', task: t })} />`;
|
|
96
|
+
} else if (top.name === 'task') {
|
|
97
|
+
body = html`<${Task} task=${top.task} />`;
|
|
98
|
+
} else if (top.name === 'docs') {
|
|
99
|
+
body = html`<${Docs} ctx=${wrapped} focused=${true}
|
|
100
|
+
onOpen=${(d) => push({ name: 'doc', doc: d })} />`;
|
|
101
|
+
} else if (top.name === 'doc') {
|
|
102
|
+
body = html`<${Doc} ctx=${wrapped} doc=${top.doc} focused=${true} />`;
|
|
103
|
+
} else if (top.name === 'briefing') {
|
|
76
104
|
body = html`<${Briefing} ctx=${wrapped} focused=${true} onOpen=${(f) => push({ name: 'finding', finding: f })} />`;
|
|
77
105
|
} else if (top.name === 'node') {
|
|
78
106
|
body = html`<${NodeScreen}
|
|
@@ -109,19 +137,31 @@ export function App({ ctx }) {
|
|
|
109
137
|
<//>`;
|
|
110
138
|
}
|
|
111
139
|
|
|
112
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* The keys that actually do something on THIS screen — never a generic legend.
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ ADVERTISING A KEY THAT DOES NOTHING teaches people the UI is unresponsive,
|
|
144
|
+
* which is worse than not mentioning it. The global jumps are appended once, in
|
|
145
|
+
* one place, so they cannot drift per screen.
|
|
146
|
+
*/
|
|
147
|
+
const JUMPS = 'b briefing · g graph · t tasks · d docs · q quit';
|
|
148
|
+
|
|
113
149
|
function footerFor(top) {
|
|
114
|
-
if (top.name === 'briefing') return
|
|
115
|
-
if (top.name === '
|
|
150
|
+
if (top.name === 'briefing') return `↑↓ move · enter open · r refresh · ${JUMPS}`;
|
|
151
|
+
if (top.name === 'graph') return `↑↓ move · enter walk in · ${JUMPS}`;
|
|
152
|
+
if (top.name === 'tasks' || top.name === 'docs') return `↑↓ move · enter open · ${JUMPS}`;
|
|
153
|
+
if (top.name === 'task') return `esc back · ${JUMPS}`;
|
|
154
|
+
if (top.name === 'doc') return `j/k scroll · esc back · ${JUMPS}`;
|
|
155
|
+
if (top.name === 'node') return `↑↓ move · enter walk in · esc back · r refresh · ${JUMPS}`;
|
|
116
156
|
if (top.name === 'finding') {
|
|
117
157
|
// Only offer the key that works. A grouped finding has members to open; a
|
|
118
158
|
// single one is an aggregate with nothing beneath it, and advertising `enter`
|
|
119
159
|
// there teaches people the UI is unresponsive.
|
|
120
160
|
return top.finding?.members?.length
|
|
121
|
-
?
|
|
122
|
-
:
|
|
161
|
+
? `↑↓ move · enter open in graph · esc back · ${JUMPS}`
|
|
162
|
+
: `esc back · ${JUMPS}`;
|
|
123
163
|
}
|
|
124
|
-
return
|
|
164
|
+
return `esc back · ${JUMPS}`;
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
export { HOME };
|
package/src/tui/launch.mjs
CHANGED
|
@@ -28,8 +28,14 @@ import { App } from './app.mjs';
|
|
|
28
28
|
|
|
29
29
|
const SHOW_CURSOR = '\x1b[?25h';
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* @param initial the screen to open on. `mnema graph "X"` opens the walker AT X
|
|
33
|
+
* rather than at the briefing, because the command someone typed
|
|
34
|
+
* should do the thing they named — not drop them at a home screen
|
|
35
|
+
* with a keybinding to hunt for.
|
|
36
|
+
*/
|
|
37
|
+
export async function startTui(ctx, initial) {
|
|
38
|
+
const instance = render(html`<${App} ctx=${ctx} initial=${initial} />`, {
|
|
33
39
|
// Ink's own exitOnCtrlC would leave our own key handling out of the loop.
|
|
34
40
|
exitOnCtrlC: true,
|
|
35
41
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Documents, and one of them open (t-638).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE VIEWER DEPENDS ON THE SDK FIX IN t-637. docs.get asked for `data.doc`
|
|
5
|
+
* where the API returns the document directly in `data`, so it resolved to
|
|
6
|
+
* undefined and the one-shot `mnema doc` crashed on `d.title`. This screen shows
|
|
7
|
+
* a real error rather than a blank pane if it is ever undefined again — a viewer
|
|
8
|
+
* that renders nothing for a document that exists is the failure this codebase
|
|
9
|
+
* keeps producing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState } from 'react';
|
|
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
|
+
import { take } from '../../paging.mjs';
|
|
18
|
+
|
|
19
|
+
export function Docs({ ctx, focused, onOpen }) {
|
|
20
|
+
const { status, data, error, elapsed, at } = useResource('docs', () =>
|
|
21
|
+
ctx.call((m) => take(m.docs.list({ limit: 40 }), 40)));
|
|
22
|
+
|
|
23
|
+
if (status === 'loading') {
|
|
24
|
+
return html`<${Box} paddingX=${1}><${Text} dimColor>Loading documents… ${elapsed > 1 ? `${elapsed}s` : ''}<//><//>`;
|
|
25
|
+
}
|
|
26
|
+
if (error && !data) {
|
|
27
|
+
return html`<${Box} paddingX=${1}><${Text} color="red">${error.message ?? String(error)}<//><//>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rows = data?.rows ?? [];
|
|
31
|
+
return html`
|
|
32
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
33
|
+
<${Box}>
|
|
34
|
+
<${Text} bold>Documents<//>
|
|
35
|
+
<${Text} dimColor> ${rows.length}${data?.more ? '+' : ''} · ${ago(at)}<//>
|
|
36
|
+
<//>
|
|
37
|
+
${rows.length === 0
|
|
38
|
+
? html`<${Text} dimColor>No documents yet.<//>`
|
|
39
|
+
: html`<${List}
|
|
40
|
+
items=${rows.map((d) => ({
|
|
41
|
+
key: d.id,
|
|
42
|
+
left: String(d.updatedAt ?? '').slice(0, 10),
|
|
43
|
+
label: d.title ?? '(untitled)',
|
|
44
|
+
}))}
|
|
45
|
+
focused=${focused}
|
|
46
|
+
height=${12}
|
|
47
|
+
onSelect=${(i) => onOpen(rows[i])}
|
|
48
|
+
/>`}
|
|
49
|
+
<//>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PAGE = 16;
|
|
53
|
+
|
|
54
|
+
export function Doc({ ctx, doc, focused }) {
|
|
55
|
+
const [top, setTop] = useState(0);
|
|
56
|
+
const { status, data, error, elapsed } = useResource(`doc:${doc?.id}`, () =>
|
|
57
|
+
ctx.call((m) => m.docs.get(doc.id)));
|
|
58
|
+
|
|
59
|
+
// ⚠️ EVERY HOOK BEFORE EVERY EARLY RETURN. React requires a stable call order,
|
|
60
|
+
// and this component returns early for loading, error and empty — so a
|
|
61
|
+
// useInput placed after those runs on some renders and not others, which is
|
|
62
|
+
// the "rendered fewer hooks than expected" crash. Guarding with `isActive`
|
|
63
|
+
// rather than by placement is what keeps the order fixed.
|
|
64
|
+
const lineCount = String(data?.markdown ?? '').split('\n').length;
|
|
65
|
+
useInput((input, key) => {
|
|
66
|
+
if (key.downArrow || input === 'j') setTop((t) => Math.min(Math.max(0, lineCount - PAGE), t + 1));
|
|
67
|
+
else if (key.upArrow || input === 'k') setTop((t) => Math.max(0, t - 1));
|
|
68
|
+
else if (input === ' ') setTop((t) => Math.min(Math.max(0, lineCount - PAGE), t + PAGE));
|
|
69
|
+
}, { isActive: Boolean(focused) && lineCount > PAGE });
|
|
70
|
+
|
|
71
|
+
if (status === 'loading') {
|
|
72
|
+
return html`<${Box} paddingX=${1}><${Text} dimColor>Opening ${doc?.title ?? ''}… ${elapsed > 1 ? `${elapsed}s` : ''}<//><//>`;
|
|
73
|
+
}
|
|
74
|
+
if (error) {
|
|
75
|
+
return html`<${Box} flexDirection="column" paddingX=${1}>
|
|
76
|
+
<${Text} color="red">${error.message ?? String(error)}<//>
|
|
77
|
+
<${Text} dimColor>esc to go back<//>
|
|
78
|
+
<//>`;
|
|
79
|
+
}
|
|
80
|
+
// ⚠️ NOT A BLANK PANE. If the SDK ever hands back undefined again, say so.
|
|
81
|
+
if (!data) {
|
|
82
|
+
return html`<${Box} paddingX=${1}>
|
|
83
|
+
<${Text} color="yellow">The API returned no document body for this id.<//>
|
|
84
|
+
<//>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const lines = String(data.markdown ?? '').split('\n');
|
|
88
|
+
const view = lines.slice(top, top + PAGE);
|
|
89
|
+
|
|
90
|
+
return html`
|
|
91
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
92
|
+
<${Text} bold>${data.title ?? '(untitled)'}<//>
|
|
93
|
+
<${Text} dimColor>${data.path ?? ''} · updated ${String(data.updatedAt ?? '').slice(0, 10)}<//>
|
|
94
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
95
|
+
${view.map((l, i) => html`<${Text} key=${i}>${l || ' '}<//>`)}
|
|
96
|
+
<//>
|
|
97
|
+
${lines.length > PAGE
|
|
98
|
+
? html`<${Text} dimColor> ${Math.min(top + PAGE, lines.length)}/${lines.length} lines · j/k to scroll<//>`
|
|
99
|
+
: null}
|
|
100
|
+
<//>`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A way INTO the graph (t-638).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THE GRAPH WAS UNREACHABLE. t-634 wired the node walker but the only door to
|
|
5
|
+
* it was drilling through a GROUPED finding — and only 2 of the 9 live findings
|
|
6
|
+
* are grouped, so for most of the briefing there was no way in at all. The most
|
|
7
|
+
* interesting thing Mnema knows had no entry point, which is exactly what "graph
|
|
8
|
+
* doesnt appear inside the cli ui" means.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ GOD NODES ARE THE RIGHT FRONT DOOR, and this is a judgement rather than a
|
|
11
|
+
* convenience. An alphabetical list of 900 concepts is a phone book: every entry
|
|
12
|
+
* equally weighted, none of them a starting point. godNodes() returns the
|
|
13
|
+
* structurally load-bearing ones — highest betweenness, largest blast radius —
|
|
14
|
+
* which is to say the places the graph is actually dense. Verified against prod:
|
|
15
|
+
* 12 hubs, each with label, type and a real summary.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Box, Text } from 'ink';
|
|
19
|
+
import { html } from '../h.mjs';
|
|
20
|
+
import { useResource, ago } from '../store.mjs';
|
|
21
|
+
import { List } from '../components/list.mjs';
|
|
22
|
+
|
|
23
|
+
export function GraphHome({ ctx, focused, onOpen }) {
|
|
24
|
+
const { status, data, error, elapsed, at } = useResource('graph:home', () =>
|
|
25
|
+
ctx.call((m) => m.graph.godNodes({ limit: 20 }).all()));
|
|
26
|
+
|
|
27
|
+
if (status === 'loading') {
|
|
28
|
+
return html`<${Box} paddingX=${1}>
|
|
29
|
+
<${Text} dimColor>Finding the hubs… ${elapsed > 1 ? `${elapsed}s` : ''}<//>
|
|
30
|
+
<//>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (error && !data) {
|
|
34
|
+
return html`<${Box} flexDirection="column" paddingX=${1}>
|
|
35
|
+
<${Text} color="red">${error.message ?? String(error)}<//>
|
|
36
|
+
<${Text} dimColor>The graph is a paid feature — this build or plan may not have it.<//>
|
|
37
|
+
<//>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const nodes = data ?? [];
|
|
41
|
+
|
|
42
|
+
return html`
|
|
43
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
44
|
+
<${Box}>
|
|
45
|
+
<${Text} bold>Graph<//>
|
|
46
|
+
<${Text} dimColor> ${nodes.length} hub${nodes.length === 1 ? '' : 's'} · ${ago(at)}<//>
|
|
47
|
+
<//>
|
|
48
|
+
<${Text} dimColor>The most connected nodes — start here and walk.<//>
|
|
49
|
+
|
|
50
|
+
${nodes.length === 0
|
|
51
|
+
? html`<${Box} marginTop=${1}><${Text} dimColor>No hubs yet. The graph needs more material before structure emerges.<//><//>`
|
|
52
|
+
: html`<${Box} marginTop=${1}>
|
|
53
|
+
<${List}
|
|
54
|
+
items=${nodes.map((n) => ({ key: n.id, left: String(n.type ?? ''), label: n.label ?? '(unlabelled)' }))}
|
|
55
|
+
focused=${focused}
|
|
56
|
+
height=${12}
|
|
57
|
+
onSelect=${(i) => onOpen(nodes[i])}
|
|
58
|
+
/>
|
|
59
|
+
<//>`}
|
|
60
|
+
<//>`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board, without leaving the app (t-638).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE BRANCH NAME IS THE POINT. This project's whole dev loop hangs on naming
|
|
5
|
+
* the branch `t-<n>-<slug>` — it is the only signal that needs no cooperation
|
|
6
|
+
* from anything else. So the detail view's job is to hand that over ready to
|
|
7
|
+
* paste, exactly as the one-shot `mnema next` does.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Box, Text } from 'ink';
|
|
11
|
+
import { html } from '../h.mjs';
|
|
12
|
+
import { useResource, ago } from '../store.mjs';
|
|
13
|
+
import { List } from '../components/list.mjs';
|
|
14
|
+
import { take } from '../../paging.mjs';
|
|
15
|
+
|
|
16
|
+
const slugOf = (t) => String(t.title ?? '').toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '');
|
|
18
|
+
|
|
19
|
+
export function Tasks({ ctx, focused, onOpen }) {
|
|
20
|
+
const { status, data, error, elapsed, at } = useResource('tasks', () =>
|
|
21
|
+
ctx.call((m) => take(m.tasks.list({ limit: 40 }), 40)));
|
|
22
|
+
|
|
23
|
+
if (status === 'loading') {
|
|
24
|
+
return html`<${Box} paddingX=${1}><${Text} dimColor>Loading tasks… ${elapsed > 1 ? `${elapsed}s` : ''}<//><//>`;
|
|
25
|
+
}
|
|
26
|
+
if (error && !data) {
|
|
27
|
+
return html`<${Box} paddingX=${1}><${Text} color="red">${error.message ?? String(error)}<//><//>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rows = data?.rows ?? [];
|
|
31
|
+
return html`
|
|
32
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
33
|
+
<${Box}>
|
|
34
|
+
<${Text} bold>Tasks<//>
|
|
35
|
+
<${Text} dimColor> ${rows.length}${data?.more ? '+' : ''} · ${ago(at)}<//>
|
|
36
|
+
<//>
|
|
37
|
+
${rows.length === 0
|
|
38
|
+
? html`<${Text} dimColor>Nothing on the board.<//>`
|
|
39
|
+
: html`<${List}
|
|
40
|
+
items=${rows.map((t) => ({
|
|
41
|
+
key: t.id,
|
|
42
|
+
left: `${t.publicId ?? String(t.id).slice(0, 8)} ${String(t.status ?? '')}`,
|
|
43
|
+
label: t.title ?? '',
|
|
44
|
+
}))}
|
|
45
|
+
focused=${focused}
|
|
46
|
+
height=${12}
|
|
47
|
+
onSelect=${(i) => onOpen(rows[i])}
|
|
48
|
+
/>`}
|
|
49
|
+
<//>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Task({ task }) {
|
|
53
|
+
if (!task) return html`<${Box} paddingX=${1}><${Text} dimColor>Nothing selected.<//><//>`;
|
|
54
|
+
const slug = slugOf(task);
|
|
55
|
+
return html`
|
|
56
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
57
|
+
<${Text} bold>${task.title ?? '(untitled)'}<//>
|
|
58
|
+
<${Box} marginTop=${1} flexDirection="column">
|
|
59
|
+
<${Box}><${Text} dimColor>id <//><${Text} color="cyan">${task.publicId ?? task.id}<//><//>
|
|
60
|
+
<${Box}><${Text} dimColor>status <//><${Text}>${task.status ?? '—'}<//><//>
|
|
61
|
+
<${Box}><${Text} dimColor>priority <//><${Text}>${task.priority ?? '—'}<//><//>
|
|
62
|
+
<//>
|
|
63
|
+
${task.description
|
|
64
|
+
? html`<${Box} marginTop=${1}><${Text} dimColor>${String(task.description).replace(/\s+/g, ' ').slice(0, 400)}<//><//>`
|
|
65
|
+
: null}
|
|
66
|
+
${task.publicId
|
|
67
|
+
? html`<${Box} marginTop=${1} flexDirection="column">
|
|
68
|
+
<${Text} dimColor>branch<//>
|
|
69
|
+
<${Text}>git checkout -b ${task.publicId}${slug ? `-${slug}` : ''}<//>
|
|
70
|
+
<//>`
|
|
71
|
+
: null}
|
|
72
|
+
<//>`;
|
|
73
|
+
}
|