@mnemahq/cli 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/package.json +7 -3
- package/src/browser.mjs +93 -0
- package/src/cli.mjs +160 -49
- package/src/login.mjs +82 -46
- package/src/paging.mjs +50 -0
- package/src/read-commands.mjs +135 -70
- package/src/render/layout.mjs +117 -0
- package/src/render/pick.mjs +131 -0
- package/src/render/theme.mjs +40 -12
- package/src/render/wait.mjs +96 -0
- package/src/tui/app.mjs +89 -0
- package/src/tui/components/list.mjs +57 -0
- package/src/tui/h.mjs +23 -0
- package/src/tui/launch.mjs +61 -0
- package/src/tui/screens/briefing.mjs +74 -0
- package/src/tui/screens/finding.mjs +53 -0
- package/src/tui/store.mjs +65 -0
- package/src/tui-gate.mjs +79 -0
package/src/paging.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop reading when the caller has enough (t-630).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ `--limit` DID NOT LIMIT ANYTHING. Every read command called `.all()`, and
|
|
5
|
+
* `paginate()` follows `next_cursor` to exhaustion — `limit` is only the page-SIZE
|
|
6
|
+
* query parameter. So a smaller `--limit` made the CLI do strictly more work:
|
|
7
|
+
*
|
|
8
|
+
* mnema docs --limit 5
|
|
9
|
+
* /api/public/v1/docs?limit=5
|
|
10
|
+
* /api/public/v1/docs?limit=5&cursor=59e921a0-…
|
|
11
|
+
* /api/public/v1/docs?limit=5&cursor=92360dfa-…
|
|
12
|
+
* …and on, and on
|
|
13
|
+
*
|
|
14
|
+
* Measured against prod: it blew past a 10-request cap and was still walking the
|
|
15
|
+
* cursor chain. `mnema docs --limit 5` never returned — it was the command that
|
|
16
|
+
* hung this branch's own baseline capture — and had it finished it would have
|
|
17
|
+
* printed EVERY document, because the header counts the rows it collected.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ SO THE PRINTED COUNT CHANGES with this fix, and that is the point: it used to
|
|
20
|
+
* be "everything you have", now it is "what you asked for".
|
|
21
|
+
*
|
|
22
|
+
* `Paginated<T>` is an AsyncIterable, so stopping is simply a `break` — the
|
|
23
|
+
* generator is closed and the next page is never requested.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read at most `limit` items.
|
|
28
|
+
*
|
|
29
|
+
* Returns `more` so callers can say that the list was cut. A row count that
|
|
30
|
+
* silently means "there might be others" is the same lie `ask` and `graph` used to
|
|
31
|
+
* tell with their headers, and it is worth exactly as little.
|
|
32
|
+
*/
|
|
33
|
+
export async function take(paginated, limit) {
|
|
34
|
+
// ⚠️ `>= 0`, NOT `> 0`. With `> 0` a limit of ZERO fell through to Infinity and
|
|
35
|
+
// fetched the entire workspace — the exact bug this module exists to fix, hiding
|
|
36
|
+
// inside the fix, and it made the `max === 0` guard below unreachable. An
|
|
37
|
+
// explicit 0 means zero rows; only undefined, NaN or a negative is "unbounded".
|
|
38
|
+
const max = Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : Infinity;
|
|
39
|
+
const rows = [];
|
|
40
|
+
if (max === 0) return { rows, more: false };
|
|
41
|
+
|
|
42
|
+
for await (const item of paginated) {
|
|
43
|
+
rows.push(item);
|
|
44
|
+
// Read ONE past the limit rather than stopping exactly on it: that extra item
|
|
45
|
+
// is the only way to distinguish "exactly this many exist" from "there are
|
|
46
|
+
// more", and getting it wrong means the footer either lies or never appears.
|
|
47
|
+
if (rows.length > max) return { rows: rows.slice(0, max), more: true };
|
|
48
|
+
}
|
|
49
|
+
return { rows, more: false };
|
|
50
|
+
}
|
package/src/read-commands.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The read commands — tasks, docs, projects, ask, graph, briefing (t-624).
|
|
2
|
+
* The read commands — tasks, docs, projects, ask, graph, briefing (t-624, t-630).
|
|
3
3
|
*
|
|
4
4
|
* ⭐ EVERY ONE OF THESE IS A WRAPPER, NOT A CLIENT. They exist because t-623 put
|
|
5
5
|
* the CLI on @mnemahq/sdk; each is four lines of SDK call plus formatting, and
|
|
@@ -16,12 +16,19 @@
|
|
|
16
16
|
* news. On a core build six of seven finding families cannot produce
|
|
17
17
|
* anything at all, and `coverage.notice` is the only thing that says so.
|
|
18
18
|
*
|
|
19
|
+
* t-630 moved every heading, row and empty state onto render/layout.mjs so the
|
|
20
|
+
* grammar is one module's job rather than eleven call sites', and put `--limit`
|
|
21
|
+
* through take() so it stops the pagination instead of decorating it.
|
|
22
|
+
*
|
|
19
23
|
* --json everywhere: a CLI you cannot pipe is half a CLI, and the SDK already
|
|
20
24
|
* returns the shape worth emitting, so it costs one line each.
|
|
21
25
|
*/
|
|
22
26
|
|
|
23
27
|
import { call, hasApiKey, canAuthenticate, renderError } from './client.mjs';
|
|
24
|
-
import {
|
|
28
|
+
import { take } from './paging.mjs';
|
|
29
|
+
import { c, truncate, link, mark } from './util.mjs';
|
|
30
|
+
import { heading, empty, rows, more, copyable } from './render/layout.mjs';
|
|
31
|
+
import { pick } from './render/pick.mjs';
|
|
25
32
|
|
|
26
33
|
/** Machine output is the whole object; humans get the formatted view. */
|
|
27
34
|
function emit(flags, value, render) {
|
|
@@ -35,28 +42,34 @@ async function guard(workspaceId) {
|
|
|
35
42
|
process.exit(1);
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
45
|
+
const limitOf = (flags, fallback) => Number(flags.limit) || fallback;
|
|
46
|
+
|
|
47
|
+
/** The short id a human actually reads back to you. */
|
|
48
|
+
const shortId = (row) => row.publicId || String(row.id).slice(0, 8);
|
|
42
49
|
|
|
43
50
|
// ── tasks ─────────────────────────────────────────────────────────────────────
|
|
44
51
|
export async function cmdTasks(flags, ctx) {
|
|
45
52
|
await guard(ctx.workspaceId);
|
|
46
|
-
const limit =
|
|
53
|
+
const limit = limitOf(flags, 20);
|
|
47
54
|
try {
|
|
48
|
-
const
|
|
55
|
+
const page = await call(ctx, (m) => take(m.tasks.list({
|
|
49
56
|
limit,
|
|
50
57
|
...(flags.status ? { status: flags.status } : {}),
|
|
51
58
|
...(flags.project ? { project: flags.project } : {}),
|
|
52
|
-
})
|
|
53
|
-
emit(flags, rows, () => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
}), limit));
|
|
60
|
+
emit(flags, page.rows, () => {
|
|
61
|
+
heading('Tasks', page.rows.length);
|
|
62
|
+
if (!page.rows.length) {
|
|
63
|
+
return empty(
|
|
64
|
+
flags.status || flags.project ? 'No tasks match those filters.' : 'No tasks yet.',
|
|
65
|
+
flags.status || flags.project ? 'Drop the filters to see the whole board.' : undefined,
|
|
66
|
+
);
|
|
59
67
|
}
|
|
68
|
+
rows(page.rows.map((t) => ({
|
|
69
|
+
cells: [shortId(t), String(t.status), String(t.priority), t.title ?? ''],
|
|
70
|
+
style: [c.cyan, undefined, c.dim, undefined],
|
|
71
|
+
})));
|
|
72
|
+
more(page.more, `mnema tasks --limit ${limit * 2}`);
|
|
60
73
|
});
|
|
61
74
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'tasks' }); }
|
|
62
75
|
}
|
|
@@ -66,14 +79,26 @@ export async function cmdNext(flags, ctx) {
|
|
|
66
79
|
try {
|
|
67
80
|
const t = await call(ctx, (m) => m.tasks.next());
|
|
68
81
|
emit(flags, t, () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
heading('Next');
|
|
83
|
+
if (!t) return empty('Nothing queued — the board is clear.');
|
|
84
|
+
rows([{ cells: [shortId(t), t.title ?? ''], style: [c.cyan, undefined] }]);
|
|
85
|
+
if (t.description) {
|
|
86
|
+
console.log(c.dim(` ${truncate(t.description.replace(/\s+/g, ' '), 300)}`));
|
|
87
|
+
}
|
|
88
|
+
// A real URL, so make it clickable where the terminal supports it. link()
|
|
89
|
+
// degrades to "label (url)" when colour is off, rather than emitting an OSC 8
|
|
90
|
+
// sequence into someone's pipe.
|
|
91
|
+
if (t.githubPrUrl) console.log(` ${c.dim('pr:')} ${link(t.githubPrUrl, t.githubPrUrl)}`);
|
|
92
|
+
// ⚠️ THE BRANCH NAME IS THE LOAD-BEARING PART OF THIS PROJECT'S DEV LOOP —
|
|
93
|
+
// it is the only signal that needs no cooperation from anything else, and
|
|
94
|
+
// from it the hook links the session, the PR links the task and the board
|
|
95
|
+
// moves on its own. So it is printed PLAIN: no colour, no padding, no
|
|
96
|
+
// truncation, because a padded string carries invisible trailing whitespace
|
|
97
|
+
// into the clipboard and an escape sequence carries worse.
|
|
74
98
|
if (t.publicId) {
|
|
75
|
-
const slug = t.title
|
|
76
|
-
|
|
99
|
+
const slug = String(t.title ?? '').toLowerCase()
|
|
100
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '');
|
|
101
|
+
copyable('branch:', `git checkout -b ${t.publicId}${slug ? `-${slug}` : ''}`);
|
|
77
102
|
}
|
|
78
103
|
});
|
|
79
104
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'next' }); }
|
|
@@ -82,30 +107,59 @@ export async function cmdNext(flags, ctx) {
|
|
|
82
107
|
// ── docs ──────────────────────────────────────────────────────────────────────
|
|
83
108
|
export async function cmdDocs(flags, ctx) {
|
|
84
109
|
await guard(ctx.workspaceId);
|
|
85
|
-
const limit =
|
|
110
|
+
const limit = limitOf(flags, 20);
|
|
86
111
|
try {
|
|
87
|
-
const
|
|
88
|
-
emit(flags, rows, () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
112
|
+
const page = await call(ctx, (m) => take(m.docs.list({ limit }), limit));
|
|
113
|
+
emit(flags, page.rows, () => {
|
|
114
|
+
heading('Documents', page.rows.length);
|
|
115
|
+
if (!page.rows.length) return empty('No documents yet.', 'Docs you write in the app show up here.');
|
|
116
|
+
rows(page.rows.map((d) => ({
|
|
117
|
+
cells: [String(d.id).slice(0, 8), String(d.updatedAt).slice(0, 10), d.title ?? ''],
|
|
118
|
+
style: [c.dim, c.dim, undefined],
|
|
119
|
+
})));
|
|
120
|
+
more(page.more, `mnema docs --limit ${limit * 2}`);
|
|
94
121
|
});
|
|
95
122
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'docs' }); }
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
export async function cmdDoc(flags, ctx, rest) {
|
|
99
126
|
await guard(ctx.workspaceId);
|
|
100
|
-
|
|
101
|
-
|
|
127
|
+
let id = rest[0];
|
|
128
|
+
// ⭐ OFFER, DO NOT DEMAND. With no id this printed a usage line, so the real
|
|
129
|
+
// workflow was: run `mnema docs`, read a truncated title, select eight hex
|
|
130
|
+
// characters with the mouse, paste them back. The list command already knows
|
|
131
|
+
// every id; asking the user to ferry one across is work the tool can do.
|
|
132
|
+
//
|
|
133
|
+
// ⚠️ Only when a human is actually there. pick() returns null on a non-TTY (and
|
|
134
|
+
// --json means a script is reading), so the usage error remains the answer for
|
|
135
|
+
// every non-interactive caller — no hanging on a stdin that will never speak.
|
|
136
|
+
// ⚠️ CHECK THE TERMINAL BEFORE THE NETWORK. Testing interactivity only inside
|
|
137
|
+
// pick() meant a piped `mnema doc` fetched fifty documents and then threw them
|
|
138
|
+
// away — a round trip, and a rate-limit slot, spent on a picker nobody can see.
|
|
139
|
+
const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
140
|
+
if (!id && !flags.json && interactive) {
|
|
141
|
+
const page = await call(ctx, (m) => take(m.docs.list({ limit: 50 }), 50)).catch(() => null);
|
|
142
|
+
if (page?.rows.length) {
|
|
143
|
+
const chosen = await pick(
|
|
144
|
+
page.rows.map((d) => ({ label: `${d.title ?? '(untitled)'} ${String(d.updatedAt).slice(0, 10)}` })),
|
|
145
|
+
{ title: 'Which document?' },
|
|
146
|
+
);
|
|
147
|
+
if (chosen === null) return;
|
|
148
|
+
id = page.rows[chosen].id;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!id) {
|
|
152
|
+
console.error(c.red('Usage: mnema doc <id>'));
|
|
153
|
+
console.error(c.dim(' Run it without an id in a terminal to pick from a list.'));
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
102
156
|
try {
|
|
103
157
|
const d = await call(ctx, (m) => m.docs.get(id));
|
|
104
|
-
//
|
|
105
|
-
// the heading is not silently baked into the file.
|
|
158
|
+
// ⚠️ MARKDOWN TO STDOUT, EVERYTHING ELSE TO STDERR, so `mnema doc <id> > f.md`
|
|
159
|
+
// gets the document alone and the heading is not silently baked into the file.
|
|
106
160
|
emit(flags, d, () => {
|
|
107
|
-
console.error(c.bold(d.title));
|
|
108
|
-
console.error(c.dim(`${d.path} · updated ${String(d.updatedAt).slice(0, 10)}`));
|
|
161
|
+
console.error(c.bold(d.title ?? '(untitled)'));
|
|
162
|
+
console.error(c.dim(`${d.path ?? ''} · updated ${String(d.updatedAt).slice(0, 10)}`));
|
|
109
163
|
if (d.markdown) console.log(d.markdown);
|
|
110
164
|
});
|
|
111
165
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'doc' }); }
|
|
@@ -114,12 +168,17 @@ export async function cmdDoc(flags, ctx, rest) {
|
|
|
114
168
|
// ── projects ──────────────────────────────────────────────────────────────────
|
|
115
169
|
export async function cmdProjects(flags, ctx) {
|
|
116
170
|
await guard(ctx.workspaceId);
|
|
171
|
+
const limit = limitOf(flags, 50);
|
|
117
172
|
try {
|
|
118
|
-
const
|
|
119
|
-
emit(flags, rows, () => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
173
|
+
const page = await call(ctx, (m) => take(m.projects.list(), limit));
|
|
174
|
+
emit(flags, page.rows, () => {
|
|
175
|
+
heading('Projects', page.rows.length);
|
|
176
|
+
if (!page.rows.length) return empty('No projects yet.');
|
|
177
|
+
rows(page.rows.map((p) => ({
|
|
178
|
+
cells: [String(p.id).slice(0, 8), p.name ?? ''],
|
|
179
|
+
style: [c.dim, undefined],
|
|
180
|
+
})));
|
|
181
|
+
more(page.more, `mnema projects --limit ${limit * 2}`);
|
|
123
182
|
});
|
|
124
183
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'projects' }); }
|
|
125
184
|
}
|
|
@@ -141,16 +200,12 @@ export async function cmdAsk(flags, ctx, rest) {
|
|
|
141
200
|
const line = `confidence ${pct}%${why} · tier ${a.tier}${a.usedFallback ? ' · fallback' : ''}`;
|
|
142
201
|
console.log(pct >= 60 ? c.dim(`\n${line}`) : c.yellow(`\n${line} — treat as a lead, not a fact`));
|
|
143
202
|
if (a.sources?.length) {
|
|
144
|
-
// ⚠️ THE HEADER USED TO LIE. It printed "sources (7):" and then listed
|
|
145
|
-
// five, silently. Either show them all or say how many are hidden —
|
|
146
|
-
// a count that does not match the rows under it teaches the reader to
|
|
147
|
-
// distrust every other number in the output.
|
|
148
203
|
const SHOWN = 5;
|
|
149
204
|
const shown = a.sources.slice(0, SHOWN);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
205
|
+
console.log('');
|
|
206
|
+
heading('Sources', a.sources.length);
|
|
207
|
+
rows(shown.map((s) => ({ cells: [truncate(s.title ?? '', 70)], style: [c.dim] })));
|
|
208
|
+
more(a.sources.length - shown.length, 'mnema ask --json "…"');
|
|
154
209
|
}
|
|
155
210
|
});
|
|
156
211
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'ask' }); }
|
|
@@ -164,22 +219,27 @@ export async function cmdGraph(flags, ctx, rest) {
|
|
|
164
219
|
const g = await call(ctx, (m) => m.graph.traverse(from, to));
|
|
165
220
|
emit(flags, g, () => {
|
|
166
221
|
if (to) {
|
|
167
|
-
// connected:false
|
|
222
|
+
// ⚠️ connected:false IS AN ANSWER, not a failure — say so plainly rather
|
|
168
223
|
// than printing an empty list and letting it read as an error.
|
|
169
|
-
if (!g.connected)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const n = g.nodes.find((x) => x.id === id);
|
|
173
|
-
console.log(` ${n ? trunc(n.label, 60) : id}${n?.type ? c.dim(` [${n.type}]`) : ''}`);
|
|
224
|
+
if (!g.connected) {
|
|
225
|
+
heading('Path');
|
|
226
|
+
return empty(`No route between "${from}" and "${to}".`, 'They are in the graph but nothing links them.');
|
|
174
227
|
}
|
|
228
|
+
heading('Path', `${g.hopCount} hop${g.hopCount === 1 ? '' : 's'}`);
|
|
229
|
+
rows(g.path.map((id) => {
|
|
230
|
+
const n = g.nodes.find((x) => x.id === id);
|
|
231
|
+
return { cells: [n?.type ?? '', n ? truncate(n.label, 60) : id], style: [c.dim, undefined] };
|
|
232
|
+
}));
|
|
175
233
|
return;
|
|
176
234
|
}
|
|
177
|
-
console.log(c.bold(`${g.nodes.length} node(s), ${g.edges.length} edge(s) around "${from}"`));
|
|
178
|
-
// ⚠️ SAME LIE AS `ask`: the header reported the true count and the body
|
|
179
|
-
// stopped at 25 without a word.
|
|
180
235
|
const CAP = 25;
|
|
181
|
-
|
|
182
|
-
if (g.nodes.length
|
|
236
|
+
heading(`Around "${from}"`, `${g.nodes.length} node${g.nodes.length === 1 ? '' : 's'} · ${g.edges.length} edge${g.edges.length === 1 ? '' : 's'}`);
|
|
237
|
+
if (!g.nodes.length) return empty(`Nothing is connected to "${from}" yet.`);
|
|
238
|
+
rows(g.nodes.slice(0, CAP).map((n) => ({
|
|
239
|
+
cells: [n.type ?? '', truncate(n.label, 60)],
|
|
240
|
+
style: [c.dim, undefined],
|
|
241
|
+
})));
|
|
242
|
+
more(g.nodes.length - CAP, `mnema graph --json "${from}"`);
|
|
183
243
|
});
|
|
184
244
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'graph' }); }
|
|
185
245
|
}
|
|
@@ -191,21 +251,26 @@ export async function cmdBriefing(flags, ctx) {
|
|
|
191
251
|
const b = await call(ctx, (m) => m.findings.briefing());
|
|
192
252
|
emit(flags, b, () => {
|
|
193
253
|
const p = b.pulse;
|
|
194
|
-
|
|
195
|
-
|
|
254
|
+
heading('Pulse');
|
|
255
|
+
rows([
|
|
256
|
+
{ cells: ['projects', String(p.activeProjects)], style: [c.dim, undefined] },
|
|
257
|
+
{ cells: ['problems', String(p.problemsInActiveProjects)], style: [c.dim, p.problemsInActiveProjects > 0 ? c.yellow : undefined] },
|
|
258
|
+
{ cells: ['spend 7d', `$${p.cost7dUsd}`], style: [c.dim, undefined] },
|
|
259
|
+
]);
|
|
196
260
|
|
|
197
261
|
// ⚠️ THE CAVEATS COME BEFORE THE LIST, on purpose. A short list has three
|
|
198
262
|
// causes and only one is good news; printing the findings first invites the
|
|
199
263
|
// reader to conclude "all clear" before reaching the reason it is short.
|
|
200
|
-
if (b.neverComputed) console.log(c.yellow(
|
|
201
|
-
if (b.coverage?.degraded) console.log(c.yellow(`\n ${b.coverage.notice}`));
|
|
264
|
+
if (b.neverComputed) console.log(c.yellow(`\n ${mark.warn()} The findings engine has never run for this workspace.`));
|
|
265
|
+
if (b.coverage?.degraded) console.log(c.yellow(`\n ${mark.warn()} ${b.coverage.notice}`));
|
|
202
266
|
|
|
203
|
-
console.log(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
267
|
+
console.log('');
|
|
268
|
+
heading('Findings', b.findings.length);
|
|
269
|
+
if (!b.findings.length) return empty('Nothing surfaced.');
|
|
270
|
+
rows(b.findings.map((f) => ({
|
|
271
|
+
cells: [String(f.kind), `${truncate(f.headline, 90)}${f.grouped ? ` ×${f.count}` : ''}`],
|
|
272
|
+
style: [c.dim, undefined],
|
|
273
|
+
})));
|
|
209
274
|
});
|
|
210
275
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'briefing' }); }
|
|
211
276
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One output grammar for the whole CLI (t-630).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THE CLI READ AS FOUR DIFFERENT PROGRAMS. Not because any one line was wrong,
|
|
5
|
+
* but because there were four heading grammars in eleven call sites:
|
|
6
|
+
*
|
|
7
|
+
* Mnema status product-prefixed
|
|
8
|
+
* Server sessions (7) noun + parenthesised count
|
|
9
|
+
* 12 task(s) count-first, with the "(s)" tell
|
|
10
|
+
* Pulse bare noun
|
|
11
|
+
*
|
|
12
|
+
* and three empty-state voices ("No docs.", "none found", "nothing surfaced"), and
|
|
13
|
+
* eleven hand-picked padEnd widths that had nothing to agree with. t-628 built the
|
|
14
|
+
* primitives; this module spends them, and it exists so the grammar is enforced by
|
|
15
|
+
* a function signature rather than by everyone remembering.
|
|
16
|
+
*
|
|
17
|
+
* The grammar, in one line each:
|
|
18
|
+
*
|
|
19
|
+
* heading Title Case noun, left. Count or context, dim, right.
|
|
20
|
+
* rows a table. Columns measured from content, last column absorbs.
|
|
21
|
+
* empty one dim sentence saying what is absent, then what to do.
|
|
22
|
+
* footer never a bare number. If something was cut, the command to see it.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ AND ONE RULE THAT IS NOT COSMETIC: anything the user is meant to COPY —
|
|
25
|
+
* the branch name from `next`, a conflict path from `pull` — is printed plain.
|
|
26
|
+
* No colour, no padding, no truncation. A padded string carries invisible trailing
|
|
27
|
+
* whitespace into their clipboard and an escape sequence carries worse.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { c, section, table, truncate, width, mark, glyph } from './theme.mjs';
|
|
31
|
+
|
|
32
|
+
/** `Tasks 12` */
|
|
33
|
+
export function heading(title, meta) {
|
|
34
|
+
console.log(section(title, meta == null ? undefined : String(meta)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The one empty state.
|
|
39
|
+
*
|
|
40
|
+
* `hint` is where the CLI earns its keep: "No documents yet." is a dead end,
|
|
41
|
+
* "No documents yet. / Docs you create in the app appear here." is an answer.
|
|
42
|
+
*/
|
|
43
|
+
export function empty(sentence, hint) {
|
|
44
|
+
console.log(c.dim(` ${sentence}`));
|
|
45
|
+
if (hint) console.log(c.dim(` ${hint}`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Aligned rows. `rows` is `[{ cells, style? }]` — see theme.table. */
|
|
49
|
+
export function rows(list, opts) {
|
|
50
|
+
for (const line of table(list, opts)) console.log(line);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What was left out, and the exact command that shows it.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ THIS IS THE ANTI-LIE. Three places used to print a header count and then a
|
|
57
|
+
* shorter body with no word about it: `ask` said "sources (7)" and listed five,
|
|
58
|
+
* `graph` capped at 25 silently, `search` cut previews mid-sentence. A count that
|
|
59
|
+
* disagrees with the rows beneath it teaches the reader to distrust every other
|
|
60
|
+
* number in the output.
|
|
61
|
+
*/
|
|
62
|
+
export function more(hiddenOrTrue, command) {
|
|
63
|
+
if (!hiddenOrTrue) return;
|
|
64
|
+
const what = typeof hiddenOrTrue === 'number' ? `${hiddenOrTrue} more` : 'more available';
|
|
65
|
+
console.log(c.dim(` … ${what} — ${command}`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A line the user is expected to copy. Plain, always. */
|
|
69
|
+
export function copyable(label, text) {
|
|
70
|
+
console.log(`\n ${c.dim(label)} ${text}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `[glyph] label value` — ONE primitive for every label/value block.
|
|
75
|
+
*
|
|
76
|
+
* ⚠️ ONE CALL, ONE TABLE, and that matters more than it looks. My first pass at
|
|
77
|
+
* `status` used three separate helpers, so the three groups measured their label
|
|
78
|
+
* columns independently and printed at three different widths — reproducing, in
|
|
79
|
+
* miniature, the exact defect this PR exists to remove. Anything meant to line up
|
|
80
|
+
* has to be measured together, which means it has to be ONE call.
|
|
81
|
+
*
|
|
82
|
+
* `state` is: true → ✓, false → ✗, 'warn' → !, null/undefined → no glyph at all.
|
|
83
|
+
* A blank glyph rather than a dot, because a bullet in front of "workspace" is
|
|
84
|
+
* decoration pretending to be information.
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ PLAIN GLYPH + STYLE FUNCTION, never mark.ok(). table() strips escapes in
|
|
87
|
+
* order to measure a cell, so a pre-coloured glyph goes in coloured and comes out
|
|
88
|
+
* plain — which is what happened the first time I wrote the doctor list, and it
|
|
89
|
+
* rendered with colourless ticks. Pad first, colour last, always.
|
|
90
|
+
*/
|
|
91
|
+
export function entries(list) {
|
|
92
|
+
const g = (state) => {
|
|
93
|
+
if (state === true) return [glyph.ok, c.green];
|
|
94
|
+
if (state === false) return [glyph.bad, c.red];
|
|
95
|
+
if (state === 'warn') return [glyph.warn, c.yellow];
|
|
96
|
+
return ['', undefined];
|
|
97
|
+
};
|
|
98
|
+
rows(list.map((e) => {
|
|
99
|
+
const [mk, mkStyle] = g(e.state);
|
|
100
|
+
return {
|
|
101
|
+
cells: [mk, e.label, e.value ?? ''],
|
|
102
|
+
style: [mkStyle, c.dim, e.dim ? c.dim : e.style],
|
|
103
|
+
};
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Check list — every row carries a pass/fail glyph. */
|
|
108
|
+
export function checks(list) {
|
|
109
|
+
entries(list.map((ch) => ({ label: ch.label, value: ch.note ?? '', state: ch.pass ?? false, dim: true })));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Plain `label value` rows, no glyph column content. */
|
|
113
|
+
export function fields(pairs) {
|
|
114
|
+
entries(pairs.map(([label, value, style]) => ({ label, value, state: null, style })));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { c, truncate, width, mark };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choose from a list without copying an id (t-631).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THE CLI KEPT ASKING FOR THINGS IT COULD OFFER. `mnema doc` with no argument
|
|
5
|
+
* printed "Usage: mnema doc <id>" — so the actual workflow was: run `mnema docs`,
|
|
6
|
+
* read a truncated title, select eight hex characters with the mouse, paste. Every
|
|
7
|
+
* id in this CLI is a UUID prefix that nobody can hold in their head, and the list
|
|
8
|
+
* command already knows all of them.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ RAW MODE IS THE DANGEROUS PART, and the danger is entirely in the exits. A
|
|
11
|
+
* process that leaves the terminal in raw mode with the cursor hidden hands back a
|
|
12
|
+
* shell where typing shows nothing and Ctrl-C does not work — the user's next move
|
|
13
|
+
* is to close the window. So there is exactly ONE restore path here, every exit
|
|
14
|
+
* goes through it, and it is also wired to SIGINT and to process exit. This is the
|
|
15
|
+
* single most damaging thing a terminal UI can get wrong, and it is worth more care
|
|
16
|
+
* than the feature itself.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { c, truncate, width } from './theme.mjs';
|
|
20
|
+
|
|
21
|
+
const HIDE = '\x1b[?25l';
|
|
22
|
+
const SHOW = '\x1b[?25h';
|
|
23
|
+
const CLEAR = '\x1b[K';
|
|
24
|
+
|
|
25
|
+
/** How many rows to show at once. Longer lists scroll within this window. */
|
|
26
|
+
const WINDOW = 10;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param items [{ label, hint }]
|
|
30
|
+
* @returns the chosen index, or null if cancelled / not interactive
|
|
31
|
+
*/
|
|
32
|
+
export function pick(items, { title = 'Select', stream = process.stderr, input = process.stdin } = {}) {
|
|
33
|
+
// Not interactive: there is nothing to drive the selection with. Returning null
|
|
34
|
+
// rather than hanging on a stdin that will never produce a keypress.
|
|
35
|
+
if (!items.length || !input.isTTY || !stream.isTTY || typeof input.setRawMode !== 'function') {
|
|
36
|
+
return Promise.resolve(null);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
let cursor = 0;
|
|
41
|
+
let top = 0;
|
|
42
|
+
let drawn = 0;
|
|
43
|
+
let finished = false;
|
|
44
|
+
|
|
45
|
+
const paint = () => {
|
|
46
|
+
// Move back over what we drew last time, clearing as we go.
|
|
47
|
+
if (drawn) stream.write(`\x1b[${drawn}A`);
|
|
48
|
+
const w = width();
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push(`${c.bold(title)} ${c.dim('↑↓ move · enter select · esc cancel')}`);
|
|
51
|
+
const view = items.slice(top, top + WINDOW);
|
|
52
|
+
for (let i = 0; i < view.length; i += 1) {
|
|
53
|
+
const idx = top + i;
|
|
54
|
+
const on = idx === cursor;
|
|
55
|
+
const label = truncate(view[i].label ?? '', w - 6);
|
|
56
|
+
// ⚠️ Colour AFTER truncation — the ordering rule from t-628. Truncating a
|
|
57
|
+
// coloured string cuts inside the escape and leaves the terminal stuck in
|
|
58
|
+
// whatever attribute was open.
|
|
59
|
+
lines.push(on ? `${c.cyan('❯')} ${c.bold(label)}` : ` ${c.dim(label)}`);
|
|
60
|
+
}
|
|
61
|
+
if (items.length > WINDOW) {
|
|
62
|
+
lines.push(c.dim(` ${cursor + 1}/${items.length}`));
|
|
63
|
+
}
|
|
64
|
+
stream.write(lines.map((l) => `${CLEAR}${l}`).join('\n') + '\n');
|
|
65
|
+
drawn = lines.length;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ⭐ ONE restore path. Every exit — enter, esc, Ctrl-C, an unexpected throw,
|
|
69
|
+
// the process dying — comes through here, and it is idempotent.
|
|
70
|
+
const restore = () => {
|
|
71
|
+
if (finished) return;
|
|
72
|
+
finished = true;
|
|
73
|
+
try { input.setRawMode(false); } catch { /* already closed */ }
|
|
74
|
+
input.pause();
|
|
75
|
+
input.removeListener('data', onData);
|
|
76
|
+
process.removeListener('SIGINT', onSigint);
|
|
77
|
+
process.removeListener('exit', restore);
|
|
78
|
+
if (drawn) stream.write(`\x1b[${drawn}A`);
|
|
79
|
+
for (let i = 0; i < drawn; i += 1) stream.write(`${CLEAR}\n`);
|
|
80
|
+
if (drawn) stream.write(`\x1b[${drawn}A`);
|
|
81
|
+
stream.write(SHOW);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function onSigint() {
|
|
85
|
+
restore();
|
|
86
|
+
// Exit the way an unhandled Ctrl-C would, so shells see the right status.
|
|
87
|
+
process.exit(130);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Named constants, because a literal control byte pasted into source is
|
|
91
|
+
// invisible in a diff and unreadable in a comparison.
|
|
92
|
+
const CTRL_C = '\u0003';
|
|
93
|
+
const CTRL_D = '\u0004';
|
|
94
|
+
const ESC = '\u001b';
|
|
95
|
+
const UP = '\u001b[A';
|
|
96
|
+
const DOWN = '\u001b[B';
|
|
97
|
+
const PGUP = '\u001b[5~';
|
|
98
|
+
const PGDN = '\u001b[6~';
|
|
99
|
+
|
|
100
|
+
function onData(buf) {
|
|
101
|
+
const s = buf.toString('utf8');
|
|
102
|
+
// ⚠️ In raw mode Ctrl-C is DATA, not a signal — nothing else handles it.
|
|
103
|
+
if (s === CTRL_C) { onSigint(); return; }
|
|
104
|
+
if (s === CTRL_D || s === ESC || s === 'q') { restore(); resolve(null); return; }
|
|
105
|
+
if (s === '\r' || s === '\n') { restore(); resolve(cursor); return; }
|
|
106
|
+
|
|
107
|
+
let moved = 0;
|
|
108
|
+
if (s === UP || s === 'k') moved = -1;
|
|
109
|
+
else if (s === DOWN || s === 'j') moved = 1;
|
|
110
|
+
else if (s === PGUP) moved = -WINDOW;
|
|
111
|
+
else if (s === PGDN) moved = WINDOW;
|
|
112
|
+
else if (s === 'g') { cursor = 0; top = 0; paint(); return; }
|
|
113
|
+
else if (s === 'G') { cursor = items.length - 1; top = Math.max(0, items.length - WINDOW); paint(); return; }
|
|
114
|
+
if (!moved) return;
|
|
115
|
+
|
|
116
|
+
cursor = Math.min(items.length - 1, Math.max(0, cursor + moved));
|
|
117
|
+
if (cursor < top) top = cursor;
|
|
118
|
+
if (cursor >= top + WINDOW) top = cursor - WINDOW + 1;
|
|
119
|
+
paint();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
stream.write(HIDE);
|
|
123
|
+
input.setRawMode(true);
|
|
124
|
+
input.resume();
|
|
125
|
+
input.setEncoding('utf8');
|
|
126
|
+
input.on('data', onData);
|
|
127
|
+
process.on('SIGINT', onSigint);
|
|
128
|
+
process.on('exit', restore);
|
|
129
|
+
paint();
|
|
130
|
+
});
|
|
131
|
+
}
|