Skip to content

Commit 3dd9bbd

Browse files
committed
feat(ux): smart menu, all commands, consistent preview cards, and back nav
- menu: detect context at startup — show only Init+Exit when no .dwf/ found, full menu with local/global badge once configured - menu: add Watch, List (with sub-select), and Explain as menu options - menu: error guard keeps loop alive instead of exiting on command failure - watch: fix SIGINT handler to resolve Promise instead of process.exit(0) so Ctrl+C returns to the menu rather than killing the process - add: replace BACK_VALUE hack in multiselect with multiselectPromptOrBack — Esc now navigates back to categories cleanly without a checkbox back option - add: unify preview card format between single-rule and multi-select flows - prompt: export runWatch, runList, runExplain; add multiselectPromptOrBack util
1 parent 1028d48 commit 3dd9bbd

6 files changed

Lines changed: 203 additions & 116 deletions

File tree

packages/cli/src/commands/add.ts

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { resolveContext } from '../core/resolve-context.js';
1717
import {
1818
selectPrompt,
1919
multiselectPrompt,
20+
multiselectPromptOrBack,
2021
confirmPrompt,
2122
introPrompt,
2223
outroPrompt,
@@ -719,32 +720,28 @@ async function runInteractive(cwd: string, options: AddOptions): Promise<void> {
719720
const category = registry.categories.find((c) => c.name === selectedCategoryName);
720721
if (!category) break;
721722

722-
const selected = await multiselectPrompt<string>({
723-
message: 'Select rules to add',
724-
options: [
725-
{ label: '\u2190 Back to categories', value: BACK_VALUE },
726-
...category.rules.map((r) => {
727-
const path = `${category.name}/${r.name}`;
728-
const installed = installedPaths.has(path);
729-
const desc = r.description ? ` ${ICONS.dash} ${r.description}` : '';
730-
const suffix = installed ? pc.dim(' (already installed)') : '';
731-
return {
732-
label: `${r.name}${desc}${suffix}`,
733-
value: r.name,
734-
};
735-
}),
736-
],
723+
const selected = await multiselectPromptOrBack<string>({
724+
message: `Select rules to add ${pc.dim('(Esc ← back)')}`,
725+
options: category.rules.map((r) => {
726+
const path = `${category.name}/${r.name}`;
727+
const installed = installedPaths.has(path);
728+
const desc = r.description ? ` ${ICONS.dash} ${r.description}` : '';
729+
const suffix = installed ? pc.dim(' (already installed)') : '';
730+
return {
731+
label: `${r.name}${desc}${suffix}`,
732+
value: r.name,
733+
};
734+
}),
737735
});
738736

739-
const realRules = selected.filter((v) => v !== BACK_VALUE);
737+
if (selected === null) continue;
740738

741-
if (realRules.length === 0) {
742-
if (selected.includes(BACK_VALUE)) continue;
739+
if (selected.length === 0) {
743740
ui.warn('No rules selected');
744741
continue;
745742
}
746743

747-
for (const ruleName of realRules) {
744+
for (const ruleName of selected) {
748745
const ruleInfo = category.rules.find((r) => r.name === ruleName);
749746
allSelected.push({
750747
category: category.name,
@@ -772,13 +769,15 @@ async function runInteractive(cwd: string, options: AddOptions): Promise<void> {
772769

773770
if (allSelected.length === 0) return;
774771

772+
const dest = '.dwf/rules/';
773+
const maxLen = Math.max(...allSelected.map((r) => `${r.category}/${r.name}`.length));
775774
const summaryLines = allSelected
776775
.map((r) => {
777-
const desc = r.description ? ` ${ICONS.dash} ${r.description}` : '';
778-
return `${r.category}/${r.name}${desc}`;
776+
const rulePath = `${r.category}/${r.name}`;
777+
return `${rulePath.padEnd(maxLen)} ${ICONS.arrow} ${dest}`;
779778
})
780779
.join('\n');
781-
notePrompt(summaryLines, 'Rules to install');
780+
notePrompt(summaryLines, `Installing ${pluralRules(allSelected.length)}`);
782781

783782
try {
784783
const shouldProceed = await confirmPrompt({
@@ -998,18 +997,10 @@ export async function runAdd(ruleArg: string | undefined, options: AddOptions):
998997

999998
// Preview card in interactive mode (without --force or --dry-run)
1000999
if (isInteractiveSession() && !options.force && !options.dryRun) {
1001-
const fileName = `pulled-${category}-${name}.yml`;
1002-
const ruleInfo = versionCheck?.registryRule;
1003-
1004-
const noteLines = [
1005-
ruleInfo?.description ?? source,
1006-
'',
1007-
`scope ${ruleInfo?.scope ?? category}`,
1008-
`version ${versionCheck?.registryVersion ?? 'unknown'}`,
1009-
`file .dwf/rules/${fileName}`,
1010-
].join('\n');
1011-
1012-
notePrompt(noteLines, source);
1000+
const dest = '.dwf/rules/';
1001+
const noteLines = `${source.padEnd(source.length)} ${ICONS.arrow} ${dest}`;
1002+
1003+
notePrompt(noteLines, `Installing 1 rule`);
10131004

10141005
try {
10151006
const confirmed = await confirmPrompt({ message: 'Install?', defaultValue: true });

packages/cli/src/commands/explain.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function formatSeparator(toolId: string): string {
5555
return pc.dim(`${prefix}${label}${suffix}`);
5656
}
5757

58-
async function runExplain(options: ExplainOptions): Promise<void> {
58+
export async function runExplain(options: ExplainOptions): Promise<void> {
5959
const resolved = await resolveContext(process.cwd());
6060

6161
if (!resolved) {

packages/cli/src/commands/list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ async function listAssets(typeFilter?: string): Promise<void> {
150150
}
151151
}
152152

153-
async function runList(subcommand: string | undefined): Promise<void> {
153+
export async function runList(subcommand: string | undefined): Promise<void> {
154154
if (!subcommand) {
155155
ui.error('Specify what to list', 'Usage: devw list <rules|tools|assets|commands|templates|hooks>');
156156
process.exitCode = 1;

packages/cli/src/commands/menu.ts

Lines changed: 100 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
11
import type { Command } from 'commander';
2+
import pc from 'picocolors';
23
import { runAdd } from './add.js';
34
import { runRemove } from './remove.js';
45
import { runDoctor } from './doctor.js';
56
import { runCompile } from './compile.js';
7+
import { runInit } from './init.js';
8+
import { runWatch } from './watch.js';
9+
import { runList } from './list.js';
10+
import { runExplain } from './explain.js';
611
import { renderBanner } from '../utils/banner.js';
7-
import { selectPrompt, introPrompt, outroPrompt, isInteractiveSession } from '../utils/prompt.js';
12+
import { selectPrompt, introPrompt, outroPrompt, notePrompt, isInteractiveSession } from '../utils/prompt.js';
13+
import { resolveContext } from '../core/resolve-context.js';
814

915
const MENU_CHOICES = {
1016
ADD: 'add',
1117
COMPILE: 'compile',
12-
DOCTOR: 'doctor',
18+
WATCH: 'watch',
1319
REMOVE: 'remove',
20+
LIST: 'list',
21+
EXPLAIN: 'explain',
22+
DOCTOR: 'doctor',
23+
INIT: 'init',
1424
EXIT: 'exit',
1525
} as const;
1626

1727
type MenuChoice = (typeof MENU_CHOICES)[keyof typeof MENU_CHOICES];
1828

29+
const LIST_CHOICES = {
30+
RULES: 'rules',
31+
TOOLS: 'tools',
32+
ASSETS: 'assets',
33+
} as const;
34+
35+
type ListChoice = (typeof LIST_CHOICES)[keyof typeof LIST_CHOICES];
36+
1937
export async function runMainMenu(command: Command): Promise<void> {
2038
if (!isInteractiveSession()) {
2139
command.help();
@@ -26,39 +44,95 @@ export async function runMainMenu(command: Command): Promise<void> {
2644
if (banner.length > 0) {
2745
console.log(banner);
2846
}
29-
introPrompt('Welcome to dev-workflows');
47+
48+
let isFirstRun = true;
3049

3150
while (true) {
51+
const ctx = await resolveContext(process.cwd());
52+
53+
if (isFirstRun) {
54+
if (ctx === null) {
55+
console.log(`\n ${pc.dim('○ No configuration found — run Init to get started')}`);
56+
} else {
57+
const mode = ctx.globalMode ? 'global mode' : 'local mode';
58+
const dirLabel = ctx.globalMode ? '~/.dwf/' : '.dwf/';
59+
introPrompt(`dev-workflows · ${mode} · ${dirLabel}`);
60+
}
61+
isFirstRun = false;
62+
}
63+
3264
let choice: MenuChoice;
33-
choice = await selectPrompt<MenuChoice>({
34-
message: 'What do you want to do?',
35-
options: [
36-
{ label: 'Add rules or assets', value: MENU_CHOICES.ADD },
37-
{ label: 'Compile for all editors', value: MENU_CHOICES.COMPILE },
38-
{ label: 'Check project status', value: MENU_CHOICES.DOCTOR },
39-
{ label: 'Remove something', value: MENU_CHOICES.REMOVE },
40-
{ label: 'Exit', value: MENU_CHOICES.EXIT },
41-
],
42-
});
65+
66+
if (ctx === null) {
67+
choice = await selectPrompt<MenuChoice>({
68+
message: 'What do you want to do?',
69+
options: [
70+
{ label: 'Init project', value: MENU_CHOICES.INIT },
71+
{ label: 'Exit', value: MENU_CHOICES.EXIT },
72+
],
73+
});
74+
} else {
75+
choice = await selectPrompt<MenuChoice>({
76+
message: 'What do you want to do?',
77+
options: [
78+
{ label: 'Add rules', value: MENU_CHOICES.ADD },
79+
{ label: 'Compile', value: MENU_CHOICES.COMPILE },
80+
{ label: 'Watch', value: MENU_CHOICES.WATCH },
81+
{ label: 'Remove', value: MENU_CHOICES.REMOVE },
82+
{ label: 'List', value: MENU_CHOICES.LIST },
83+
{ label: 'Explain', value: MENU_CHOICES.EXPLAIN },
84+
{ label: 'Check status', value: MENU_CHOICES.DOCTOR },
85+
{ label: 'Exit', value: MENU_CHOICES.EXIT },
86+
],
87+
});
88+
}
4389

4490
if (choice === MENU_CHOICES.EXIT) {
4591
outroPrompt('See you next time.');
4692
process.exit(0);
4793
}
4894

49-
switch (choice) {
50-
case MENU_CHOICES.ADD:
51-
await runAdd(undefined, {});
52-
break;
53-
case MENU_CHOICES.COMPILE:
54-
await runCompile({ verbose: false, dryRun: false });
55-
break;
56-
case MENU_CHOICES.DOCTOR:
57-
await runDoctor();
58-
break;
59-
case MENU_CHOICES.REMOVE:
60-
await runRemove(undefined);
61-
break;
95+
try {
96+
switch (choice) {
97+
case MENU_CHOICES.INIT:
98+
await runInit({});
99+
isFirstRun = true;
100+
break;
101+
case MENU_CHOICES.ADD:
102+
await runAdd(undefined, {});
103+
break;
104+
case MENU_CHOICES.COMPILE:
105+
await runCompile({ verbose: false, dryRun: false });
106+
break;
107+
case MENU_CHOICES.WATCH:
108+
notePrompt('Press Ctrl+C to stop watching and return to menu', 'Watch mode');
109+
await runWatch({});
110+
break;
111+
case MENU_CHOICES.REMOVE:
112+
await runRemove(undefined);
113+
break;
114+
case MENU_CHOICES.LIST: {
115+
const listChoice = await selectPrompt<ListChoice>({
116+
message: 'List what?',
117+
options: [
118+
{ label: 'Rules', value: LIST_CHOICES.RULES },
119+
{ label: 'Tools', value: LIST_CHOICES.TOOLS },
120+
{ label: 'Assets', value: LIST_CHOICES.ASSETS },
121+
],
122+
});
123+
await runList(listChoice);
124+
break;
125+
}
126+
case MENU_CHOICES.EXPLAIN:
127+
await runExplain({});
128+
break;
129+
case MENU_CHOICES.DOCTOR:
130+
await runDoctor();
131+
break;
132+
}
133+
} catch (err) {
134+
const message = err instanceof Error ? err.message : String(err);
135+
console.error(`\n ${pc.red('✗')} ${message}\n`);
62136
}
63137
}
64138
}

0 commit comments

Comments
 (0)