claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
315 lines (270 loc) • 10 kB
JavaScript
/**
* 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();
});
});
};