feat: Replace figlet with @clack/prompts for polished CLI experience

Replaces ASCII art banner and basic readline prompts with @clack/prompts for a modern interactive CLI. Adds animated shimmer progress bars with spinner glyphs during indexing operations. Improves installer UX with structured prompts, better error handling, and cleaner output formatting throughout all CLI commands.
This commit is contained in:
Colby McHenry
2026-04-04 11:07:18 -05:00
parent 3a44d5c4d1
commit ed35d65f4c
8 changed files with 521 additions and 484 deletions
+214 -127
View File
@@ -45,6 +45,12 @@ async function loadCodeGraph(): Promise<typeof import('../index')> {
type IndexProgress = import('../index').IndexProgress;
// Dynamic import helper — tsc compiles import() to require() in CJS mode,
// which fails for ESM-only packages. This bypasses the transformation.
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const importESM = new Function('specifier', 'return import(specifier)') as
(specifier: string) => Promise<typeof import('@clack/prompts')>;
// Check if running with no arguments - run installer
if (process.argv.length === 2) {
import('../installer').then(({ runInstaller }) =>
@@ -168,59 +174,140 @@ function formatDuration(ms: number): string {
return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
}
/**
* Create a progress bar string
*/
function progressBar(current: number, total: number, width: number = 30): string {
const percent = total > 0 ? current / total : 0;
const filled = Math.round(width * percent);
const empty = width - filled;
const bar = chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
const percentStr = `${Math.round(percent * 100)}%`.padStart(4);
return `${bar} ${percentStr}`;
// =============================================================================
// Shimmer Progress Renderer (devpit-style animation)
// =============================================================================
const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
const ANIM_INTERVAL = 150;
const FRAMES_PER_GLYPH = 3;
function lerp(a: number, b: number, t: number): number {
return Math.round(a + (b - a) * t);
}
/**
* Truncate a string to fit a given visible width, adding ellipsis if needed
*/
function truncate(str: string, maxWidth: number): string {
if (maxWidth <= 0) return '';
if (str.length <= maxWidth) return str;
if (maxWidth <= 1) return str.charAt(0);
return '\u2026' + str.slice(-(maxWidth - 1));
function shimmerColor(frame: number): string {
const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2;
const r = lerp(160, 251, t);
const g = lerp(100, 191, t);
const b = lerp(9, 36, t);
return `\x1b[38;2;${r};${g};${b}m\x1b[1m`;
}
/**
* Print a progress update (overwrites current line)
*/
function printProgress(progress: IndexProgress): void {
const phaseNames: Record<string, string> = {
scanning: 'Scanning files',
parsing: 'Parsing code',
storing: 'Storing data',
resolving: 'Resolving refs',
};
const PHASE_NAMES: Record<string, string> = {
scanning: 'Scanning files',
parsing: 'Parsing code',
storing: 'Storing data',
resolving: 'Resolving refs',
};
const phaseName = phaseNames[progress.phase] || progress.phase;
const cols = process.stdout.columns || 80;
interface ShimmerProgress {
onProgress: (progress: IndexProgress) => void;
stop: () => void;
}
if (progress.total > 0) {
const bar = progressBar(progress.current, progress.total);
// "Phase: [bar] XX% filename" — calculate space left for filename
// phaseName + ": " = phaseName.length + 2, bar visible = 30 + 1 + 4 = 35, space before file = 1
const prefixWidth = phaseName.length + 2 + 35;
const fileMaxWidth = cols - prefixWidth - 1;
const file = progress.currentFile ? chalk.dim(` ${truncate(progress.currentFile, fileMaxWidth)}`) : '';
process.stdout.write(`\r${chalk.cyan(phaseName)}: ${bar}${file}\x1b[K`);
} else {
// No known total (e.g. scanning) — show a running count
const countStr = progress.current > 0 ? ` ${formatNumber(progress.current)} found` : '';
const prefixWidth = phaseName.length + 1 + countStr.length;
const fileMaxWidth = cols - prefixWidth - 1;
const file = progress.currentFile ? chalk.dim(` ${truncate(progress.currentFile, fileMaxWidth)}`) : '';
const count = progress.current > 0 ? ` ${chalk.green(formatNumber(progress.current))} found` : '';
process.stdout.write(`\r${chalk.cyan(phaseName)}:${count}${file}\x1b[K`);
function createShimmerProgress(): ShimmerProgress {
const startTime = Date.now();
let interval: ReturnType<typeof setInterval> | null = null;
let currentMessage = '';
let currentPercent = -1;
let currentCount = 0;
let lastPhase = '';
function animFrame(): number {
return Math.floor((Date.now() - startTime) / ANIM_INTERVAL);
}
function spinnerGlyph(): string {
const idx = Math.floor(animFrame() / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length;
return SPINNER_GLYPHS[idx] ?? '·';
}
function renderBar(filled: number, empty: number): string {
if (filled === 0) return `${colors.dim}${'░'.repeat(empty)}${colors.reset}`;
// Shimmer sweeps left-to-right across the filled portion
const cycleFrames = 24;
const shimmerPos = ((animFrame() % cycleFrames) / cycleFrames) * (filled + 6) - 3;
const shimmerWidth = 3;
let bar = '';
for (let i = 0; i < filled; i++) {
const dist = Math.abs(i - shimmerPos);
const t = Math.max(0, 1 - dist / shimmerWidth);
const r = lerp(160, 251, t);
const g = lerp(100, 191, t);
const b = lerp(9, 36, t);
bar += `\x1b[38;2;${r};${g};${b}m\x1b[1m█`;
}
bar += `${colors.reset}${colors.dim}${'░'.repeat(empty)}${colors.reset}`;
return bar;
}
function render() {
const glyph = spinnerGlyph();
const frame = animFrame();
const color = shimmerColor(frame);
const rst = colors.reset;
const dm = colors.dim;
let line: string;
if (currentPercent >= 0) {
const barWidth = 25;
const filled = Math.round(barWidth * currentPercent / 100);
const empty = barWidth - filled;
line = `${dm}${rst} ${color}${glyph}${rst} ${currentMessage} ${renderBar(filled, empty)} ${currentPercent}%`;
} else if (currentCount > 0) {
line = `${dm}${rst} ${color}${glyph}${rst} ${currentMessage}... ${formatNumber(currentCount)} found`;
} else {
line = `${dm}${rst} ${color}${glyph}${rst} ${currentMessage}...`;
}
process.stdout.write(`\r\x1b[K${line}`);
}
function finishPhase() {
if (!currentMessage) return;
process.stdout.write(`\r\x1b[K`);
let detail = '';
if (currentPercent >= 0) detail = ' — done';
else if (currentCount > 0) detail = `${formatNumber(currentCount)} found`;
process.stdout.write(`${colors.dim}${colors.reset} ${colors.green}${colors.reset} ${currentMessage}${detail}\n`);
}
// Start animation loop
interval = setInterval(render, ANIM_INTERVAL);
return {
onProgress(progress: IndexProgress) {
const phaseName = PHASE_NAMES[progress.phase] || progress.phase;
if (progress.phase !== lastPhase && lastPhase) {
finishPhase();
}
lastPhase = progress.phase;
currentMessage = phaseName;
if (progress.total > 0) {
currentPercent = Math.round((progress.current / progress.total) * 100);
currentCount = 0;
} else if (progress.current > 0) {
currentPercent = -1;
currentCount = progress.current;
} else {
currentPercent = -1;
currentCount = 0;
}
// Render immediately — scanning is synchronous so setInterval can't fire
render();
},
stop() {
if (interval) {
clearInterval(interval);
interval = null;
}
finishPhase();
},
};
}
/**
@@ -251,30 +338,37 @@ function warn(message: string): void {
console.log(chalk.yellow('⚠') + ' ' + message);
}
type IndexResult = {
success: boolean;
filesIndexed: number;
filesSkipped: number;
filesErrored: number;
nodesCreated: number;
edgesCreated: number;
errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>;
durationMs: number;
};
/**
* Print a summary of indexing results with clear error breakdown
* Print indexing results using clack log methods
*/
function printIndexResult(result: { success: boolean; filesIndexed: number; filesSkipped: number; filesErrored: number; nodesCreated: number; edgesCreated: number; errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>; durationMs: number }, projectPath?: string): void {
function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void {
const hasErrors = result.filesErrored > 0;
// Always show what was indexed
if (result.filesIndexed > 0) {
if (hasErrors) {
success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`);
clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`);
} else {
success(`Indexed ${formatNumber(result.filesIndexed)} files`);
clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`);
}
info(`Created ${formatNumber(result.nodesCreated)} nodes and ${formatNumber(result.edgesCreated)} edges`);
info(`Completed in ${formatDuration(result.durationMs)}`);
clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
} else if (hasErrors) {
error(`Indexing failed — all ${formatNumber(result.filesErrored)} files had errors`);
clack.log.error(`Indexing failed — all ${formatNumber(result.filesErrored)} files had errors`);
} else {
warn('No files found to index');
clack.log.warn('No files found to index');
}
// Show error breakdown if there were errors
if (hasErrors) {
// Group errors by code for a concise summary
const errorsByCode = new Map<string, number>();
for (const err of result.errors) {
if (err.severity === 'error') {
@@ -292,26 +386,20 @@ function printIndexResult(result: { success: boolean; filesIndexed: number; file
parser_error: 'parser initialization failures',
};
console.log('');
console.log(chalk.dim(' Error breakdown:'));
for (const [code, count] of errorsByCode) {
const label = codeLabels[code] || code;
console.log(chalk.dim(` ${formatNumber(count)} ${label}`));
}
const breakdown = Array.from(errorsByCode)
.map(([code, count]) => `${formatNumber(count)} ${codeLabels[code] || code}`)
.join('\n');
clack.note(breakdown, 'Error breakdown');
// Write detailed error log to .codegraph/errors.log
if (projectPath) {
writeErrorLog(projectPath, result.errors);
clack.log.info('See .codegraph/errors.log for details');
}
// Reassure the user the index is usable
if (result.filesIndexed > 0) {
console.log('');
info('The index is fully usable — only the failed files are missing from the graph.');
info('This is common in large repos with test fixtures or generated files that use non-standard syntax.');
clack.log.info('The index is fully usable — only the failed files are missing.');
}
} else if (projectPath) {
// No errors — clean up any stale error log
const logPath = path.join(projectPath, '.codegraph', 'errors.log');
if (fs.existsSync(logPath)) {
fs.unlinkSync(logPath);
@@ -363,7 +451,6 @@ function writeErrorLog(projectPath: string, errors: Array<{ message: string; fil
}
fs.writeFileSync(logPath, lines.join('\n') + '\n');
info(`See .codegraph/errors.log for the full list of failed files`);
}
// =============================================================================
@@ -378,47 +465,41 @@ program
.description('Initialize CodeGraph in a project directory')
.option('-i, --index', 'Run initial indexing after initialization')
.action(async (pathArg: string | undefined, options: { index?: boolean }) => {
// init should always target the exact path given (or cwd), never walk up parents
const projectPath = path.resolve(pathArg || process.cwd());
const clack = await importESM('@clack/prompts');
console.log(chalk.bold('\nInitializing CodeGraph...\n'));
clack.intro('Initializing CodeGraph');
try {
// Check if already initialized
if (isInitialized(projectPath)) {
warn(`CodeGraph already initialized in ${projectPath}`);
info('Use "codegraph index" to re-index or "codegraph sync" to update');
clack.log.warn(`Already initialized in ${projectPath}`);
clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update');
clack.outro('');
return;
}
// Initialize
const { default: CodeGraph } = await loadCodeGraph();
const cg = await CodeGraph.init(projectPath, {
index: false, // We'll handle indexing ourselves for progress
});
const cg = await CodeGraph.init(projectPath, { index: false });
clack.log.success(`Initialized in ${projectPath}`);
success(`Initialized CodeGraph in ${projectPath}`);
info(`Created .codegraph/ directory`);
// Run initial index if requested
if (options.index) {
console.log('\nIndexing project...\n');
process.stdout.write(`${colors.dim}${colors.reset}\n`);
const progress = createShimmerProgress();
const result = await cg.indexAll({
onProgress: printProgress,
onProgress: progress.onProgress,
});
// Clear progress line
process.stdout.write('\r\x1b[K');
printIndexResult(result, projectPath);
progress.stop();
printIndexResult(clack, result, projectPath);
} else {
info('Run "codegraph index" to index the project');
clack.log.info('Run "codegraph index" to index the project');
}
clack.outro('Done');
cg.destroy();
} catch (err) {
error(`Failed to initialize: ${err instanceof Error ? err.message : String(err)}`);
clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});
@@ -489,35 +570,38 @@ program
const { default: CodeGraph } = await loadCodeGraph();
const cg = await CodeGraph.open(projectPath);
if (!options.quiet) {
console.log(chalk.bold('\nIndexing project...\n'));
if (options.quiet) {
// Quiet mode: no UI, just run
if (options.force) cg.clear();
const result = await cg.indexAll();
if (!result.success) process.exit(1);
cg.destroy();
return;
}
// Clear existing data if force
const clack = await importESM('@clack/prompts');
clack.intro('Indexing project');
if (options.force) {
cg.clear();
if (!options.quiet) {
info('Cleared existing index');
}
clack.log.info('Cleared existing index');
}
process.stdout.write(`${colors.dim}${colors.reset}\n`);
const progress = createShimmerProgress();
const result = await cg.indexAll({
onProgress: options.quiet ? undefined : printProgress,
onProgress: progress.onProgress,
});
// Clear progress line
if (!options.quiet) {
process.stdout.write('\r\x1b[K');
}
if (!options.quiet) {
printIndexResult(result, projectPath);
}
progress.stop();
printIndexResult(clack, result, projectPath);
if (!result.success) {
process.exit(1);
}
clack.outro('Done');
cg.destroy();
} catch (err) {
error(`Failed to index: ${err instanceof Error ? err.message : String(err)}`);
@@ -546,35 +630,38 @@ program
const { default: CodeGraph } = await loadCodeGraph();
const cg = await CodeGraph.open(projectPath);
if (options.quiet) {
await cg.sync();
cg.destroy();
return;
}
const clack = await importESM('@clack/prompts');
clack.intro('Syncing CodeGraph');
process.stdout.write(`${colors.dim}${colors.reset}\n`);
const progress = createShimmerProgress();
const result = await cg.sync({
onProgress: options.quiet ? undefined : printProgress,
onProgress: progress.onProgress,
});
// Clear progress line
if (!options.quiet) {
process.stdout.write('\r\x1b[K');
}
progress.stop();
const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved;
if (!options.quiet) {
if (totalChanges === 0) {
success('Already up to date');
} else {
success(`Synced ${formatNumber(totalChanges)} changed files`);
if (result.filesAdded > 0) {
info(` Added: ${result.filesAdded}`);
}
if (result.filesModified > 0) {
info(` Modified: ${result.filesModified}`);
}
if (result.filesRemoved > 0) {
info(` Removed: ${result.filesRemoved}`);
}
info(`Updated ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
}
if (totalChanges === 0) {
clack.log.info('Already up to date');
} else {
clack.log.success(`Synced ${formatNumber(totalChanges)} changed files`);
const details: string[] = [];
if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`);
if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`);
if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`);
clack.log.info(`${details.join(', ')}${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
}
clack.outro('Done');
cg.destroy();
} catch (err) {
if (!options.quiet) {