@ajaykumarnpm/talea 0.1.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/prompt.js ADDED
@@ -0,0 +1,375 @@
1
+ // Interactive selection. No dependencies: raw stdin plus the same ANSI codes
2
+ // log.js already uses.
3
+ //
4
+ // The state machine (buildTree / toggle / groupState / selectedRepos) is pure
5
+ // and separate from the rendering, so the selection logic is testable without
6
+ // a terminal — which is the only reason it can be trusted at all.
7
+
8
+ import readline from 'node:readline/promises';
9
+
10
+ import { c, glyph } from './log.js';
11
+ import { repoGroup } from './config.js';
12
+
13
+ const ESC = '\u001b';
14
+ const CTRL_C = '\u0003';
15
+
16
+ /**
17
+ * A flat list of rows: each group followed by its repos. Flat rather than a
18
+ * nested structure because the thing being rendered is a list, and the thing
19
+ * being navigated is a list.
20
+ *
21
+ * `preselected` is either a boolean for every row, or a predicate — which is
22
+ * how the picker opens with your default set already ticked and everything
23
+ * else visible but off.
24
+ */
25
+ export function buildTree(manifest, repos = manifest.repos, preselected = true) {
26
+ const tick = typeof preselected === 'function' ? preselected : () => preselected;
27
+
28
+ const byGroup = new Map();
29
+ for (const repo of repos) {
30
+ const group = repoGroup(repo);
31
+ if (!byGroup.has(group)) byGroup.set(group, []);
32
+ byGroup.get(group).push(repo);
33
+ }
34
+
35
+ // The catalogue's group order first, then any group the catalogue does not
36
+ // name — a repo's owner stands in for a group it was never given, so a
37
+ // freshly discovered catalogue has a sensible tree with no curation at all.
38
+ const order = [
39
+ ...Object.keys(manifest.groups ?? {}),
40
+ ...[...byGroup.keys()].filter((g) => !(g in (manifest.groups ?? {}))).sort(),
41
+ ];
42
+
43
+ const rows = [];
44
+ for (const group of order) {
45
+ const members = byGroup.get(group);
46
+ if (!members?.length) continue;
47
+ rows.push({ kind: 'group', group, label: group, title: manifest.groups?.[group]?.title });
48
+ for (const repo of members) {
49
+ rows.push({ kind: 'repo', group, label: repo.name, repo, checked: Boolean(tick(repo)) });
50
+ }
51
+ }
52
+ return rows;
53
+ }
54
+
55
+ /** 'all' | 'some' | 'none' for a group, derived from its repos — never stored. */
56
+ export function groupState(rows, group) {
57
+ const members = rows.filter((r) => r.kind === 'repo' && r.group === group);
58
+ const on = members.filter((r) => r.checked).length;
59
+ if (on === 0) return 'none';
60
+ return on === members.length ? 'all' : 'some';
61
+ }
62
+
63
+ /**
64
+ * Toggle row `i`, in place.
65
+ *
66
+ * A group row toggles every repo under it — checking `V1` takes all of V1,
67
+ * which is what selecting a folder is expected to mean. A partially selected
68
+ * group fills up rather than emptying, because that is the direction someone
69
+ * pressing space on it almost always wants.
70
+ */
71
+ export function toggle(rows, i) {
72
+ const row = rows[i];
73
+ if (!row) return rows;
74
+
75
+ if (row.kind === 'repo') {
76
+ row.checked = !row.checked;
77
+ return rows;
78
+ }
79
+
80
+ const next = groupState(rows, row.group) !== 'all';
81
+ for (const r of rows) {
82
+ if (r.kind === 'repo' && r.group === row.group) r.checked = next;
83
+ }
84
+ return rows;
85
+ }
86
+
87
+ export function setAll(rows, checked) {
88
+ for (const r of rows) if (r.kind === 'repo') r.checked = checked;
89
+ return rows;
90
+ }
91
+
92
+ export const selectedRepos = (rows) =>
93
+ rows.filter((r) => r.kind === 'repo' && r.checked).map((r) => r.repo);
94
+
95
+ const GROUP_MARK = { all: '◼', some: '◐', none: '◻' };
96
+
97
+ /** Visible length, ignoring ANSI colour codes. */
98
+ const visible = (s) => s.replace(/\u001b\[[0-9;]*m/g, '').length;
99
+
100
+ /**
101
+ * Cut a line to `width` visible characters, keeping colour codes intact.
102
+ * Written by hand rather than by slicing the string, because a naive slice cuts
103
+ * an escape sequence in half and bleeds colour across the rest of the screen.
104
+ */
105
+ export function truncate(line, width) {
106
+ if (visible(line) <= width) return line;
107
+ let out = '';
108
+ let shown = 0;
109
+ let i = 0;
110
+ while (i < line.length && shown < width - 1) {
111
+ if (line[i] === '\u001b') {
112
+ const end = line.indexOf('m', i);
113
+ if (end === -1) break;
114
+ out += line.slice(i, end + 1);
115
+ i = end + 1;
116
+ continue;
117
+ }
118
+ out += line[i];
119
+ shown++;
120
+ i++;
121
+ }
122
+ return `${out}\u001b[0m…`;
123
+ }
124
+
125
+ /** One rendered line. Exported so a test can assert what the user sees. */
126
+ export function renderRow(rows, i, cursor) {
127
+ const row = rows[i];
128
+ const pointer = i === cursor ? c.cyan('❯') : ' ';
129
+
130
+ if (row.kind === 'group') {
131
+ const state = groupState(rows, row.group);
132
+ const box = state === 'none' ? c.grey(GROUP_MARK.none) : c.cyan(GROUP_MARK[state]);
133
+ return `${pointer} ${box} ${c.bold(row.label)} ${c.dim(row.title ?? '')}`;
134
+ }
135
+
136
+ const box = row.checked ? c.green('◼') : c.grey('◻');
137
+ return `${pointer} ${box} ${i === cursor ? c.bold(row.label) : row.label}`;
138
+ }
139
+
140
+ /**
141
+ * Split one stdin chunk into individual keypresses.
142
+ *
143
+ * stdin delivers whatever arrived since the last read, so fast typing, key
144
+ * repeat and paste all produce several keys in a single event — and an escape
145
+ * sequence like an arrow key is itself multiple bytes. Comparing a whole chunk
146
+ * against one key silently drops everything the user did, which is how this
147
+ * was first found: a pty test that fed five keys at once and hung forever.
148
+ */
149
+ export function splitKeys(chunk) {
150
+ const keys = [];
151
+ let i = 0;
152
+ while (i < chunk.length) {
153
+ if (chunk[i] === ESC && chunk[i + 1] === '[') {
154
+ // CSI sequence: ESC [ ... final byte in @-~
155
+ let j = i + 2;
156
+ while (j < chunk.length && !/[A-Za-z~]/.test(chunk[j])) j++;
157
+ keys.push(chunk.slice(i, j + 1));
158
+ i = j + 1;
159
+ } else if (chunk[i] === ESC && chunk[i + 1] === 'O') {
160
+ // Application cursor mode: ESC O A. Some terminals send arrows this way.
161
+ keys.push(chunk.slice(i, i + 3));
162
+ i += 3;
163
+ } else {
164
+ keys.push(chunk[i]);
165
+ i++;
166
+ }
167
+ }
168
+ return keys;
169
+ }
170
+
171
+ /** What a key did, so the caller knows whether to repaint or stop reading. */
172
+ export function applyKey(rows, key, cursor, pageSize) {
173
+ const clamp = (n) => Math.max(0, Math.min(rows.length - 1, n));
174
+
175
+ switch (key) {
176
+ case CTRL_C:
177
+ case ESC:
178
+ case 'q':
179
+ return { action: 'cancel', cursor };
180
+ case '\r':
181
+ case '\n':
182
+ return { action: 'confirm', cursor };
183
+ case ' ':
184
+ toggle(rows, cursor);
185
+ return { action: 'changed', cursor };
186
+ case 'a':
187
+ setAll(rows, true);
188
+ return { action: 'changed', cursor };
189
+ case 'n':
190
+ setAll(rows, false);
191
+ return { action: 'changed', cursor };
192
+ case `${ESC}[A`:
193
+ case `${ESC}OA`:
194
+ case 'k':
195
+ return { action: 'changed', cursor: clamp(cursor - 1) };
196
+ case `${ESC}[B`:
197
+ case `${ESC}OB`:
198
+ case 'j':
199
+ return { action: 'changed', cursor: clamp(cursor + 1) };
200
+ case `${ESC}[5~`:
201
+ return { action: 'changed', cursor: clamp(cursor - pageSize) };
202
+ case `${ESC}[6~`:
203
+ return { action: 'changed', cursor: clamp(cursor + pageSize) };
204
+ case `${ESC}[H`:
205
+ return { action: 'changed', cursor: 0 };
206
+ case `${ESC}[F`:
207
+ return { action: 'changed', cursor: rows.length - 1 };
208
+ default:
209
+ return { action: 'ignored', cursor };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The checkbox list. Resolves to the selected repos, or null if cancelled.
215
+ * The caller must have already confirmed this is a TTY.
216
+ */
217
+ export function pickRepos(rows, { title = 'Select what to clone' } = {}) {
218
+ return new Promise((resolve, reject) => {
219
+ const out = process.stdout;
220
+ let cursor = rows.findIndex((r) => r.kind === 'repo');
221
+ if (cursor < 0) cursor = 0;
222
+ let top = 0;
223
+ let painted = 0;
224
+ let done = false;
225
+
226
+ // Leave room for the title, the counter and the key hints.
227
+ const viewport = () => Math.max(5, (out.rows || 24) - 6);
228
+
229
+ const draw = () => {
230
+ const height = viewport();
231
+ if (cursor < top) top = cursor;
232
+ if (cursor >= top + height) top = cursor - height + 1;
233
+
234
+ const total = rows.filter((r) => r.kind === 'repo').length;
235
+ const lines = [
236
+ c.bold(title),
237
+ c.dim(`${selectedRepos(rows).length} of ${total} selected`),
238
+ '',
239
+ ];
240
+ for (let i = top; i < Math.min(rows.length, top + height); i++) {
241
+ lines.push(renderRow(rows, i, cursor));
242
+ }
243
+ const more = rows.length - (top + height);
244
+ lines.push('');
245
+ lines.push(
246
+ c.dim(`${glyph.up}${glyph.down} move space toggle a all n none enter ok q cancel`) +
247
+ (more > 0 ? c.cyan(` +${more} below`) : ''),
248
+ );
249
+
250
+ // Repaint in place: back up over what was drawn last time and clear down.
251
+ // Every line is clamped to the terminal width first — a line that wraps
252
+ // occupies two rows, `painted` would undercount, and the list smears.
253
+ const width = Math.max(20, out.columns || 80);
254
+ const clamped = lines.map((line) => truncate(line, width));
255
+ if (painted) out.write(`${ESC}[${painted}A${ESC}[0J`);
256
+ out.write(clamped.join('\n') + '\n');
257
+ painted = clamped.length;
258
+ };
259
+
260
+ // A process that exits while raw mode is on leaves the shell with no echo
261
+ // and no line editing, which looks like a hung terminal.
262
+ const restore = () => {
263
+ try {
264
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
265
+ } catch {
266
+ // Nothing useful to do while unwinding.
267
+ }
268
+ };
269
+ process.once('exit', restore);
270
+
271
+ const finish = (value) => {
272
+ done = true;
273
+ process.stdin.off('data', onData);
274
+ process.off('exit', restore);
275
+ restore();
276
+ process.stdin.pause();
277
+ if (painted) out.write(`${ESC}[${painted}A${ESC}[0J`);
278
+ resolve(value);
279
+ };
280
+
281
+ const onData = (chunk) => {
282
+ let dirty = false;
283
+ for (const key of splitKeys(chunk)) {
284
+ if (done) return;
285
+ const res = applyKey(rows, key, cursor, viewport());
286
+ cursor = res.cursor;
287
+ if (res.action === 'cancel') return finish(null);
288
+ if (res.action === 'confirm') return finish(selectedRepos(rows));
289
+ if (res.action === 'changed') dirty = true;
290
+ }
291
+ if (dirty) draw();
292
+ };
293
+
294
+ // Some Windows console hosts refuse raw mode outright. Surface that so the
295
+ // caller can fall back to typed names, rather than dying mid-prompt.
296
+ try {
297
+ process.stdin.setRawMode(true);
298
+ } catch (err) {
299
+ process.off('exit', restore);
300
+ reject(err);
301
+ return;
302
+ }
303
+ process.stdin.resume();
304
+ process.stdin.setEncoding('utf8');
305
+ process.stdin.on('data', onData);
306
+ draw();
307
+ });
308
+ }
309
+
310
+ /**
311
+ * The opening question. Cloning everything is the default because a workspace
312
+ * with repos missing is one where cross-repo search quietly lies to you — but
313
+ * disk is disk, so choosing is one keypress away.
314
+ */
315
+ export async function askScope({ defaults, total, groups }) {
316
+ console.log(`\n${c.bold('This machine has not chosen what it keeps.')}`);
317
+ console.log(
318
+ ` ${c.cyan('1')} the default set — ${defaults} repo${defaults === 1 ? '' : 's'} ${c.dim('(default)')}`,
319
+ );
320
+ console.log(
321
+ ` ${c.cyan('2')} choose from all ${total} across ${groups} owner${groups === 1 ? '' : 's'}`,
322
+ );
323
+
324
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
325
+ const answer = (await rl.question(`\n${c.dim('[1/2]')} `)).trim();
326
+ rl.close();
327
+ return answer === '2' || /^c(hoose)?$/i.test(answer) ? 'choose' : 'defaults';
328
+ }
329
+
330
+ /**
331
+ * Selection without raw mode: a numbered list read as one line. Used when the
332
+ * terminal cannot do raw mode — a real possibility on Windows consoles, which
333
+ * this project has not been able to test.
334
+ */
335
+ export async function pickByLine(rows) {
336
+ const groups = [...new Set(rows.map((r) => r.group))];
337
+ console.log(`\n${c.bold('Groups')}`);
338
+ for (const g of groups) {
339
+ const members = rows.filter((r) => r.kind === 'repo' && r.group === g);
340
+ console.log(` ${c.cyan(g.padEnd(10))} ${c.dim(`${members.length} repos`)}`);
341
+ }
342
+ console.log(c.dim('\nEnter group and/or repo names, comma separated. Empty = everything.'));
343
+
344
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
345
+ const answer = (await rl.question('\n> ')).trim();
346
+ rl.close();
347
+
348
+ if (!answer) return { picked: selectedRepos(setAll(rows, true)), unknown: [] };
349
+ return applyNames(rows, answer.split(','));
350
+ }
351
+
352
+ /**
353
+ * Resolve typed group/repo names against the tree. Unknown names are returned
354
+ * rather than ignored — a typo that silently clones nothing is the failure
355
+ * mode this project treats as worse than stopping.
356
+ */
357
+ export function applyNames(rows, names) {
358
+ const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
359
+ setAll(rows, false);
360
+
361
+ for (const r of rows) {
362
+ if (r.kind !== 'repo') continue;
363
+ if (wanted.has(r.group.toLowerCase()) || wanted.has(r.label.toLowerCase())) r.checked = true;
364
+ }
365
+
366
+ const known = new Set();
367
+ for (const r of rows) {
368
+ known.add(r.group.toLowerCase());
369
+ if (r.kind === 'repo') known.add(r.label.toLowerCase());
370
+ }
371
+ return {
372
+ picked: selectedRepos(rows),
373
+ unknown: [...wanted].filter((w) => !known.has(w)),
374
+ };
375
+ }
package/src/select.js ADDED
@@ -0,0 +1,97 @@
1
+ // "What does this machine keep?" — asked once, remembered forever.
2
+ //
3
+ // The answer lives in `.talea.json` as a list of repo names. Until it exists,
4
+ // the catalogue's `default: true` repos stand in, and the first interactive
5
+ // `sync` or `init` offers the picker with exactly those ticked.
6
+ //
7
+ // Why a list of names rather than a filter: a filter re-evaluates. Adding
8
+ // `default: true` to a repo in the catalogue would silently start cloning it on
9
+ // every machine you own, which is not a decision the catalogue gets to make.
10
+
11
+ import { saveState } from './config.js';
12
+ import { c, fail, plain, warn } from './log.js';
13
+ import { askScope, buildTree, pickByLine, pickRepos, selectedRepos } from './prompt.js';
14
+ import { hasChosen, machineRepos } from './workspace.js';
15
+
16
+ /**
17
+ * Show the checklist and return what was ticked, or null if cancelled.
18
+ *
19
+ * The full-screen list needs raw mode. `setRawMode` always *exists* on a TTY
20
+ * stream, so the failure to plan for is it THROWING — some Windows console
21
+ * hosts raise ERR_TTY_INIT_FAILED. Fall back to typing names rather than
22
+ * letting that escape and taking a default nobody chose.
23
+ */
24
+ export async function runPicker(rows, title) {
25
+ try {
26
+ return await pickRepos(rows, { title });
27
+ } catch (err) {
28
+ warn(`This terminal could not enter raw mode (${err.code ?? err.message}).`);
29
+ const { picked, unknown } = await pickByLine(rows);
30
+ if (unknown.length) {
31
+ fail(`Unknown name${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
32
+ process.exit(1);
33
+ }
34
+ return picked;
35
+ }
36
+ }
37
+
38
+ const interactive = () => process.stdin.isTTY && process.stdout.isTTY;
39
+
40
+ /**
41
+ * The repos this run is about, asking the machine's owner if nobody has.
42
+ *
43
+ * `--pick` re-opens the checklist with the current selection ticked, which is
44
+ * how you change your mind without editing JSON.
45
+ *
46
+ * An explicit -g/-r never prompts, so scripts and CI behave the same on a fresh
47
+ * machine as on an old one.
48
+ */
49
+ export async function chooseRepos({ manifest, root, state, opts = {} }) {
50
+ const already = machineRepos(manifest, state);
51
+
52
+ if (opts.group || opts.repo) return { repos: already, asked: false };
53
+
54
+ const mustAsk = opts.pick || !hasChosen(state);
55
+ if (!mustAsk) return { repos: already, asked: false };
56
+
57
+ if (!interactive()) {
58
+ // Nobody to ask. The defaults are a sane answer and saying which one was
59
+ // taken is better than silently picking it.
60
+ if (!hasChosen(state)) {
61
+ plain(c.dim(` Not a terminal — taking the catalogue's default set (${already.length} repos).`));
62
+ }
63
+ return { repos: already, asked: false };
64
+ }
65
+
66
+ const groups = new Set(manifest.repos.map((r) => r.group ?? r.owner ?? 'repos'));
67
+ const chosenNames = new Set(already.map((r) => r.name));
68
+
69
+ if (!opts.pick) {
70
+ const scope = await askScope({
71
+ defaults: already.length,
72
+ total: manifest.repos.length,
73
+ groups: groups.size,
74
+ });
75
+ if (scope === 'defaults') {
76
+ saveState(root, { ...state, selected: already.map((r) => r.name) });
77
+ return { repos: already, asked: true };
78
+ }
79
+ }
80
+
81
+ // Everything is listed, including the archived and the forks. The point of
82
+ // the checklist is that this machine can take something the default set does
83
+ // not have — a repo you only touch on the desktop should not need a catalogue
84
+ // edit to reach.
85
+ const rows = buildTree(manifest, manifest.repos, (repo) => chosenNames.has(repo.name));
86
+ const picked = await runPicker(rows, 'What should this machine keep?');
87
+
88
+ if (picked === null) {
89
+ plain(c.dim('\nCancelled — nothing changed.'));
90
+ process.exit(0);
91
+ }
92
+
93
+ saveState(root, { ...state, selected: picked.map((r) => r.name) });
94
+ return { repos: picked, asked: true };
95
+ }
96
+
97
+ export { selectedRepos };
package/src/theme.js ADDED
@@ -0,0 +1,138 @@
1
+ // The look of every byte this CLI prints.
2
+ //
3
+ // Colours, glyphs, spinner frames, box-drawing and the width maths that keeps
4
+ // columns aligned once colour codes are in the string. Nothing here knows what
5
+ // a repo or a branch is — `log.js` and `live.js` compose these into output, and
6
+ // commands use those. Re-skin the whole tool by editing this file alone.
7
+ //
8
+ // Zero dependencies: raw ANSI escapes, same as the rest of the project.
9
+
10
+ // ── Gating ─────────────────────────────────────────────────────
11
+ // NO_COLOR disables colour. FORCE_COLOR forces it on, which is how the tests
12
+ // and `talea ... | less -R` get colour out of a pipe. Otherwise colour follows
13
+ // TTY detection, so piping into a file or CI log stays free of escape codes.
14
+ export const useColor =
15
+ process.env.NO_COLOR || process.env.TERM === 'dumb'
16
+ ? false
17
+ : process.env.FORCE_COLOR
18
+ ? true
19
+ : !!process.stdout.isTTY;
20
+
21
+ // 24-bit colour is not universal — Apple Terminal, tmux without `-2` and the
22
+ // older Windows consoles all lie about it or drop the escape. COLORTERM is the
23
+ // only signal that is actually reliable, so the palette carries a basic-16
24
+ // fallback and uses it whenever COLORTERM is not set.
25
+ const trueColor = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? '');
26
+
27
+ // ── Phosphor palette ───────────────────────────────────────────
28
+ // [24-bit hex, basic-16 SGR code]. The basic code is what a terminal without
29
+ // COLORTERM gets; it is deliberately the closest *readable* match rather than
30
+ // the closest numerically — `faint` has no bright equivalent, so it falls back
31
+ // to grey rather than a green nobody can read on a light background.
32
+ const PALETTE = {
33
+ green: ['#39ff14', 92], // hot phosphor — something worked
34
+ aged: ['#1f8a3b', 32], // settled green — a branch, a path, a detail
35
+ faint: ['#0e3b1c', 90], // barely lit — rules and fills
36
+ amber: ['#ffb000', 33], // attention, but not a failure
37
+ red: ['#ff3b30', 31], // failure
38
+ bone: ['#d6dbd6', 37], // plain text that still wants to be lit
39
+ };
40
+
41
+
42
+ const hexToRgb = (hex) => [
43
+ parseInt(hex.slice(1, 3), 16),
44
+ parseInt(hex.slice(3, 5), 16),
45
+ parseInt(hex.slice(5, 7), 16),
46
+ ];
47
+
48
+ /** Wrap `str` in one of the palette colours. A no-op when colour is off. */
49
+ function tint(name, str) {
50
+ if (!useColor) return String(str);
51
+ const [hex, basic] = PALETTE[name];
52
+ // Both branches close with 39 — "default foreground" — and never with 0.
53
+ // A full reset also clears bold and dim, so a colour nested inside `dim()`
54
+ // would un-dim the rest of the line on a truecolor terminal and not on a
55
+ // basic one: a rendering difference gated on COLORTERM, which is the worst
56
+ // kind to reproduce.
57
+ if (!trueColor) return `\x1b[${basic}m${str}\x1b[39m`;
58
+ const [r, g, b] = hexToRgb(hex);
59
+ return `\x1b[38;2;${r};${g};${b}m${str}\x1b[39m`;
60
+ }
61
+
62
+ export const bold = (s) => (useColor ? `\x1b[1m${s}\x1b[22m` : String(s));
63
+ export const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[22m` : String(s));
64
+
65
+ /** The palette, by role. Everything that prints colour goes through here. */
66
+ export const paint = {
67
+ ok: (s) => tint('green', s),
68
+ aged: (s) => tint('aged', s),
69
+ faint: (s) => tint('faint', s),
70
+ warn: (s) => tint('amber', s),
71
+ fail: (s) => tint('red', s),
72
+ bone: (s) => tint('bone', s),
73
+ bold,
74
+ dim,
75
+ };
76
+
77
+ // ── Glyphs ─────────────────────────────────────────────────────
78
+ // All BMP, all present in the fonts people actually run terminals in. Keep it
79
+ // that way — a dingbat that renders as a box wrecks every column after it.
80
+ export const glyph = {
81
+ ok: '▣',
82
+ skip: '◌',
83
+ fail: '▤',
84
+ warn: '!',
85
+ pending: '·',
86
+ arrow: '→',
87
+ up: '↑',
88
+ down: '↓',
89
+ maybe: '~',
90
+ groupMark: '◇',
91
+ rule: '─',
92
+ prompt: '==>',
93
+ };
94
+
95
+ /** Frames for work that is genuinely in flight. */
96
+ export const spinner = ['◜', '◠', '◝', '◞', '◡', '◟'];
97
+
98
+ export const box = { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' };
99
+
100
+ // ── Low-level ANSI, for the live block ─────────────────────────
101
+ export const ansi = {
102
+ hideCursor: '\x1b[?25l',
103
+ showCursor: '\x1b[?25h',
104
+ clearLine: '\x1b[2K',
105
+ cr: '\r',
106
+ up: (n = 1) => (n > 0 ? `\x1b[${n}A` : ''),
107
+ };
108
+
109
+ // ── Width maths ────────────────────────────────────────────────
110
+ // Every pad and centre in the tool goes through these. Measuring a coloured
111
+ // string with `.length` counts the escape codes and is the classic way to
112
+ // break a table one release after it was written.
113
+
114
+ // eslint-disable-next-line no-control-regex
115
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
116
+
117
+ export const stripAnsi = (s) => String(s).replace(ANSI_RE, '');
118
+ export const visibleWidth = (s) => [...stripAnsi(s)].length;
119
+
120
+ export function padEndVisible(s, width) {
121
+ const pad = width - visibleWidth(s);
122
+ return pad > 0 ? s + ' '.repeat(pad) : String(s);
123
+ }
124
+
125
+ export function padStartVisible(s, width) {
126
+ const pad = width - visibleWidth(s);
127
+ return pad > 0 ? ' '.repeat(pad) + s : String(s);
128
+ }
129
+
130
+ export function centerVisible(s, width) {
131
+ const pad = width - visibleWidth(s);
132
+ if (pad <= 0) return String(s);
133
+ const left = Math.floor(pad / 2);
134
+ return ' '.repeat(left) + s + ' '.repeat(pad - left);
135
+ }
136
+
137
+ /** Terminal width, clamped to something a human can read across. */
138
+ export const columns = () => Math.max(40, Math.min(process.stdout.columns || 80, 120));