UNPKG

claude-git-hooks

Version:

Git hooks with Claude CLI for code analysis and automatic commit messages

315 lines (270 loc) 10 kB
/** * File: interactive-ui.js * Purpose: Interactive UI helpers for command-line prompts * * Features: * - PR preview with colored output * - Confirmation prompts * - Field editing * - Keyboard navigation */ import readline from 'readline'; import logger from './logger.js'; /** * Show PR preview in formatted box * Why: Give user visual preview before creating PR * * @param {Object} prData - PR data to preview * @param {string} prData.title - PR title * @param {string} prData.body - PR description * @param {string} prData.base - Base branch * @param {string} prData.head - Head branch * @param {Array<string>} prData.labels - Labels * @param {Array<string>} prData.reviewers - Reviewers */ export const showPRPreview = (prData) => { const { title, body, base, head, labels = [], reviewers = [] } = prData; console.log(''); console.log('┌───────────────────────────────────────────────────────────────┐'); console.log(' PR Preview'); console.log('├───────────────────────────────────────────────────────────────┤'); console.log(` Title: ${truncate(title, 55)}`); console.log(` From: ${truncate(head, 55)}`); console.log(` To: ${truncate(base, 55)}`); if (labels.length > 0) { console.log(` Labels: ${truncate(labels.join(', '), 53)}`); } if (reviewers.length > 0) { console.log(` Reviewers: ${truncate(reviewers.join(', '), 50)}`); } console.log('├───────────────────────────────────────────────────────────────┤'); console.log(' Description:'); // Show first 5 lines of body const bodyLines = body.split('\n').slice(0, 5); bodyLines.forEach(line => { console.log(` ${truncate(line, 61)}`); }); if (body.split('\n').length > 5) { console.log(' ... (truncated)'); } console.log('└───────────────────────────────────────────────────────────────┘'); console.log(''); }; /** * Truncate string to fit in box * @private */ const truncate = (str, maxLen) => { const padded = str.padEnd(maxLen, ' '); return padded.length > maxLen ? padded.substring(0, maxLen - 3) + '...' : padded; }; /** * Prompt user for confirmation with custom message * Why: Get yes/no confirmation before destructive operations * * @param {string} message - Question to ask * @param {boolean} defaultValue - Default if user presses Enter (default: true) * @returns {Promise<boolean>} - True if confirmed, false otherwise */ export const promptConfirmation = async (message, defaultValue = true) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { const defaultText = defaultValue ? 'Y/n' : 'y/N'; const promptMessage = `${message} (${defaultText}): `; rl.question(promptMessage, (answer) => { rl.close(); const trimmed = answer.trim().toLowerCase(); // If empty, use default if (!trimmed) { logger.debug('interactive-ui - promptConfirmation', 'Using default', { defaultValue }); resolve(defaultValue); return; } // Check yes/no const isYes = ['y', 'yes', 'si', 's'].includes(trimmed); const isNo = ['n', 'no'].includes(trimmed); if (isYes) { logger.debug('interactive-ui - promptConfirmation', 'User confirmed'); resolve(true); } else if (isNo) { logger.debug('interactive-ui - promptConfirmation', 'User declined'); resolve(false); } else { // Invalid input, use default logger.debug('interactive-ui - promptConfirmation', 'Invalid input, using default', { answer: trimmed, defaultValue }); resolve(defaultValue); } }); }); }; /** * Prompt user to edit a field value * Why: Allow user to modify PR fields before creation * * @param {string} fieldName - Name of field (e.g., "Title", "Description") * @param {string} currentValue - Current value * @returns {Promise<string>} - Updated value (or current if user skips) */ export const promptEditField = async (fieldName, currentValue) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { console.log(`\nCurrent ${fieldName}:`); console.log(currentValue); console.log(''); const promptMessage = `New ${fieldName} (press Enter to keep current): `; rl.question(promptMessage, (answer) => { rl.close(); const trimmed = answer.trim(); if (!trimmed) { logger.debug('interactive-ui - promptEditField', 'Keeping current value', { fieldName }); resolve(currentValue); } else { logger.debug('interactive-ui - promptEditField', 'Updated value', { fieldName }); resolve(trimmed); } }); }); }; /** * Show menu with options and get user choice * Why: Present multiple options to user * * @param {string} message - Menu message * @param {Array<{key: string, label: string}>} options - Menu options * @param {string} defaultKey - Default option key if user presses Enter * @returns {Promise<string>} - Selected option key */ export const promptMenu = async (message, options, defaultKey = null) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { console.log(''); console.log(message); console.log(''); // Display options options.forEach(opt => { const isDefault = opt.key === defaultKey; const marker = isDefault ? '→' : ' '; console.log(` ${marker} [${opt.key}] ${opt.label}`); }); console.log(''); const defaultText = defaultKey ? ` (default: ${defaultKey})` : ''; const promptMessage = `Choose an option${defaultText}: `; rl.question(promptMessage, (answer) => { rl.close(); const trimmed = answer.trim().toLowerCase(); // If empty, use default if (!trimmed && defaultKey) { logger.debug('interactive-ui - promptMenu', 'Using default', { defaultKey }); resolve(defaultKey); return; } // Check if valid option const selectedOption = options.find(opt => opt.key.toLowerCase() === trimmed); if (selectedOption) { logger.debug('interactive-ui - promptMenu', 'Option selected', { key: selectedOption.key }); resolve(selectedOption.key); } else { // Invalid, use default or first option const fallback = defaultKey || options[0]?.key; logger.debug('interactive-ui - promptMenu', 'Invalid option, using fallback', { answer: trimmed, fallback }); resolve(fallback); } }); }); }; /** * Show loading spinner with message * Why: Provide visual feedback during long operations * * @param {string} message - Loading message * @returns {Function} - Stop function to clear spinner */ export const showSpinner = (message) => { const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let frameIndex = 0; let isActive = true; const interval = setInterval(() => { if (!isActive) { clearInterval(interval); return; } const frame = frames[frameIndex]; process.stdout.write(`\r${frame} ${message}`); frameIndex = (frameIndex + 1) % frames.length; }, 80); // Return stop function return () => { isActive = false; clearInterval(interval); process.stdout.write('\r'); // Clear line }; }; /** * Show success message with checkmark * @param {string} message - Success message */ export const showSuccess = (message) => { console.log(`✅ ${message}`); }; /** * Show error message with X mark * @param {string} message - Error message */ export const showError = (message) => { console.log(`❌ ${message}`); }; /** * Show warning message with warning sign * @param {string} message - Warning message */ export const showWarning = (message) => { console.log(`⚠️ ${message}`); }; /** * Show info message with info icon * @param {string} message - Info message */ export const showInfo = (message) => { console.log(`ℹ️ ${message}`); }; /** * Clear console screen * Why: Clean slate for new UI sections */ export const clearScreen = () => { console.clear(); }; /** * Wait for user to press Enter * Why: Pause before continuing * * @param {string} message - Message to show (default: "Press Enter to continue") * @returns {Promise<void>} */ export const waitForEnter = async (message = 'Press Enter to continue') => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(`\n${message}... `, () => { rl.close(); resolve(); }); }); };