@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/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
+ }
@@ -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 { c, truncate } from './util.mjs';
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
- // ⚠️ The old local helper measured `.length` — UTF-16 code units — so a CJK title
39
- // or an emoji made the column visibly ragged. truncate() from the theme measures
40
- // terminal columns instead.
41
- const trunc = (s, n) => truncate(s ?? '', n);
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 = Number(flags.limit) || 20;
53
+ const limit = limitOf(flags, 20);
47
54
  try {
48
- const rows = await call(ctx, (m) => m.tasks.list({
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
- }).all());
53
- emit(flags, rows, () => {
54
- if (!rows.length) return console.log(c.dim('No tasks match.'));
55
- console.log(c.bold(`${rows.length} task(s)`));
56
- for (const t of rows) {
57
- const id = (t.publicId || t.id.slice(0, 8)).padEnd(8);
58
- console.log(` ${c.cyan(id)} ${String(t.status).padEnd(12)} ${c.dim(String(t.priority).padEnd(8))} ${trunc(t.title, 70)}`);
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
- if (!t) return console.log(c.dim('Nothing queued — the board is clear.'));
70
- console.log(`${c.bold(t.publicId || t.id)} ${t.title}`);
71
- if (t.description) console.log(c.dim(trunc(t.description.replace(/\s+/g, ' '), 300)));
72
- // The branch name is the load-bearing part of this project's dev loop, so
73
- // the CLI hands it over ready to paste rather than making it be derived.
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.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '');
76
- console.log(`\n ${c.dim('branch:')} git checkout -b ${t.publicId}${slug ? `-${slug}` : ''}`);
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 = Number(flags.limit) || 20;
110
+ const limit = limitOf(flags, 20);
86
111
  try {
87
- const rows = await call(ctx, (m) => m.docs.list({ limit }).all());
88
- emit(flags, rows, () => {
89
- if (!rows.length) return console.log(c.dim('No docs.'));
90
- console.log(c.bold(`${rows.length} doc(s)`));
91
- for (const d of rows) {
92
- console.log(` ${c.dim(d.id.slice(0, 8))} ${trunc(d.title, 60).padEnd(60)} ${c.dim(String(d.updatedAt).slice(0, 10))}`);
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
- const id = rest[0];
101
- if (!id) { console.error(c.red('Usage: mnema doc <id>')); process.exit(1); }
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
- // Markdown goes to stdout unadorned so `mnema doc <id> > file.md` works and
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 rows = await call(ctx, (m) => m.projects.list().all());
119
- emit(flags, rows, () => {
120
- if (!rows.length) return console.log(c.dim('No projects.'));
121
- console.log(c.bold(`${rows.length} project(s)`));
122
- for (const p of rows) console.log(` ${c.dim(p.id.slice(0, 8))} ${trunc(p.name, 50)}`);
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
- const hidden = a.sources.length - shown.length;
151
- console.log(c.dim(`\nsources (${a.sources.length}):`));
152
- for (const s of shown) console.log(c.dim(` ${trunc(s.title, 70)}`));
153
- if (hidden > 0) console.log(c.dim(` … ${hidden} more --json for all`));
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 is an ANSWER, not a failure — say so plainly rather
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) return console.log(c.yellow(`No route between "${from}" and "${to}".`));
170
- console.log(c.bold(`${g.hopCount} hop(s) from "${from}" to "${to}"`));
171
- for (const id of g.path) {
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
- for (const n of g.nodes.slice(0, CAP)) console.log(` ${trunc(n.label, 60).padEnd(60)} ${c.dim(n.type ?? '')}`);
182
- if (g.nodes.length > CAP) console.log(c.dim(` ${g.nodes.length - CAP} more — --json for all`));
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
- console.log(c.bold('Pulse'));
195
- console.log(` ${p.activeProjects} active project(s) · ${p.problemsInActiveProjects} problem(s) · $${p.cost7dUsd} last 7d`);
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('\n The findings engine has never run for this workspace.'));
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(c.bold(`\nFindings (${b.findings.length})`));
204
- if (!b.findings.length) console.log(c.dim(' nothing surfaced'));
205
- for (const f of b.findings) {
206
- const count = f.grouped ? c.dim(` ×${f.count}`) : '';
207
- console.log(` ${c.dim(String(f.kind).padEnd(14))} ${trunc(f.headline, 90)}${count}`);
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
+ }