agentic-workflow-manager 2.1.0 → 2.1.1
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/dist/src/index.js +130 -17
- package/dist/src/ui/picker-view.js +77 -0
- package/dist/src/ui/picker.js +194 -0
- package/dist/src/ui/text.js +75 -0
- package/dist/src/ui/tty.js +15 -0
- package/dist/src/utils/registry-view.js +35 -21
- package/dist/tests/ui/picker-reducer.test.js +92 -0
- package/dist/tests/ui/picker-shell.test.js +59 -0
- package/dist/tests/ui/picker-view.test.js +57 -0
- package/dist/tests/ui/text.test.js +55 -0
- package/dist/tests/ui/tty.test.js +39 -0
- package/dist/tests/utils/registry-view.test.js +39 -36
- package/package.json +1 -1
package/dist/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@ const commander_1 = require("commander");
|
|
|
9
9
|
const config_1 = require("./utils/config");
|
|
10
10
|
const grouping_1 = require("./utils/grouping");
|
|
11
11
|
const registry_view_1 = require("./utils/registry-view");
|
|
12
|
+
const tty_1 = require("./ui/tty");
|
|
13
|
+
const picker_1 = require("./ui/picker");
|
|
12
14
|
const providers_1 = require("./providers");
|
|
13
15
|
const executor_1 = require("./core/executor");
|
|
14
16
|
const regenerate_1 = require("./core/context/regenerate");
|
|
@@ -73,6 +75,7 @@ program.command('add [name]')
|
|
|
73
75
|
.option('-s, --scope <scope>', 'Scope: local or global')
|
|
74
76
|
.option('-m, --method <method>', 'Install method: symlink or copy')
|
|
75
77
|
.option('-y, --yes', 'Skip confirmation prompts')
|
|
78
|
+
.option('--all', 'install all artifacts from all packages without prompting')
|
|
76
79
|
.action(async (name, options) => {
|
|
77
80
|
(0, prompts_1.intro)(picocolors_1.default.bgCyan(picocolors_1.default.black(' AWM - Agentic Workflow Manager ')));
|
|
78
81
|
// 1. Sync the registry
|
|
@@ -142,6 +145,10 @@ program.command('add [name]')
|
|
|
142
145
|
(0, prompts_1.outro)(`✅ Installed bundle ${picocolors_1.default.cyan(matchedBundle.name)}:\n ${lines}${recordNote}`);
|
|
143
146
|
return;
|
|
144
147
|
}
|
|
148
|
+
// name given but no matching bundle found — report clearly regardless of TTY
|
|
149
|
+
console.error(picocolors_1.default.red(`Bundle "${name}" not found in registry.`));
|
|
150
|
+
console.error(picocolors_1.default.dim('Run `awm list` to see available packages.'));
|
|
151
|
+
process.exit(1);
|
|
145
152
|
}
|
|
146
153
|
// 2. Discover artifacts
|
|
147
154
|
const skills = (0, discovery_1.discoverSkills)();
|
|
@@ -152,6 +159,103 @@ program.command('add [name]')
|
|
|
152
159
|
process.exit(0);
|
|
153
160
|
}
|
|
154
161
|
const prefs = (0, config_1.getPreferences)();
|
|
162
|
+
// --all: install everything from every package headlessly
|
|
163
|
+
if (options.all) {
|
|
164
|
+
let targetAgents;
|
|
165
|
+
if (options.agent) {
|
|
166
|
+
const validAgents = Object.keys(providers_1.PROVIDERS);
|
|
167
|
+
const parsed = options.agent.split(',').map((a) => a.trim());
|
|
168
|
+
for (const a of parsed) {
|
|
169
|
+
if (!validAgents.includes(a)) {
|
|
170
|
+
console.error(picocolors_1.default.red(`Invalid agent "${a}". Use: ${validAgents.join(', ')}.`));
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
targetAgents = parsed;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
targetAgents = [prefs.defaultAgent];
|
|
178
|
+
}
|
|
179
|
+
let scopeVal;
|
|
180
|
+
if (options.scope) {
|
|
181
|
+
if (!['local', 'global'].includes(options.scope)) {
|
|
182
|
+
console.error(picocolors_1.default.red(`Invalid scope "${options.scope}". Use: local or global.`));
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
scopeVal = options.scope;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
scopeVal = prefs.defaultScope;
|
|
189
|
+
}
|
|
190
|
+
let methodVal;
|
|
191
|
+
if (options.method) {
|
|
192
|
+
if (!['symlink', 'copy'].includes(options.method)) {
|
|
193
|
+
console.error(picocolors_1.default.red(`Invalid method "${options.method}". Use: symlink or copy.`));
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
methodVal = options.method;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
methodVal = scopeVal === 'local' ? 'copy' : 'symlink';
|
|
200
|
+
}
|
|
201
|
+
const includeWorkflows = targetAgents.some((a) => providers_1.PROVIDERS[a].workflow !== null);
|
|
202
|
+
const includeAgents = targetAgents.some((a) => providers_1.PROVIDERS[a].agent !== null);
|
|
203
|
+
const allView = (0, registry_view_1.buildPackageView)(skills, includeWorkflows ? workflows : [], includeAgents ? agents : [], (0, bundles_1.discoverAllBundles)());
|
|
204
|
+
const allDedup = new Map();
|
|
205
|
+
for (const pkg of allView) {
|
|
206
|
+
for (const a of pkg.artifacts) {
|
|
207
|
+
allDedup.set((0, registry_view_1.artifactValue)(a), a);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const artifactsToInstall = Array.from(allDedup.values()).map((a) => ({
|
|
211
|
+
name: a.installName,
|
|
212
|
+
sourcePath: a.sourcePath,
|
|
213
|
+
type: a.type,
|
|
214
|
+
}));
|
|
215
|
+
if (artifactsToInstall.length === 0) {
|
|
216
|
+
(0, prompts_1.outro)(picocolors_1.default.yellow('No artifacts available.'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const installSpinner = (0, prompts_1.spinner)();
|
|
220
|
+
installSpinner.start('Installing artifacts...');
|
|
221
|
+
try {
|
|
222
|
+
const installed = [];
|
|
223
|
+
const skipped = [];
|
|
224
|
+
for (const currentAgent of targetAgents) {
|
|
225
|
+
for (const artifact of artifactsToInstall) {
|
|
226
|
+
if (providers_1.PROVIDERS[currentAgent][artifact.type] === null) {
|
|
227
|
+
skipped.push(`${artifact.name} (${currentAgent})`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const targetDir = (0, providers_1.getTargetPath)(artifact.type, currentAgent, scopeVal);
|
|
231
|
+
const finalDest = path_1.default.join(targetDir, artifact.name);
|
|
232
|
+
(0, executor_1.installArtifact)(artifact.sourcePath, finalDest, methodVal);
|
|
233
|
+
installed.push(`${artifact.name} → ${currentAgent} (${scopeVal})`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
(0, config_1.savePreferences)({ defaultAgent: targetAgents[0], defaultScope: scopeVal, installMethod: methodVal });
|
|
237
|
+
installSpinner.stop('Installation complete!');
|
|
238
|
+
if (skipped.length > 0) {
|
|
239
|
+
for (const sk of skipped) {
|
|
240
|
+
console.log(picocolors_1.default.yellow(` ⚠️ Skipped: ${sk} (not supported by target agent)`));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const names = installed.map((n) => picocolors_1.default.green(n)).join('\n ');
|
|
244
|
+
(0, prompts_1.outro)(`✅ Installed:\n ${names}`);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
installSpinner.stop('Installation failed.');
|
|
248
|
+
console.error(picocolors_1.default.red(e.message));
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Interactive selection requires a TTY. Bundle-by-name (handled above) works headless.
|
|
254
|
+
if (!(0, tty_1.isInteractive)()) {
|
|
255
|
+
console.error(picocolors_1.default.red('`awm add` needs an interactive terminal for selection.'));
|
|
256
|
+
console.error(picocolors_1.default.dim('Install a bundle non-interactively: `awm add <bundle>` (optionally with --agent/--scope).'));
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
155
259
|
// 3. Agent & Scope Prompts (Moved up)
|
|
156
260
|
let targetAgents;
|
|
157
261
|
if (options.agent) {
|
|
@@ -207,27 +311,35 @@ program.command('add [name]')
|
|
|
207
311
|
process.exit(0);
|
|
208
312
|
}
|
|
209
313
|
// 5. Level 1 — pick package(s)
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
required: true
|
|
314
|
+
const pickedPackages = await (0, picker_1.multiselectPicker)({
|
|
315
|
+
title: 'Select package(s)',
|
|
316
|
+
items: (0, registry_view_1.packagePickerItems)(view),
|
|
214
317
|
});
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
.
|
|
318
|
+
if (pickedPackages === null) {
|
|
319
|
+
(0, prompts_1.outro)('Operation cancelled.');
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
if (pickedPackages.length === 0) {
|
|
323
|
+
(0, prompts_1.outro)(picocolors_1.default.yellow('No packages selected.'));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const selectedPackages = pickedPackages
|
|
327
|
+
.map((name) => view.find((p) => p.name === name))
|
|
218
328
|
.filter(Boolean);
|
|
219
329
|
// 5b. Level 2 — pick skills within each package, in sequence
|
|
220
330
|
const dedup = new Map();
|
|
221
331
|
for (let i = 0; i < selectedPackages.length; i++) {
|
|
222
332
|
const pkg = selectedPackages[i];
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
required: true
|
|
333
|
+
const picked = await (0, picker_1.multiselectPicker)({
|
|
334
|
+
title: `[${i + 1}/${selectedPackages.length}] ${pkg.name} — select artifacts`,
|
|
335
|
+
items: (0, registry_view_1.artifactPickerItems)(pkg),
|
|
336
|
+
initialSelected: [registry_view_1.ALL_SENTINEL, ...pkg.artifacts.map((a) => (0, registry_view_1.artifactValue)(a))],
|
|
228
337
|
});
|
|
229
|
-
|
|
230
|
-
|
|
338
|
+
if (picked === null) {
|
|
339
|
+
(0, prompts_1.outro)('Operation cancelled.');
|
|
340
|
+
process.exit(0);
|
|
341
|
+
}
|
|
342
|
+
for (const a of (0, registry_view_1.resolveLevel2Selection)(pkg, picked)) {
|
|
231
343
|
dedup.set((0, registry_view_1.artifactValue)(a), a);
|
|
232
344
|
}
|
|
233
345
|
}
|
|
@@ -469,6 +581,7 @@ program.command('list [package]')
|
|
|
469
581
|
if (packageName && options.all) {
|
|
470
582
|
console.log(picocolors_1.default.dim('(Ignoring --all when a package name is provided.)'));
|
|
471
583
|
}
|
|
584
|
+
const listWidth = process.stdout.isTTY ? process.stdout.columns : undefined;
|
|
472
585
|
// Detail for a single package.
|
|
473
586
|
if (packageName) {
|
|
474
587
|
const { match, suggestion } = (0, registry_view_1.findPackage)(fullView, packageName);
|
|
@@ -480,7 +593,7 @@ program.command('list [package]')
|
|
|
480
593
|
process.exit(1);
|
|
481
594
|
}
|
|
482
595
|
console.log();
|
|
483
|
-
for (const line of (0, registry_view_1.packageDetailLines)(match))
|
|
596
|
+
for (const line of (0, registry_view_1.packageDetailLines)(match, listWidth))
|
|
484
597
|
console.log(line);
|
|
485
598
|
console.log();
|
|
486
599
|
(0, prompts_1.outro)(`Run ${picocolors_1.default.green(`awm add`)} to install artifacts from ${picocolors_1.default.cyan(match.name)}.`);
|
|
@@ -490,7 +603,7 @@ program.command('list [package]')
|
|
|
490
603
|
if (options.all) {
|
|
491
604
|
for (const pkg of view) {
|
|
492
605
|
console.log();
|
|
493
|
-
for (const line of (0, registry_view_1.packageDetailLines)(pkg))
|
|
606
|
+
for (const line of (0, registry_view_1.packageDetailLines)(pkg, listWidth))
|
|
494
607
|
console.log(line);
|
|
495
608
|
}
|
|
496
609
|
console.log();
|
|
@@ -499,7 +612,7 @@ program.command('list [package]')
|
|
|
499
612
|
}
|
|
500
613
|
// Default: compact summary.
|
|
501
614
|
console.log();
|
|
502
|
-
for (const line of (0, registry_view_1.packageSummaryLines)(view))
|
|
615
|
+
for (const line of (0, registry_view_1.packageSummaryLines)(view, listWidth))
|
|
503
616
|
console.log(line);
|
|
504
617
|
console.log();
|
|
505
618
|
console.log(picocolors_1.default.dim(` awm list <pkg> · awm list --all`));
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TWO_PANE_MIN_COLUMNS = void 0;
|
|
7
|
+
exports.visibleItems = visibleItems;
|
|
8
|
+
exports.renderPicker = renderPicker;
|
|
9
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
10
|
+
const text_1 = require("./text");
|
|
11
|
+
exports.TWO_PANE_MIN_COLUMNS = 72;
|
|
12
|
+
const FOOTER = '↑↓ move · space select · tab all · ⏎ confirm · esc clear';
|
|
13
|
+
function visibleItems(state) {
|
|
14
|
+
if (!state.filter)
|
|
15
|
+
return state.items;
|
|
16
|
+
const q = state.filter.toLowerCase();
|
|
17
|
+
return state.items.filter((i) => i.label.toLowerCase().includes(q));
|
|
18
|
+
}
|
|
19
|
+
/** Compute a scroll window [start, end) around the cursor that fits `height` rows. */
|
|
20
|
+
function windowFor(cursor, total, height) {
|
|
21
|
+
if (total <= height)
|
|
22
|
+
return [0, total];
|
|
23
|
+
let start = cursor - Math.floor(height / 2);
|
|
24
|
+
start = Math.max(0, Math.min(start, total - height));
|
|
25
|
+
return [start, start + height];
|
|
26
|
+
}
|
|
27
|
+
function checkbox(selected) {
|
|
28
|
+
return selected ? picocolors_1.default.green('◼') : '◻';
|
|
29
|
+
}
|
|
30
|
+
function renderPicker(state, vp) {
|
|
31
|
+
const vis = visibleItems(state);
|
|
32
|
+
const cursor = Math.max(0, Math.min(state.cursor, vis.length - 1));
|
|
33
|
+
const selCount = state.selected.size;
|
|
34
|
+
const filterPart = state.filter ? ` filter: ${state.filter}` : '';
|
|
35
|
+
const header = picocolors_1.default.bold(state.title) + filterPart + (selCount ? picocolors_1.default.dim(` ${selCount} sel`) : '');
|
|
36
|
+
const out = [header];
|
|
37
|
+
if (vis.length === 0) {
|
|
38
|
+
out.push(picocolors_1.default.dim(' (no matches)'));
|
|
39
|
+
out.push(FOOTER);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
const bodyHeight = Math.max(3, vp.rows - 4); // header + footer + breathing room
|
|
43
|
+
const [start, end] = windowFor(cursor, vis.length, bodyHeight);
|
|
44
|
+
const twoPane = vp.columns >= exports.TWO_PANE_MIN_COLUMNS;
|
|
45
|
+
const listWidth = twoPane ? Math.floor(vp.columns * 0.5) : vp.columns - 2;
|
|
46
|
+
const rows = [];
|
|
47
|
+
for (let i = start; i < end; i++) {
|
|
48
|
+
const item = vis[i];
|
|
49
|
+
const isCursor = i === cursor;
|
|
50
|
+
const marker = isCursor ? picocolors_1.default.cyan('❯') : ' ';
|
|
51
|
+
const box = checkbox(state.selected.has(item.value));
|
|
52
|
+
const labelWidth = listWidth - 5; // wide cursor marker (2 cells) + space + checkbox + space
|
|
53
|
+
const label = (0, text_1.truncate)(item.label, labelWidth);
|
|
54
|
+
rows.push(`${marker} ${box} ${label}`);
|
|
55
|
+
}
|
|
56
|
+
if (twoPane) {
|
|
57
|
+
const detailWidth = vp.columns - listWidth - 3; // gutter " │ "
|
|
58
|
+
const detail = (0, text_1.wrap)(vis[cursor].description, detailWidth).slice(0, rows.length);
|
|
59
|
+
const height = Math.max(rows.length, detail.length);
|
|
60
|
+
for (let i = 0; i < height; i++) {
|
|
61
|
+
const leftPadded = padToWidth(rows[i] ?? '', listWidth);
|
|
62
|
+
const right = detail[i] ?? '';
|
|
63
|
+
out.push(`${leftPadded} ${picocolors_1.default.dim('│')} ${picocolors_1.default.dim(right)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
out.push(...rows);
|
|
68
|
+
out.push(picocolors_1.default.dim(' ' + ((0, text_1.wrap)(vis[cursor].description, vp.columns - 4)[0] ?? '')));
|
|
69
|
+
}
|
|
70
|
+
out.push(FOOTER);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** Pad a possibly-colored string to `width` visible cells. */
|
|
74
|
+
function padToWidth(s, width) {
|
|
75
|
+
const pad = width - (0, text_1.displayWidth)(s);
|
|
76
|
+
return pad > 0 ? s + ' '.repeat(pad) : s;
|
|
77
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ALL_SENTINEL = void 0;
|
|
4
|
+
exports.parseKey = parseKey;
|
|
5
|
+
exports.pickerReducer = pickerReducer;
|
|
6
|
+
exports.multiselectPicker = multiselectPicker;
|
|
7
|
+
const picker_view_1 = require("./picker-view");
|
|
8
|
+
const registry_view_1 = require("../utils/registry-view");
|
|
9
|
+
// ALL_SENTINEL: single source of truth lives in registry-view.ts — re-exported here.
|
|
10
|
+
exports.ALL_SENTINEL = registry_view_1.ALL_SENTINEL;
|
|
11
|
+
function parseKey(data) {
|
|
12
|
+
switch (data) {
|
|
13
|
+
case '\x1b[A': return { action: 'up' };
|
|
14
|
+
case '\x1b[B': return { action: 'down' };
|
|
15
|
+
case '\r':
|
|
16
|
+
case '\n': return { action: 'confirm' };
|
|
17
|
+
case ' ': return { action: 'toggle' };
|
|
18
|
+
case '\t': return { action: 'toggleAll' };
|
|
19
|
+
case '\x7f':
|
|
20
|
+
case '\b': return { action: 'backspace' };
|
|
21
|
+
case '\x03': return { action: 'cancel' }; // Ctrl-C
|
|
22
|
+
case '\x1b': return { action: 'clearOrCancel' }; // Esc
|
|
23
|
+
}
|
|
24
|
+
if (data.length === 1 && /[a-zA-Z0-9-]/.test(data))
|
|
25
|
+
return { action: 'char', char: data };
|
|
26
|
+
return { action: 'none' };
|
|
27
|
+
}
|
|
28
|
+
function realValues(state) {
|
|
29
|
+
return state.items.filter((i) => i.value !== exports.ALL_SENTINEL).map((i) => i.value);
|
|
30
|
+
}
|
|
31
|
+
function pickerReducer(state, key) {
|
|
32
|
+
const vis = (0, picker_view_1.visibleItems)(state);
|
|
33
|
+
switch (key.type) {
|
|
34
|
+
case 'up': {
|
|
35
|
+
if (vis.length === 0)
|
|
36
|
+
return state;
|
|
37
|
+
return { ...state, cursor: (state.cursor - 1 + vis.length) % vis.length };
|
|
38
|
+
}
|
|
39
|
+
case 'down': {
|
|
40
|
+
if (vis.length === 0)
|
|
41
|
+
return state;
|
|
42
|
+
return { ...state, cursor: (state.cursor + 1) % vis.length };
|
|
43
|
+
}
|
|
44
|
+
case 'toggle': {
|
|
45
|
+
if (vis.length === 0)
|
|
46
|
+
return state;
|
|
47
|
+
const item = vis[Math.max(0, Math.min(state.cursor, vis.length - 1))];
|
|
48
|
+
const selected = new Set(state.selected);
|
|
49
|
+
if (item.value === exports.ALL_SENTINEL) {
|
|
50
|
+
const reals = realValues(state);
|
|
51
|
+
const allSel = reals.every((v) => selected.has(v));
|
|
52
|
+
if (allSel) {
|
|
53
|
+
reals.forEach((v) => selected.delete(v));
|
|
54
|
+
selected.delete(exports.ALL_SENTINEL);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
reals.forEach((v) => selected.add(v));
|
|
58
|
+
selected.add(exports.ALL_SENTINEL);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
if (selected.has(item.value)) {
|
|
63
|
+
selected.delete(item.value);
|
|
64
|
+
selected.delete(exports.ALL_SENTINEL); // no longer all-selected
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
selected.add(item.value);
|
|
68
|
+
// if every real item is now selected, also mark the sentinel
|
|
69
|
+
const reals = realValues(state);
|
|
70
|
+
if (reals.every((v) => selected.has(v)))
|
|
71
|
+
selected.add(exports.ALL_SENTINEL);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { ...state, selected };
|
|
75
|
+
}
|
|
76
|
+
case 'toggleAll': {
|
|
77
|
+
const reals = vis.filter((i) => i.value !== exports.ALL_SENTINEL).map((i) => i.value);
|
|
78
|
+
const selected = new Set(state.selected);
|
|
79
|
+
const allSel = reals.every((v) => selected.has(v));
|
|
80
|
+
if (allSel) {
|
|
81
|
+
reals.forEach((v) => selected.delete(v));
|
|
82
|
+
selected.delete(exports.ALL_SENTINEL);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
reals.forEach((v) => selected.add(v));
|
|
86
|
+
selected.add(exports.ALL_SENTINEL);
|
|
87
|
+
}
|
|
88
|
+
return { ...state, selected };
|
|
89
|
+
}
|
|
90
|
+
case 'filterChar':
|
|
91
|
+
return { ...state, filter: state.filter + key.char, cursor: 0 };
|
|
92
|
+
case 'backspace':
|
|
93
|
+
return { ...state, filter: state.filter.slice(0, -1), cursor: 0 };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function defaultIO() {
|
|
97
|
+
return { input: process.stdin, output: process.stdout };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Interactive multiselect. Returns the selected values, or null if cancelled.
|
|
101
|
+
* Streams are injectable for testing. Always restores terminal state on exit.
|
|
102
|
+
*/
|
|
103
|
+
function multiselectPicker(opts, io = defaultIO()) {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
let state = {
|
|
106
|
+
title: opts.title,
|
|
107
|
+
items: opts.items,
|
|
108
|
+
selected: new Set(opts.initialSelected ?? []),
|
|
109
|
+
cursor: 0,
|
|
110
|
+
filter: '',
|
|
111
|
+
};
|
|
112
|
+
let lastLineCount = 0;
|
|
113
|
+
const size = () => ({
|
|
114
|
+
columns: io.output.columns ?? 80,
|
|
115
|
+
rows: io.output.rows ?? 24,
|
|
116
|
+
});
|
|
117
|
+
const draw = () => {
|
|
118
|
+
const lines = (0, picker_view_1.renderPicker)(state, size());
|
|
119
|
+
// Move up over the previous frame and clear downward.
|
|
120
|
+
if (lastLineCount > 0)
|
|
121
|
+
io.output.write(`\x1b[${lastLineCount}A`);
|
|
122
|
+
io.output.write('\x1b[0J');
|
|
123
|
+
io.output.write(lines.join('\n') + '\n');
|
|
124
|
+
lastLineCount = lines.length;
|
|
125
|
+
};
|
|
126
|
+
let onSigint;
|
|
127
|
+
const cleanup = () => {
|
|
128
|
+
io.input.removeListener('data', onData);
|
|
129
|
+
if (onSigint)
|
|
130
|
+
process.removeListener('SIGINT', onSigint);
|
|
131
|
+
try {
|
|
132
|
+
io.input.setRawMode?.(false);
|
|
133
|
+
}
|
|
134
|
+
catch { /* best-effort: terminal may not support raw mode */ }
|
|
135
|
+
io.input.pause?.();
|
|
136
|
+
io.output.write('\x1b[?25h'); // show cursor
|
|
137
|
+
};
|
|
138
|
+
const onData = (chunk) => {
|
|
139
|
+
const key = parseKey(chunk.toString());
|
|
140
|
+
switch (key.action) {
|
|
141
|
+
case 'confirm':
|
|
142
|
+
cleanup();
|
|
143
|
+
resolve(Array.from(state.selected));
|
|
144
|
+
return;
|
|
145
|
+
case 'cancel':
|
|
146
|
+
cleanup();
|
|
147
|
+
resolve(null);
|
|
148
|
+
return;
|
|
149
|
+
case 'clearOrCancel':
|
|
150
|
+
if (state.filter) {
|
|
151
|
+
state = { ...state, filter: '', cursor: 0 };
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
cleanup();
|
|
155
|
+
resolve(null);
|
|
156
|
+
return;
|
|
157
|
+
case 'up':
|
|
158
|
+
state = pickerReducer(state, { type: 'up' });
|
|
159
|
+
break;
|
|
160
|
+
case 'down':
|
|
161
|
+
state = pickerReducer(state, { type: 'down' });
|
|
162
|
+
break;
|
|
163
|
+
case 'toggle':
|
|
164
|
+
state = pickerReducer(state, { type: 'toggle' });
|
|
165
|
+
break;
|
|
166
|
+
case 'toggleAll':
|
|
167
|
+
state = pickerReducer(state, { type: 'toggleAll' });
|
|
168
|
+
break;
|
|
169
|
+
case 'backspace':
|
|
170
|
+
state = pickerReducer(state, { type: 'backspace' });
|
|
171
|
+
break;
|
|
172
|
+
case 'char':
|
|
173
|
+
state = pickerReducer(state, { type: 'filterChar', char: key.char });
|
|
174
|
+
break;
|
|
175
|
+
case 'none': return; // ignore, no redraw
|
|
176
|
+
}
|
|
177
|
+
draw();
|
|
178
|
+
};
|
|
179
|
+
onSigint = () => {
|
|
180
|
+
cleanup();
|
|
181
|
+
resolve(null);
|
|
182
|
+
};
|
|
183
|
+
process.once('SIGINT', onSigint);
|
|
184
|
+
try {
|
|
185
|
+
io.input.setRawMode?.(true);
|
|
186
|
+
}
|
|
187
|
+
catch { /* best-effort: degrade if raw mode unsupported */ }
|
|
188
|
+
io.input.resume?.();
|
|
189
|
+
io.input.setEncoding?.('utf8');
|
|
190
|
+
io.output.write('\x1b[?25l'); // hide cursor
|
|
191
|
+
io.input.on('data', onData);
|
|
192
|
+
draw();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stripAnsi = stripAnsi;
|
|
4
|
+
exports.displayWidth = displayWidth;
|
|
5
|
+
exports.truncate = truncate;
|
|
6
|
+
exports.wrap = wrap;
|
|
7
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
8
|
+
function stripAnsi(s) {
|
|
9
|
+
return s.replace(ANSI_RE, '');
|
|
10
|
+
}
|
|
11
|
+
function isWide(cp) {
|
|
12
|
+
return ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
13
|
+
(cp >= 0x2300 && cp <= 0x23ff) || // misc technical (⏎ etc.)
|
|
14
|
+
(cp >= 0x2600 && cp <= 0x27bf) || // misc symbols / dingbats
|
|
15
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
16
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
|
17
|
+
(cp >= 0xff00 && cp <= 0xffef) || // Fullwidth Latin/symbols
|
|
18
|
+
(cp >= 0x1f000 && cp <= 0x1faff) // emoji
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
/** Visible terminal cells, ignoring ANSI color and treating known wide glyphs as 2. */
|
|
22
|
+
function displayWidth(s) {
|
|
23
|
+
const plain = stripAnsi(s);
|
|
24
|
+
let w = 0;
|
|
25
|
+
for (const ch of plain) {
|
|
26
|
+
const cp = ch.codePointAt(0);
|
|
27
|
+
if (cp === 0xfe0f)
|
|
28
|
+
continue; // variation selector — zero width
|
|
29
|
+
w += isWide(cp) ? 2 : 1;
|
|
30
|
+
}
|
|
31
|
+
return w;
|
|
32
|
+
}
|
|
33
|
+
/** Truncate plain text to `width` cells, appending '…'. Strips ANSI — color AFTER truncating. */
|
|
34
|
+
function truncate(s, width) {
|
|
35
|
+
if (width <= 0)
|
|
36
|
+
return '';
|
|
37
|
+
const plain = stripAnsi(s);
|
|
38
|
+
if (displayWidth(plain) <= width)
|
|
39
|
+
return plain;
|
|
40
|
+
if (width === 1)
|
|
41
|
+
return '…';
|
|
42
|
+
let out = '';
|
|
43
|
+
let w = 0;
|
|
44
|
+
for (const ch of plain) {
|
|
45
|
+
const cw = displayWidth(ch);
|
|
46
|
+
if (w + cw > width - 1)
|
|
47
|
+
break;
|
|
48
|
+
out += ch;
|
|
49
|
+
w += cw;
|
|
50
|
+
}
|
|
51
|
+
return out + '…';
|
|
52
|
+
}
|
|
53
|
+
/** Word-wrap plain text to `width` cells. Long single words are not split (acceptable). */
|
|
54
|
+
function wrap(s, width) {
|
|
55
|
+
if (width <= 0)
|
|
56
|
+
return [s];
|
|
57
|
+
const words = stripAnsi(s).split(/\s+/).filter(Boolean);
|
|
58
|
+
const lines = [];
|
|
59
|
+
let cur = '';
|
|
60
|
+
for (const word of words) {
|
|
61
|
+
if (cur === '') {
|
|
62
|
+
cur = word;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (displayWidth(cur) + 1 + displayWidth(word) <= width)
|
|
66
|
+
cur += ' ' + word;
|
|
67
|
+
else {
|
|
68
|
+
lines.push(cur);
|
|
69
|
+
cur = word;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (cur)
|
|
73
|
+
lines.push(cur);
|
|
74
|
+
return lines.length ? lines : [''];
|
|
75
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isInteractive = isInteractive;
|
|
4
|
+
exports.terminalSize = terminalSize;
|
|
5
|
+
/** True only when both ends are TTYs — the precondition for the interactive picker. */
|
|
6
|
+
function isInteractive() {
|
|
7
|
+
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
8
|
+
}
|
|
9
|
+
/** Current terminal size, with a safe 80x24 fallback when undefined (non-TTY). */
|
|
10
|
+
function terminalSize() {
|
|
11
|
+
return {
|
|
12
|
+
columns: process.stdout.columns ?? 80,
|
|
13
|
+
rows: process.stdout.rows ?? 24,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -10,11 +10,12 @@ exports.packageSummaryLines = packageSummaryLines;
|
|
|
10
10
|
exports.packageDetailLines = packageDetailLines;
|
|
11
11
|
exports.findPackage = findPackage;
|
|
12
12
|
exports.artifactValue = artifactValue;
|
|
13
|
-
exports.
|
|
14
|
-
exports.
|
|
13
|
+
exports.packagePickerItems = packagePickerItems;
|
|
14
|
+
exports.artifactPickerItems = artifactPickerItems;
|
|
15
15
|
exports.resolveLevel2Selection = resolveLevel2Selection;
|
|
16
16
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
17
17
|
const registries_1 = require("../core/registries");
|
|
18
|
+
const text_1 = require("../ui/text");
|
|
18
19
|
exports.STANDALONE_NAME = 'standalone';
|
|
19
20
|
function makePackage(name, description, isStandalone, visibility, artifacts) {
|
|
20
21
|
return {
|
|
@@ -73,7 +74,7 @@ function artifactCountLabel(counts) {
|
|
|
73
74
|
function packageIcon(pkg) {
|
|
74
75
|
return pkg.isStandalone ? '🔹' : '📦';
|
|
75
76
|
}
|
|
76
|
-
function packageSummaryLines(packages) {
|
|
77
|
+
function packageSummaryLines(packages, width) {
|
|
77
78
|
const uniqueSkillNames = new Set(packages.flatMap((p) => p.artifacts.filter((a) => a.type === 'skill').map((a) => a.name)));
|
|
78
79
|
const totalSkills = uniqueSkillNames.size;
|
|
79
80
|
const lines = [`AWM Registry — ${plural(packages.length, 'package')}, ${plural(totalSkills, 'skill')}`, ''];
|
|
@@ -83,12 +84,17 @@ function packageSummaryLines(packages) {
|
|
|
83
84
|
packages.forEach((p, i) => {
|
|
84
85
|
const name = p.name.padEnd(nameWidth);
|
|
85
86
|
const count = countLabels[i].padEnd(countWidth);
|
|
86
|
-
|
|
87
|
+
let desc = p.isStandalone ? '' : p.description;
|
|
88
|
+
if (width && desc) {
|
|
89
|
+
const used = 3 + nameWidth + 3 + countWidth + 3; // icon(2)+space(1), name, gap, count, gap
|
|
90
|
+
const avail = width - used;
|
|
91
|
+
desc = avail > 1 ? (0, text_1.truncate)(desc, avail) : '';
|
|
92
|
+
}
|
|
87
93
|
lines.push(`${packageIcon(p)} ${name} ${count} ${desc}`.trimEnd());
|
|
88
94
|
});
|
|
89
95
|
return lines;
|
|
90
96
|
}
|
|
91
|
-
function packageDetailLines(pkg) {
|
|
97
|
+
function packageDetailLines(pkg, width) {
|
|
92
98
|
const lines = [];
|
|
93
99
|
const header = pkg.isStandalone
|
|
94
100
|
? `${packageIcon(pkg)} ${pkg.name} — ${plural(pkg.artifacts.length, 'artifact')}`
|
|
@@ -99,8 +105,10 @@ function packageDetailLines(pkg) {
|
|
|
99
105
|
? picocolors_1.default.yellow(` ← ${(0, registries_1.registryNameForPath)(a.sourcePath) ?? 'unknown'} (override)`)
|
|
100
106
|
: '';
|
|
101
107
|
lines.push(` ${TYPE_ICON[a.type]}${a.name}${mark}`);
|
|
102
|
-
if (a.description)
|
|
103
|
-
|
|
108
|
+
if (a.description) {
|
|
109
|
+
const desc = width ? (0, text_1.truncate)(a.description, width - 5) : a.description;
|
|
110
|
+
lines.push(` ${desc}`);
|
|
111
|
+
}
|
|
104
112
|
});
|
|
105
113
|
return lines;
|
|
106
114
|
}
|
|
@@ -130,21 +138,27 @@ exports.ALL_SENTINEL = '__ALL__';
|
|
|
130
138
|
function artifactValue(a) {
|
|
131
139
|
return `${a.type}:${a.name}`;
|
|
132
140
|
}
|
|
133
|
-
function
|
|
134
|
-
return packages.map((p) => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
function packagePickerItems(packages) {
|
|
142
|
+
return packages.map((p) => ({
|
|
143
|
+
value: p.name,
|
|
144
|
+
label: `${packageIcon(p)} ${p.name}`,
|
|
145
|
+
description: p.isStandalone
|
|
146
|
+
? plural(p.artifacts.length, 'artifact')
|
|
147
|
+
: `${artifactCountLabel(p.counts)} · ${p.description}`,
|
|
148
|
+
}));
|
|
139
149
|
}
|
|
140
|
-
function
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
function artifactPickerItems(pkg) {
|
|
151
|
+
const all = {
|
|
152
|
+
value: exports.ALL_SENTINEL,
|
|
153
|
+
label: `✨ Install entire package (${pkg.artifacts.length})`,
|
|
154
|
+
description: 'Select every artifact in this package.',
|
|
155
|
+
};
|
|
156
|
+
const rest = pkg.artifacts.map((a) => ({
|
|
157
|
+
value: artifactValue(a),
|
|
158
|
+
label: `${TYPE_ICON[a.type]}${a.name}`,
|
|
159
|
+
description: a.description || '',
|
|
160
|
+
}));
|
|
161
|
+
return [all, ...rest];
|
|
148
162
|
}
|
|
149
163
|
function resolveLevel2Selection(pkg, selectedValues) {
|
|
150
164
|
if (selectedValues.includes(exports.ALL_SENTINEL))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const picker_1 = require("../../src/ui/picker");
|
|
4
|
+
const items = [
|
|
5
|
+
{ value: picker_1.ALL_SENTINEL, label: '✨ Install entire package (2)', description: '' },
|
|
6
|
+
{ value: 'skill:a', label: 'alpha', description: '' },
|
|
7
|
+
{ value: 'skill:b', label: 'beta', description: '' },
|
|
8
|
+
];
|
|
9
|
+
const base = (over = {}) => ({
|
|
10
|
+
title: 't', items, selected: new Set(), cursor: 0, filter: '', ...over,
|
|
11
|
+
});
|
|
12
|
+
describe('parseKey', () => {
|
|
13
|
+
it('maps arrow and control sequences', () => {
|
|
14
|
+
expect((0, picker_1.parseKey)('\x1b[A')).toEqual({ action: 'up' });
|
|
15
|
+
expect((0, picker_1.parseKey)('\x1b[B')).toEqual({ action: 'down' });
|
|
16
|
+
expect((0, picker_1.parseKey)('\r')).toEqual({ action: 'confirm' });
|
|
17
|
+
expect((0, picker_1.parseKey)(' ')).toEqual({ action: 'toggle' });
|
|
18
|
+
expect((0, picker_1.parseKey)('\t')).toEqual({ action: 'toggleAll' });
|
|
19
|
+
expect((0, picker_1.parseKey)('\x03')).toEqual({ action: 'cancel' });
|
|
20
|
+
expect((0, picker_1.parseKey)('\x1b')).toEqual({ action: 'clearOrCancel' });
|
|
21
|
+
expect((0, picker_1.parseKey)('\x7f')).toEqual({ action: 'backspace' });
|
|
22
|
+
});
|
|
23
|
+
it('maps filter characters and ignores the rest', () => {
|
|
24
|
+
expect((0, picker_1.parseKey)('c')).toEqual({ action: 'char', char: 'c' });
|
|
25
|
+
expect((0, picker_1.parseKey)('-')).toEqual({ action: 'char', char: '-' });
|
|
26
|
+
expect((0, picker_1.parseKey)('?')).toEqual({ action: 'none' });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('pickerReducer', () => {
|
|
30
|
+
it('moves the cursor and wraps around', () => {
|
|
31
|
+
expect((0, picker_1.pickerReducer)(base({ cursor: 0 }), { type: 'up' }).cursor).toBe(2);
|
|
32
|
+
expect((0, picker_1.pickerReducer)(base({ cursor: 2 }), { type: 'down' }).cursor).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
it('toggles the current item', () => {
|
|
35
|
+
const s = (0, picker_1.pickerReducer)(base({ cursor: 1 }), { type: 'toggle' });
|
|
36
|
+
expect(s.selected.has('skill:a')).toBe(true);
|
|
37
|
+
const s2 = (0, picker_1.pickerReducer)(s, { type: 'toggle' });
|
|
38
|
+
expect(s2.selected.has('skill:a')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it('toggling the ALL sentinel selects every real item plus the sentinel', () => {
|
|
41
|
+
const s = (0, picker_1.pickerReducer)(base({ cursor: 0 }), { type: 'toggle' });
|
|
42
|
+
expect(s.selected.has('skill:a')).toBe(true);
|
|
43
|
+
expect(s.selected.has('skill:b')).toBe(true);
|
|
44
|
+
expect(s.selected.has(picker_1.ALL_SENTINEL)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('Tab toggles all real items without needing the sentinel row', () => {
|
|
47
|
+
const s = (0, picker_1.pickerReducer)(base(), { type: 'toggleAll' });
|
|
48
|
+
expect(s.selected.has('skill:a')).toBe(true);
|
|
49
|
+
expect(s.selected.has('skill:b')).toBe(true);
|
|
50
|
+
expect(s.selected.has(picker_1.ALL_SENTINEL)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
it('toggleAll via Tab syncs ALL_SENTINEL when all become selected', () => {
|
|
53
|
+
const s = (0, picker_1.pickerReducer)(base(), { type: 'toggleAll' });
|
|
54
|
+
expect(s.selected.has('skill:a')).toBe(true);
|
|
55
|
+
expect(s.selected.has('skill:b')).toBe(true);
|
|
56
|
+
expect(s.selected.has(picker_1.ALL_SENTINEL)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
it('typing filters and resets the cursor to 0', () => {
|
|
59
|
+
const s = (0, picker_1.pickerReducer)(base({ cursor: 2 }), { type: 'filterChar', char: 'b' });
|
|
60
|
+
expect(s.filter).toBe('b');
|
|
61
|
+
expect(s.cursor).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
it('backspace edits the filter', () => {
|
|
64
|
+
expect((0, picker_1.pickerReducer)(base({ filter: 'be' }), { type: 'backspace' }).filter).toBe('b');
|
|
65
|
+
});
|
|
66
|
+
it('does not move the cursor when the filtered list is empty', () => {
|
|
67
|
+
const s = base({ filter: 'zzz', cursor: 0 });
|
|
68
|
+
expect((0, picker_1.pickerReducer)(s, { type: 'down' }).cursor).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
it('toggling an individual item off removes ALL_SENTINEL from selected', () => {
|
|
71
|
+
// Start with all items pre-selected (the initialSelected scenario)
|
|
72
|
+
const all = new Set([picker_1.ALL_SENTINEL, 'skill:a', 'skill:b']);
|
|
73
|
+
const s = (0, picker_1.pickerReducer)(base({ cursor: 1, selected: all }), { type: 'toggle' });
|
|
74
|
+
// Unchecked skill:a — sentinel should be gone
|
|
75
|
+
expect(s.selected.has('skill:a')).toBe(false);
|
|
76
|
+
expect(s.selected.has(picker_1.ALL_SENTINEL)).toBe(false);
|
|
77
|
+
expect(s.selected.has('skill:b')).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it('toggling the last missing item on syncs the ALL_SENTINEL', () => {
|
|
80
|
+
// skill:a is missing, skill:b is selected — select skill:a and sentinel should appear
|
|
81
|
+
const partial = new Set(['skill:b']);
|
|
82
|
+
const s = (0, picker_1.pickerReducer)(base({ cursor: 1, selected: partial }), { type: 'toggle' });
|
|
83
|
+
expect(s.selected.has('skill:a')).toBe(true);
|
|
84
|
+
expect(s.selected.has(picker_1.ALL_SENTINEL)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
it('toggleAll with active filter only toggles visible items', () => {
|
|
87
|
+
// filter to 'beta' — only skill:b is visible (ALL_SENTINEL label '✨ Install entire package (2)' does not match)
|
|
88
|
+
const s = (0, picker_1.pickerReducer)(base({ filter: 'beta' }), { type: 'toggleAll' });
|
|
89
|
+
expect(s.selected.has('skill:b')).toBe(true);
|
|
90
|
+
expect(s.selected.has('skill:a')).toBe(false); // not visible, should NOT be selected
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const events_1 = require("events");
|
|
4
|
+
const picker_1 = require("../../src/ui/picker");
|
|
5
|
+
function fakeIO() {
|
|
6
|
+
const input = new events_1.EventEmitter();
|
|
7
|
+
input.setRawMode = jest.fn();
|
|
8
|
+
input.resume = jest.fn();
|
|
9
|
+
input.pause = jest.fn();
|
|
10
|
+
input.setEncoding = jest.fn();
|
|
11
|
+
const writes = [];
|
|
12
|
+
const output = { write: (s) => { writes.push(s); return true; }, columns: 100, rows: 20 };
|
|
13
|
+
return { io: { input, output }, writes, send: (s) => input.emit('data', s) };
|
|
14
|
+
}
|
|
15
|
+
const items = [
|
|
16
|
+
{ value: picker_1.ALL_SENTINEL, label: '✨ all', description: '' },
|
|
17
|
+
{ value: 'skill:a', label: 'alpha', description: 'A.' },
|
|
18
|
+
{ value: 'skill:b', label: 'beta', description: 'B.' },
|
|
19
|
+
];
|
|
20
|
+
it('resolves with the toggled selection on Enter', async () => {
|
|
21
|
+
const { io, send } = fakeIO();
|
|
22
|
+
const p = (0, picker_1.multiselectPicker)({ title: 't', items }, io);
|
|
23
|
+
send('\x1b[B'); // down → cursor on alpha
|
|
24
|
+
send(' '); // toggle alpha
|
|
25
|
+
send('\r'); // confirm
|
|
26
|
+
await expect(p).resolves.toEqual(['skill:a']);
|
|
27
|
+
expect(io.input.setRawMode).toHaveBeenLastCalledWith(false); // restored
|
|
28
|
+
});
|
|
29
|
+
it('resolves null on Ctrl-C and restores raw mode', async () => {
|
|
30
|
+
const { io, send } = fakeIO();
|
|
31
|
+
const p = (0, picker_1.multiselectPicker)({ title: 't', items }, io);
|
|
32
|
+
send('\x03');
|
|
33
|
+
await expect(p).resolves.toBeNull();
|
|
34
|
+
expect(io.input.setRawMode).toHaveBeenLastCalledWith(false);
|
|
35
|
+
});
|
|
36
|
+
it('seeds the initial selection', async () => {
|
|
37
|
+
const { io, send } = fakeIO();
|
|
38
|
+
const p = (0, picker_1.multiselectPicker)({ title: 't', items, initialSelected: [picker_1.ALL_SENTINEL, 'skill:a', 'skill:b'] }, io);
|
|
39
|
+
send('\r');
|
|
40
|
+
const result = await p;
|
|
41
|
+
expect(new Set(result)).toEqual(new Set([picker_1.ALL_SENTINEL, 'skill:a', 'skill:b']));
|
|
42
|
+
});
|
|
43
|
+
it('Esc with empty filter cancels and returns null', async () => {
|
|
44
|
+
const { io, send } = fakeIO();
|
|
45
|
+
const p = (0, picker_1.multiselectPicker)({ title: 't', items }, io);
|
|
46
|
+
send('\x1b'); // Esc with no active filter → cancel
|
|
47
|
+
await expect(p).resolves.toBeNull();
|
|
48
|
+
expect(io.input.setRawMode).toHaveBeenLastCalledWith(false);
|
|
49
|
+
});
|
|
50
|
+
it('Esc with active filter clears it instead of cancelling', async () => {
|
|
51
|
+
const { io, send } = fakeIO();
|
|
52
|
+
const p = (0, picker_1.multiselectPicker)({ title: 't', items }, io);
|
|
53
|
+
send('a'); // type 'a' → filter becomes 'a'
|
|
54
|
+
send('\x1b'); // Esc → should clear filter, NOT cancel
|
|
55
|
+
send('\r'); // Enter → confirm (with no selection)
|
|
56
|
+
await expect(p).resolves.toEqual([]);
|
|
57
|
+
// setRawMode(false) called on confirm, not on Esc
|
|
58
|
+
expect(io.input.setRawMode).toHaveBeenLastCalledWith(false);
|
|
59
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const picker_view_1 = require("../../src/ui/picker-view");
|
|
4
|
+
const items = [
|
|
5
|
+
{ value: 'skill:architecture-advisor', label: 'architecture-advisor', description: 'Define and review system architecture.' },
|
|
6
|
+
{ value: 'skill:brainstorming', label: 'brainstorming', description: 'Turn ideas into designs.' },
|
|
7
|
+
{ value: 'skill:cicd-proposal-builder', label: 'cicd-proposal-builder', description: 'Specialist in CI/CD pipeline design. Use when defining a pipeline.' },
|
|
8
|
+
];
|
|
9
|
+
const base = (over = {}) => ({
|
|
10
|
+
title: 'dev — select artifacts',
|
|
11
|
+
items,
|
|
12
|
+
selected: new Set(),
|
|
13
|
+
cursor: 0,
|
|
14
|
+
filter: '',
|
|
15
|
+
...over,
|
|
16
|
+
});
|
|
17
|
+
describe('visibleItems', () => {
|
|
18
|
+
it('returns all items with an empty filter', () => {
|
|
19
|
+
expect((0, picker_view_1.visibleItems)(base())).toHaveLength(3);
|
|
20
|
+
});
|
|
21
|
+
it('filters by label substring, case-insensitive', () => {
|
|
22
|
+
expect((0, picker_view_1.visibleItems)(base({ filter: 'CIC' })).map((i) => i.value)).toEqual(['skill:cicd-proposal-builder']);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('renderPicker', () => {
|
|
26
|
+
it('shows the title in the header', () => {
|
|
27
|
+
const lines = (0, picker_view_1.renderPicker)(base(), { columns: 100, rows: 20 });
|
|
28
|
+
expect(lines[0]).toContain('dev — select artifacts');
|
|
29
|
+
});
|
|
30
|
+
it('marks the cursor row and selected rows', () => {
|
|
31
|
+
const lines = (0, picker_view_1.renderPicker)(base({ cursor: 1, selected: new Set(['skill:brainstorming']) }), { columns: 100, rows: 20 });
|
|
32
|
+
const body = lines.join('\n');
|
|
33
|
+
expect(body).toContain('❯'); // cursor marker
|
|
34
|
+
expect(body).toContain('◼'); // a selected checkbox
|
|
35
|
+
expect(body).toContain('◻'); // an unselected checkbox
|
|
36
|
+
});
|
|
37
|
+
it('uses two panes (a gutter) when wide', () => {
|
|
38
|
+
const lines = (0, picker_view_1.renderPicker)(base(), { columns: picker_view_1.TWO_PANE_MIN_COLUMNS + 10, rows: 20 });
|
|
39
|
+
expect(lines.some((l) => l.includes(' │ '))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('collapses to one pane (no gutter) when narrow', () => {
|
|
42
|
+
const lines = (0, picker_view_1.renderPicker)(base(), { columns: 50, rows: 20 });
|
|
43
|
+
expect(lines.some((l) => l.includes(' │ '))).toBe(false);
|
|
44
|
+
// the highlighted item's description still appears below the list
|
|
45
|
+
expect(lines.join('\n')).toContain('Define and review');
|
|
46
|
+
});
|
|
47
|
+
it('shows the filter text in the header when filtering', () => {
|
|
48
|
+
expect((0, picker_view_1.renderPicker)(base({ filter: 'cic' }), { columns: 100, rows: 20 })[0]).toContain('filter: cic');
|
|
49
|
+
});
|
|
50
|
+
it('reports no matches when the filter excludes everything', () => {
|
|
51
|
+
expect((0, picker_view_1.renderPicker)(base({ filter: 'zzz' }), { columns: 100, rows: 20 }).join('\n').toLowerCase()).toContain('no matches');
|
|
52
|
+
});
|
|
53
|
+
it('always ends with the key hint footer', () => {
|
|
54
|
+
const lines = (0, picker_view_1.renderPicker)(base(), { columns: 100, rows: 20 });
|
|
55
|
+
expect(lines[lines.length - 1]).toContain('confirm');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const text_1 = require("../../src/ui/text");
|
|
7
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
8
|
+
describe('stripAnsi', () => {
|
|
9
|
+
it('removes color escape sequences', () => {
|
|
10
|
+
expect((0, text_1.stripAnsi)(picocolors_1.default.green('hi'))).toBe('hi');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
describe('displayWidth', () => {
|
|
14
|
+
it('counts plain ASCII one cell each', () => {
|
|
15
|
+
expect((0, text_1.displayWidth)('hello')).toBe(5);
|
|
16
|
+
});
|
|
17
|
+
it('ignores color codes', () => {
|
|
18
|
+
expect((0, text_1.displayWidth)(picocolors_1.default.red('abc'))).toBe(3);
|
|
19
|
+
});
|
|
20
|
+
it('counts an emoji as two cells and ignores the VS16 selector', () => {
|
|
21
|
+
expect((0, text_1.displayWidth)('📦')).toBe(2);
|
|
22
|
+
expect((0, text_1.displayWidth)('✍️')).toBe(2);
|
|
23
|
+
});
|
|
24
|
+
it('counts CJK ideographs as 2 cells', () => {
|
|
25
|
+
expect((0, text_1.displayWidth)('你好')).toBe(4);
|
|
26
|
+
});
|
|
27
|
+
it('counts Hangul syllables as 2 cells', () => {
|
|
28
|
+
expect((0, text_1.displayWidth)('안녕')).toBe(4);
|
|
29
|
+
});
|
|
30
|
+
it('counts fullwidth Latin as 2 cells', () => {
|
|
31
|
+
// U+FF21 = A (fullwidth A)
|
|
32
|
+
expect((0, text_1.displayWidth)('AB')).toBe(4);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('truncate', () => {
|
|
36
|
+
it('returns the string unchanged when it fits', () => {
|
|
37
|
+
expect((0, text_1.truncate)('abc', 5)).toBe('abc');
|
|
38
|
+
});
|
|
39
|
+
it('truncates and appends an ellipsis, never exceeding width', () => {
|
|
40
|
+
const out = (0, text_1.truncate)('abcdefgh', 5);
|
|
41
|
+
expect(out).toBe('abcd…');
|
|
42
|
+
expect((0, text_1.displayWidth)(out)).toBeLessThanOrEqual(5);
|
|
43
|
+
});
|
|
44
|
+
it('returns empty for non-positive width', () => {
|
|
45
|
+
expect((0, text_1.truncate)('abc', 0)).toBe('');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('wrap', () => {
|
|
49
|
+
it('breaks text at word boundaries within width', () => {
|
|
50
|
+
expect((0, text_1.wrap)('the quick brown fox', 9)).toEqual(['the quick', 'brown fox']);
|
|
51
|
+
});
|
|
52
|
+
it('returns a single empty line for empty input', () => {
|
|
53
|
+
expect((0, text_1.wrap)('', 10)).toEqual(['']);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tty_1 = require("../../src/ui/tty");
|
|
4
|
+
describe('isInteractive', () => {
|
|
5
|
+
const outTTY = process.stdout.isTTY;
|
|
6
|
+
const inTTY = process.stdin.isTTY;
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: outTTY, configurable: true });
|
|
9
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: inTTY, configurable: true });
|
|
10
|
+
});
|
|
11
|
+
it('is true only when both stdin and stdout are TTYs', () => {
|
|
12
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
13
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
14
|
+
expect((0, tty_1.isInteractive)()).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it('is false when stdout is not a TTY (piped output)', () => {
|
|
17
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
|
18
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
19
|
+
expect((0, tty_1.isInteractive)()).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('terminalSize', () => {
|
|
23
|
+
const cols = process.stdout.columns;
|
|
24
|
+
const rows = process.stdout.rows;
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
Object.defineProperty(process.stdout, 'columns', { value: cols, configurable: true });
|
|
27
|
+
Object.defineProperty(process.stdout, 'rows', { value: rows, configurable: true });
|
|
28
|
+
});
|
|
29
|
+
it('reports the stdout dimensions', () => {
|
|
30
|
+
Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true });
|
|
31
|
+
Object.defineProperty(process.stdout, 'rows', { value: 40, configurable: true });
|
|
32
|
+
expect((0, tty_1.terminalSize)()).toEqual({ columns: 120, rows: 40 });
|
|
33
|
+
});
|
|
34
|
+
it('falls back to 80x24 when dimensions are undefined', () => {
|
|
35
|
+
Object.defineProperty(process.stdout, 'columns', { value: undefined, configurable: true });
|
|
36
|
+
Object.defineProperty(process.stdout, 'rows', { value: undefined, configurable: true });
|
|
37
|
+
expect((0, tty_1.terminalSize)()).toEqual({ columns: 80, rows: 24 });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -89,42 +89,6 @@ describe('findPackage', () => {
|
|
|
89
89
|
expect(res.suggestion).toBe('core-dev');
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
-
describe('buildLevel1Options', () => {
|
|
93
|
-
it('produces one option per package keyed by package name', () => {
|
|
94
|
-
const view = (0, registry_view_1.buildPackageView)([skill('brainstorming'), skill('shared')], [], [], processes);
|
|
95
|
-
const opts = (0, registry_view_1.buildLevel1Options)(view);
|
|
96
|
-
expect(opts.map((o) => o.value).sort()).toEqual(['core-dev', 'docs']);
|
|
97
|
-
expect(opts.find((o) => o.value === 'core-dev').label).toContain('core-dev');
|
|
98
|
-
expect(opts.find((o) => o.value === 'core-dev').label).toContain('Dev lifecycle');
|
|
99
|
-
});
|
|
100
|
-
it('uses artifact-count label for standalone packages', () => {
|
|
101
|
-
const view = (0, registry_view_1.buildPackageView)([skill('orphan')], [], [], []);
|
|
102
|
-
const opts = (0, registry_view_1.buildLevel1Options)(view);
|
|
103
|
-
expect(opts[0].value).toBe(registry_view_1.STANDALONE_NAME);
|
|
104
|
-
expect(opts[0].label).toContain('1 artifact');
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
describe('buildLevel2Options', () => {
|
|
108
|
-
it('puts an "install entire package" sentinel first, then one option per artifact', () => {
|
|
109
|
-
const view = (0, registry_view_1.buildPackageView)([skill('brainstorming', 'explore')], [wf('exec')], [agent('plan')], processes);
|
|
110
|
-
const core = view.find((p) => p.name === 'core-dev');
|
|
111
|
-
const opts = (0, registry_view_1.buildLevel2Options)(core);
|
|
112
|
-
expect(opts[0].value).toBe(registry_view_1.ALL_SENTINEL);
|
|
113
|
-
expect(opts[0].label).toContain('Install entire package');
|
|
114
|
-
const values = opts.slice(1).map((o) => o.value);
|
|
115
|
-
expect(values).toContain('skill:brainstorming');
|
|
116
|
-
expect(values).toContain('workflow:exec');
|
|
117
|
-
expect(values).toContain('agent:plan');
|
|
118
|
-
expect(opts.find((o) => o.value === 'skill:brainstorming').label).toContain('explore');
|
|
119
|
-
});
|
|
120
|
-
it('uses plain title for artifact with no description', () => {
|
|
121
|
-
const view = (0, registry_view_1.buildPackageView)([skill('brainstorming', 'explore')], [wf('exec')], [agent('plan')], processes);
|
|
122
|
-
const core = view.find((p) => p.name === 'core-dev');
|
|
123
|
-
const opts = (0, registry_view_1.buildLevel2Options)(core);
|
|
124
|
-
const wfOpt = opts.find((o) => o.value === 'workflow:exec');
|
|
125
|
-
expect(wfOpt.label).toBe('⚡ exec');
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
92
|
describe('resolveLevel2Selection', () => {
|
|
129
93
|
const view = (0, registry_view_1.buildPackageView)([skill('brainstorming'), skill('shared')], [], [], processes);
|
|
130
94
|
const core = view.find((p) => p.name === 'core-dev');
|
|
@@ -154,3 +118,42 @@ describe('visibility', () => {
|
|
|
154
118
|
expect(view.find((p) => p.name === 'core-dev').visibility).toBe('public');
|
|
155
119
|
});
|
|
156
120
|
});
|
|
121
|
+
describe('packageSummaryLines width-awareness', () => {
|
|
122
|
+
it('truncates the description column when a width is given', () => {
|
|
123
|
+
const view = (0, registry_view_1.buildPackageView)([skill('brainstorming', 'x'.repeat(200))], [], [], [
|
|
124
|
+
bundle({ name: 'core-dev', description: 'x'.repeat(200), skills: [{ name: 'brainstorming', onSignal: false }] }),
|
|
125
|
+
]);
|
|
126
|
+
const lines = (0, registry_view_1.packageSummaryLines)(view, 60);
|
|
127
|
+
for (const l of lines)
|
|
128
|
+
expect(l.length).toBeLessThanOrEqual(60);
|
|
129
|
+
expect(lines.some((l) => l.includes('…'))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
it('does not truncate when no width is given (piped output)', () => {
|
|
132
|
+
const view = (0, registry_view_1.buildPackageView)([skill('brainstorming', 'y'.repeat(200))], [], [], [
|
|
133
|
+
bundle({ name: 'core-dev', description: 'z'.repeat(200), skills: [{ name: 'brainstorming', onSignal: false }] }),
|
|
134
|
+
]);
|
|
135
|
+
const lines = (0, registry_view_1.packageSummaryLines)(view); // no width
|
|
136
|
+
expect(lines.some((l) => l.includes('z'.repeat(200)))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('artifactPickerItems', () => {
|
|
140
|
+
it('prepends an "install entire package" sentinel item, then one per artifact', () => {
|
|
141
|
+
const view = (0, registry_view_1.buildPackageView)([skill('a', 'desc a'), skill('b', 'desc b')], [], [], [
|
|
142
|
+
bundle({ name: 'p', description: 'pkg', skills: [{ name: 'a', onSignal: false }, { name: 'b', onSignal: false }] }),
|
|
143
|
+
]);
|
|
144
|
+
const items = (0, registry_view_1.artifactPickerItems)(view.find((p) => p.name === 'p'));
|
|
145
|
+
expect(items[0].value).toBe(registry_view_1.ALL_SENTINEL);
|
|
146
|
+
expect(items.slice(1).map((i) => i.label)).toEqual(['a', 'b']);
|
|
147
|
+
expect(items.find((i) => i.label === 'a').description).toBe('desc a');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('packagePickerItems', () => {
|
|
151
|
+
it('builds one item per package with a count+description summary', () => {
|
|
152
|
+
const view = (0, registry_view_1.buildPackageView)([skill('a')], [], [], [
|
|
153
|
+
bundle({ name: 'p', description: 'pkg desc', skills: [{ name: 'a', onSignal: false }] }),
|
|
154
|
+
]);
|
|
155
|
+
const items = (0, registry_view_1.packagePickerItems)(view);
|
|
156
|
+
expect(items[0].value).toBe('p');
|
|
157
|
+
expect(items[0].description).toContain('pkg desc');
|
|
158
|
+
});
|
|
159
|
+
});
|