UNPKG

@akiojin/claude-worktree

Version:

Interactive Git worktree manager for Claude Code with graphical branch selection

1,260 lines 47.8 kB
import { select, input, confirm, checkbox } from '@inquirer/prompts'; import chalk from 'chalk'; /** * Custom select prompt with q key support for going back * @param config - prompt configuration * @returns selected value or null if user pressed q */ async function createQuitableSelect(config) { const { createPrompt, useState, useKeypress, isEnterKey, usePrefix, isUpKey, isDownKey } = await import('@inquirer/core'); const customSelect = createPrompt((promptConfig, done) => { const [selectedIndex, setSelectedIndex] = useState(0); const [status, setStatus] = useState('idle'); const prefix = usePrefix({}); useKeypress((key) => { if (status === 'done') return; if (key.name === 'q' || (key.name === 'c' && key.ctrl)) { setStatus('done'); done(null); return; } if (isEnterKey(key)) { const selectedChoice = promptConfig.choices[selectedIndex]; if (!selectedChoice) return; if (selectedChoice.disabled) { return; } setStatus('done'); done(selectedChoice.value); return; } if (isUpKey(key)) { let newIndex = selectedIndex > 0 ? selectedIndex - 1 : promptConfig.choices.length - 1; // Skip disabled items while (promptConfig.choices[newIndex]?.disabled && newIndex !== selectedIndex) { newIndex = newIndex > 0 ? newIndex - 1 : promptConfig.choices.length - 1; } setSelectedIndex(newIndex); } else if (isDownKey(key)) { let newIndex = selectedIndex < promptConfig.choices.length - 1 ? selectedIndex + 1 : 0; // Skip disabled items while (promptConfig.choices[newIndex]?.disabled && newIndex !== selectedIndex) { newIndex = newIndex < promptConfig.choices.length - 1 ? newIndex + 1 : 0; } setSelectedIndex(newIndex); } }); if (status === 'done') { return ''; } const message = promptConfig.message; const choicesDisplay = promptConfig.choices.map((choice, index) => { const isSelected = index === selectedIndex; const pointer = isSelected ? '❯' : ' '; const nameDisplay = choice.disabled ? chalk.gray(choice.name) : (isSelected ? chalk.cyan(choice.name) : choice.name); const description = choice.description && isSelected ? `\n ${choice.disabled ? chalk.gray(choice.description) : chalk.gray(choice.description)}` : ''; return `${pointer} ${nameDisplay}${description}`; }).join('\n'); return `${prefix} ${message}\n${choicesDisplay}`; }); return await customSelect(config); } export async function selectFromTable(choices, statistics) { // Display statistics if provided if (statistics) { const { printStatistics, printWelcome } = await import('./display.js'); console.clear(); await printWelcome(); await printStatistics(statistics.branches, statistics.worktrees); } return await selectBranchWithShortcuts(choices); } async function selectBranchWithShortcuts(allChoices) { const { createPrompt, useState, useKeypress, isEnterKey, usePrefix } = await import('@inquirer/core'); const branchSelectPrompt = createPrompt((config, done) => { const [selectedIndex, setSelectedIndex] = useState(0); const [status, setStatus] = useState('idle'); const prefix = usePrefix({}); useKeypress((key) => { if (key.name === 'n') { setStatus('done'); done('__create_new__'); return; } if (key.name === 'm') { setStatus('done'); done('__manage_worktrees__'); return; } if (key.name === 'c') { setStatus('done'); done('__cleanup_prs__'); return; } if (key.name === 'q') { setStatus('done'); done('__exit__'); return; } if (key.name === 'up' || key.name === 'k') { // 最上部で停止(ループしない) if (selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); } return; } if (key.name === 'down' || key.name === 'j') { // 選択可能な項目数に基づいて制限 const selectableChoices = config.choices.filter(c => c.value !== '__header__' && c.value !== '__separator__' && !c.disabled); // 最下部で停止(ループしない) if (selectedIndex < selectableChoices.length - 1) { setSelectedIndex(selectedIndex + 1); } return; } if (isEnterKey(key)) { // 選択可能な項目のみから選択 const selectableChoices = config.choices.filter(c => c.value !== '__header__' && c.value !== '__separator__' && !c.disabled); const selectedChoice = selectableChoices[selectedIndex]; if (selectedChoice) { setStatus('done'); done(selectedChoice.value); } return; } }); if (status === 'done') { return `${prefix} ${config.message}`; } // ヘッダー行とセパレーター行を探す const headerChoice = config.choices.find(c => c.value === '__header__'); const separatorChoice = config.choices.find(c => c.value === '__separator__'); // 選択可能な項目のみをフィルタリング const selectableChoices = config.choices.filter(c => c.value !== '__header__' && c.value !== '__separator__' && !c.disabled); const pageSize = config.pageSize || 15; let output = `${prefix} ${config.message}\n`; output += 'Actions: (n) Create new branch, (m) Manage worktrees, (c) Clean up merged PRs, (q) Exit\n\n'; // ヘッダー行とセパレーター行を表示 if (headerChoice) { output += ` ${headerChoice.name}\n`; } if (separatorChoice) { output += ` ${separatorChoice.name}\n`; } // 選択可能な項目のみを表示(ページネーション付き) const selectableStartIndex = Math.max(0, selectedIndex - Math.floor(pageSize / 2)); const selectableEndIndex = Math.min(selectableChoices.length, selectableStartIndex + pageSize); const visibleSelectableChoices = selectableChoices.slice(selectableStartIndex, selectableEndIndex); visibleSelectableChoices.forEach((choice, index) => { const globalIndex = selectableStartIndex + index; const cursor = globalIndex === selectedIndex ? '❯' : ' '; output += `${cursor} ${choice.name}\n`; }); return output; }); return await branchSelectPrompt({ message: 'Select a branch:', choices: allChoices, pageSize: 15 }); } export async function selectBranchType() { return await select({ message: 'Select branch type:', choices: [ { name: '🚀 Feature', value: 'feature', description: 'A new feature branch' }, { name: '🔥 Hotfix', value: 'hotfix', description: 'A critical bug fix' }, { name: '📦 Release', value: 'release', description: 'A release preparation branch' } ] }); } export async function selectVersionBumpType(currentVersion) { const versionParts = currentVersion.split('.'); const major = parseInt(versionParts[0] || '0'); const minor = parseInt(versionParts[1] || '0'); const patch = parseInt(versionParts[2] || '0'); return await select({ message: `Current version: ${currentVersion}. Select version bump type:`, choices: [ { name: `📌 Patch (${major}.${minor}.${patch + 1})`, value: 'patch', description: 'Bug fixes and minor changes' }, { name: `📈 Minor (${major}.${minor + 1}.0)`, value: 'minor', description: 'New features, backwards compatible' }, { name: `🚀 Major (${major + 1}.0.0)`, value: 'major', description: 'Breaking changes' } ] }); } export async function inputBranchName(type) { return await input({ message: `Enter ${type} name:`, validate: (value) => { if (!value.trim()) { return 'Branch name cannot be empty'; } if (/[\s\\/:*?"<>|]/.test(value.trim())) { return 'Branch name cannot contain spaces or special characters (\\/:*?"<>|)'; } return true; }, transformer: (value) => value.trim() }); } export async function selectBaseBranch(branches) { const mainBranches = branches.filter(b => b.type === 'local' && (b.branchType === 'main' || b.branchType === 'develop')); if (mainBranches.length === 0) { throw new Error('No main or develop branch found'); } if (mainBranches.length === 1 && mainBranches[0]) { return mainBranches[0].name; } return await select({ message: 'Select base branch:', choices: mainBranches.map(branch => ({ name: branch.name, value: branch.name, description: `${branch.branchType} branch` })) }); } export async function confirmWorktreeCreation(branchName, worktreePath) { return await confirm({ message: `Create worktree for "${branchName}" at "${worktreePath}"?`, default: true }); } export async function confirmWorktreeRemoval(worktreePath) { return await confirm({ message: `Remove worktree at "${worktreePath}"?`, default: false }); } export async function getNewBranchConfig() { const type = await selectBranchType(); // リリースブランチの場合は、バージョン選択後に自動生成されるため、 // ここでは仮の値を返す if (type === 'release') { return { type, taskName: 'version-placeholder', branchName: 'release/version-placeholder' }; } const taskName = await inputBranchName(type); const branchName = `${type}/${taskName}`; return { type, taskName, branchName }; } export async function confirmSkipPermissions() { return await confirm({ message: 'Skip Claude Code permissions check (--dangerously-skip-permissions)?', default: false }); } export async function selectWorktreeForManagement(worktrees) { const { createPrompt, useState, useKeypress, isEnterKey, usePrefix, isUpKey, isDownKey } = await import('@inquirer/core'); // Custom prompt that handles 'q' key const customSelect = createPrompt((config, done) => { const [selectedIndex, setSelectedIndex] = useState(0); const [status, setStatus] = useState('idle'); const prefix = usePrefix({}); useKeypress((key) => { if (status === 'done') return; if (key.name === 'q' || (key.name === 'c' && key.ctrl)) { setStatus('done'); done(null); // Return null for cancelled return; } if (isEnterKey(key)) { const selectedChoice = config.choices[selectedIndex]; if (selectedChoice.disabled) { // Don't allow selection of disabled items return; } setStatus('done'); done(selectedChoice.value); return; } if (isUpKey(key)) { let newIndex = selectedIndex > 0 ? selectedIndex - 1 : config.choices.length - 1; // Skip disabled items while (config.choices[newIndex].disabled && newIndex !== selectedIndex) { newIndex = newIndex > 0 ? newIndex - 1 : config.choices.length - 1; } setSelectedIndex(newIndex); } else if (isDownKey(key)) { let newIndex = selectedIndex < config.choices.length - 1 ? selectedIndex + 1 : 0; // Skip disabled items while (config.choices[newIndex].disabled && newIndex !== selectedIndex) { newIndex = newIndex < config.choices.length - 1 ? newIndex + 1 : 0; } setSelectedIndex(newIndex); } }); if (status === 'done') { return ''; } const message = config.message; const choicesDisplay = config.choices.map((choice, index) => { const isSelected = index === selectedIndex; const pointer = isSelected ? '❯' : ' '; const nameDisplay = choice.disabled ? chalk.gray(choice.name) : (isSelected ? chalk.cyan(choice.name) : choice.name); const description = choice.description ? `\n ${choice.disabled ? chalk.gray(choice.description) : chalk.gray(choice.description)}` : ''; return `${pointer} ${nameDisplay}${isSelected ? description : ''}`; }).join('\n'); return `${prefix} ${message}\n${choicesDisplay}`; }); const choices = worktrees.map((w, index) => { const isInvalid = w.isAccessible === false; return { name: isInvalid ? `${index + 1}. ✗ ${w.branch}` : `${index + 1}. ${w.branch}`, value: w.branch, description: isInvalid ? `${w.path} (${w.invalidReason || 'Inaccessible'})` : w.path, disabled: isInvalid }; }); const result = await customSelect({ message: 'Select worktree to manage (q to go back):', choices }); return result === null ? 'back' : result; } export async function selectWorktreeAction() { const result = await createQuitableSelect({ message: 'What would you like to do (q to go back)?', choices: [ { name: '📂 Open in Claude Code', value: 'open', description: 'Launch Claude Code in this worktree' }, { name: '🗑️ Remove worktree', value: 'remove', description: 'Delete this worktree only' }, { name: '🔥 Remove worktree and branch', value: 'remove-branch', description: 'Delete both worktree and branch' } ] }); return result === null ? 'back' : result; } export async function confirmBranchRemoval(branchName) { return await confirm({ message: `Are you sure you want to delete the branch "${branchName}"? This cannot be undone.`, default: false }); } export async function selectChangesAction() { return await select({ message: 'Changes detected in worktree. What would you like to do?', choices: [ { name: '📋 View changes (git status)', value: 'status', description: 'Show modified files' }, { name: '💾 Commit changes', value: 'commit', description: 'Create a new commit' }, { name: '📦 Stash changes', value: 'stash', description: 'Save changes for later' }, { name: '🗑️ Discard changes', value: 'discard', description: 'Discard all changes (careful!)' }, { name: '➡️ Continue without action', value: 'continue', description: 'Return to main menu' } ] }); } export async function inputCommitMessage() { return await input({ message: 'Enter commit message:', validate: (value) => { if (!value.trim()) { return 'Commit message cannot be empty'; } return true; } }); } export async function confirmDiscardChanges() { return await confirm({ message: 'Are you sure you want to discard all changes? This cannot be undone.', default: false }); } export async function confirmContinue(message = 'Continue?') { return await confirm({ message, default: true }); } export async function selectCleanupTargets(targets) { if (targets.length === 0) { return []; } const choices = targets.map(target => ({ name: `${target.branch} (PR #${target.pullRequest.number}: ${target.pullRequest.title})`, value: target, disabled: target.hasUncommittedChanges ? 'Has uncommitted changes' : false, checked: !target.hasUncommittedChanges })); const selected = await checkbox({ message: 'Select worktrees to clean up (merged PRs):', choices, pageSize: 15, instructions: 'Space to select, Enter to confirm' }); return selected; } export async function confirmCleanup(targets) { const message = targets.length === 1 && targets[0] ? `Delete worktree and branch "${targets[0].branch}"?` : `Delete ${targets.length} worktrees and their branches?`; return await confirm({ message, default: false }); } export async function confirmRemoteBranchDeletion(targets) { const message = targets.length === 1 && targets[0] ? `Also delete remote branch "${targets[0].branch}"?` : `Also delete ${targets.length} remote branches?`; return await confirm({ message, default: false }); } export async function confirmPushUnpushedCommits(targets) { const branchesWithUnpushed = targets.filter(t => t.hasUnpushedCommits); if (branchesWithUnpushed.length === 0) { return false; } const message = branchesWithUnpushed.length === 1 && branchesWithUnpushed[0] ? `Push unpushed commits in "${branchesWithUnpushed[0].branch}" before deletion?` : `Push unpushed commits in ${branchesWithUnpushed.length} branches before deletion?`; return await confirm({ message, default: true }); } export async function confirmProceedWithoutPush(branchName) { return await confirm({ message: `Failed to push "${branchName}". Proceed with deletion anyway?`, default: false }); } export async function selectReleaseAction() { return await select({ message: 'What would you like to do with this release branch?', choices: [ { name: '🚀 Complete release - Push and create PR to main', value: 'complete', description: 'Start the release process' }, { name: '⏸️ Save and continue later', value: 'continue', description: 'Keep the branch for future work' }, { name: '❌ Exit without action', value: 'nothing', description: 'Just exit' } ] }); } export async function selectSession(sessions) { if (sessions.length === 0) { return null; } console.log('\n' + chalk.bold.cyan('Recent Claude Code Sessions')); console.log(chalk.gray('Select a session to resume:\n')); // Collect enhanced session information with categorization const categorizedSessions = []; for (let index = 0; index < sessions.length; index++) { const session = sessions[index]; if (!session) continue; if (!session.lastWorktreePath || !session.lastBranch) { // Create a fallback category for incomplete sessions const fallbackInfo = { hasUncommittedChanges: false, uncommittedChangesCount: 0, hasUnpushedCommits: false, unpushedCommitsCount: 0, latestCommitMessage: null, branchType: 'other' }; categorizedSessions.push({ session, sessionInfo: fallbackInfo, category: categorizeSession(fallbackInfo), index }); continue; } try { const { getEnhancedSessionInfo } = await import('../git.js'); const sessionInfo = await getEnhancedSessionInfo(session.lastWorktreePath, session.lastBranch); const category = categorizeSession(sessionInfo); categorizedSessions.push({ session, sessionInfo, category, index }); } catch { // Fallback for sessions where enhanced info is not available const fallbackInfo = { hasUncommittedChanges: false, uncommittedChangesCount: 0, hasUnpushedCommits: false, unpushedCommitsCount: 0, latestCommitMessage: null, branchType: 'other' }; categorizedSessions.push({ session, sessionInfo: fallbackInfo, category: categorizeSession(fallbackInfo), index }); } } // Group and sort sessions const groupedSessions = groupAndSortSessions(categorizedSessions); // Create choices with grouping const groupedChoices = createGroupedChoices(groupedSessions); // No cancel option - use q key to go back const selectedIndex = await createQuitableSelect({ message: 'Select session (q to go back):', choices: groupedChoices.map(choice => { const result = { name: choice.name, value: choice.value }; if (choice.description) result.description = choice.description; if (choice.disabled) result.disabled = choice.disabled; return result; }), pageSize: 12 }); if (selectedIndex === null) { // User pressed q - user wants to go back return null; } const index = parseInt(selectedIndex); return sessions[index] ?? null; } /** * Select Claude Code conversation from history */ export async function selectClaudeConversation(worktreePath) { try { const { getConversationsForProject, isClaudeHistoryAvailable } = await import('../claude-history.js'); // Check if Claude Code history is available if (!(await isClaudeHistoryAvailable())) { console.log(chalk.yellow('⚠️ Claude Code history not found on this system')); console.log(chalk.gray(' Using standard Claude Code resume functionality instead...')); return null; } console.log('\n' + chalk.bold.cyan('🔄 Resume Claude Code Conversation')); console.log(chalk.gray('Select a conversation to resume:\n')); // Get conversations for the current project const conversations = await getConversationsForProject(worktreePath); if (conversations.length === 0) { console.log(chalk.yellow('📝 No conversations found for this project')); console.log(chalk.gray(' Starting a new conversation instead...')); return null; } // Categorize conversations by recency const categorizedConversations = categorizeConversationsByActivity(conversations); // Create grouped choices const choices = createConversationChoices(categorizedConversations); // No cancel option - use q key to go back // Single selection prompt const selectedValue = await createQuitableSelect({ message: 'Choose conversation to resume (q to go back):', choices: choices.map(choice => { const result = { name: choice.name, value: choice.value }; if (choice.description) result.description = choice.description; if (choice.disabled) result.disabled = choice.disabled; return result; }), pageSize: 15 }); if (selectedValue === null) { // Handle q key - user wants to go back return null; } const selectedIndex = parseInt(selectedValue); const selectedConversation = conversations[selectedIndex] || null; if (!selectedConversation) { return null; } // Clear screen before showing preview console.clear(); // Show enhanced preview console.log(chalk.bold.cyan('📖 Conversation Preview')); console.log(chalk.gray('─'.repeat(Math.min(80, process.stdout.columns || 80)))); console.log(); const { getDetailedConversation } = await import('../claude-history.js'); const detailed = await getDetailedConversation(selectedConversation); if (detailed) { displayConversationPreview(detailed.messages); } console.log(); console.log(chalk.gray('─'.repeat(Math.min(80, process.stdout.columns || 80)))); // Simple confirmation - use q to go back try { const shouldResume = await confirm({ message: `Resume "${selectedConversation.title}"?`, default: true }); if (shouldResume) { return selectedConversation; } else { // User chose not to resume, go back to conversation selection console.clear(); return await selectClaudeConversation(worktreePath); } } catch { // Handle q key - go back to conversation selection console.clear(); return await selectClaudeConversation(worktreePath); } } catch { console.error(chalk.red('Failed to load Claude Code conversations:')); console.log(chalk.gray('Using standard Claude Code resume functionality instead...')); return null; } } /** * Display conversation messages with scrollable interface */ export async function displayConversationMessages(conversation) { try { const { getDetailedConversation } = await import('../claude-history.js'); const detailedConversation = await getDetailedConversation(conversation); if (!detailedConversation || !detailedConversation.messages) { console.log(chalk.red('Unable to load conversation messages')); return false; } console.clear(); console.log(chalk.bold.cyan(`📖 ${conversation.title}`)); console.log(chalk.gray(`${conversation.messageCount} messages • ${formatTimeAgo(conversation.lastActivity)}`)); console.log(chalk.gray('─'.repeat(80))); console.log(); // Create scrollable message viewer return await createMessageViewer(detailedConversation.messages); } catch { console.error(chalk.red('Failed to display conversation messages:')); return false; } } /** * Create scrollable message viewer component */ async function createMessageViewer(messages) { console.clear(); console.log(chalk.bold.cyan(`📖 Conversation History (${messages.length} messages)`)); console.log(chalk.gray('─'.repeat(80))); console.log(); // Show recent messages (last 10) const recentMessages = messages.slice(-10); recentMessages.forEach((message) => { const isUser = message.role === 'user'; const roleSymbol = isUser ? '>' : '⏺'; const roleColor = isUser ? chalk.blue : chalk.cyan; // Format message content let content = ''; if (typeof message.content === 'string') { content = message.content; } else if (Array.isArray(message.content)) { content = message.content.map(item => item.text || '').join(' '); } // Handle special content types let displayContent = content; let toolInfo = ''; if (content.startsWith('🔧 Used tool:')) { const toolName = content.replace('🔧 Used tool: ', ''); toolInfo = chalk.yellow(`[Tool: ${toolName}]`); displayContent = ''; // Don't show content for tool calls } else if (content.length > 60) { // Truncate long messages displayContent = content.substring(0, 57) + '...'; } // Format like Claude Code const roleDisplay = roleColor(roleSymbol); // Display the message with Claude Code formatting if (toolInfo) { console.log(`${roleDisplay} ${toolInfo}`); } else if (displayContent.trim()) { console.log(`${roleDisplay} ${displayContent}`); } // Add spacing between messages like Claude Code console.log(); }); if (messages.length > 10) { console.log(); console.log(chalk.gray(`... and ${messages.length - 10} more messages above`)); } console.log(); console.log(chalk.gray('─'.repeat(80))); console.log(); // Simple confirmation return await confirm({ message: 'Resume this conversation?', default: true }); } /** * Display conversation preview (ccresume style) */ function displayConversationPreview(messages) { // Get terminal height and calculate available space for messages const terminalHeight = process.stdout.rows || 24; // Default to 24 if unavailable const headerLines = 3; // Title + separator + empty line const footerLines = 3; // Empty line + separator + confirmation prompt const availableLines = Math.max(6, terminalHeight - headerLines - footerLines); // Be very conservative with message count to ensure newest messages are always visible const messagesToShow = Math.min(messages.length, Math.floor(availableLines / 4)); // Very conservative estimate // Always start from the most recent messages and display in normal order (oldest to newest) const recentMessages = messages.slice(-messagesToShow); recentMessages.forEach((message) => { const isUser = message.role === 'user'; const roleSymbol = isUser ? '>' : '⏺'; const roleColor = isUser ? chalk.blue : chalk.cyan; // Format message content let content = ''; if (typeof message.content === 'string') { content = message.content; } else if (Array.isArray(message.content)) { content = message.content.map(item => item.text || '').join(' '); } // Handle special content types let displayContent = content; if (content.startsWith('🔧 Used tool:')) { const toolName = content.replace('🔧 Used tool: ', ''); displayContent = chalk.yellow(`[Tool: ${toolName}]`); } else { // Aggressive truncation to ensure all messages fit const terminalWidth = process.stdout.columns || 80; const maxContentWidth = terminalWidth - 15; // Account for role label and spacing if (content.length > maxContentWidth) { displayContent = content.substring(0, maxContentWidth - 3) + '...'; } else { displayContent = content; } // Limit multi-line content strictly const lines = displayContent.split('\n'); if (lines.length > 1) { displayContent = lines[0] + (lines.length > 1 ? '\n' + chalk.gray(`... (${lines.length - 1} more lines)`) : ''); } } // Format like Claude Code const roleDisplay = roleColor(roleSymbol); // Handle multi-line display const contentLines = displayContent.split('\n'); contentLines.forEach((line, index) => { if (index === 0) { console.log(`${roleDisplay} ${line}`); } else { // Indent continuation lines console.log(`${' '.repeat(roleDisplay.length - 8)} ${line}`); // Account for ANSI color codes } }); }); if (messages.length > messagesToShow) { console.log(chalk.gray(`... and ${messages.length - messagesToShow} more messages above`)); } console.log(); // Add spacing before footer } /** * Categorize conversations by activity recency */ function categorizeConversationsByActivity(conversations) { const now = Date.now(); const oneHour = 60 * 60 * 1000; const oneDay = 24 * oneHour; const oneWeek = 7 * oneDay; return conversations.map((conversation, index) => { const age = now - conversation.lastActivity; let category; if (age < oneHour) { category = { type: 'recent', title: '🔥 Very Recent (within 1 hour)', emoji: '🔥' }; } else if (age < oneDay) { category = { type: 'recent', title: '⚡ Recent (within 24 hours)', emoji: '⚡' }; } else if (age < oneWeek) { category = { type: 'this-week', title: '📅 This week', emoji: '📅' }; } else { category = { type: 'older', title: '📚 Older conversations', emoji: '📚' }; } return { conversation, category, index }; }).sort((a, b) => { // First sort by category priority (recent -> this-week -> older) const categoryOrder = { 'recent': 0, 'this-week': 1, 'older': 2 }; const categoryDiff = categoryOrder[a.category.type] - categoryOrder[b.category.type]; if (categoryDiff !== 0) return categoryDiff; // Within each category, sort by most recent first return b.conversation.lastActivity - a.conversation.lastActivity; }); } /** * Create conversation choices with grouping */ function createConversationChoices(categorizedConversations) { const choices = []; // Group conversations by category const groups = new Map(); groups.set('recent', []); groups.set('this-week', []); groups.set('older', []); for (const item of categorizedConversations) { const group = groups.get(item.category.type) || []; group.push(item); groups.set(item.category.type, group); } // Add groups in order const groupOrder = ['recent', 'this-week', 'older']; for (const groupType of groupOrder) { const group = groups.get(groupType) || []; if (group.length === 0) continue; // Add group header const category = group[0]?.category; if (!category) continue; choices.push({ name: `\n${category.title}`, value: `__header_${groupType}__`, disabled: true }); // Add conversations in this group for (const { conversation, index } of group) { const formatted = formatConversationDisplay(conversation, index); choices.push(formatted); } } // Add separator before cancel option if (choices.length > 0) { choices.push({ name: '', value: '__separator__', disabled: true }); } return choices; } /** * Format conversation display */ function formatConversationDisplay(conversation, index) { const timeAgo = formatTimeAgo(conversation.lastActivity); const messageCount = conversation.messageCount; // Icon based on conversation content/title let icon = '💬'; const lowerTitle = conversation.title.toLowerCase(); if (lowerTitle.includes('bug') || lowerTitle.includes('fix') || lowerTitle.includes('error')) { icon = '🐛'; } else if (lowerTitle.includes('feature') || lowerTitle.includes('implement') || lowerTitle.includes('add')) { icon = '🚀'; } else if (lowerTitle.includes('doc') || lowerTitle.includes('readme') || lowerTitle.includes('comment')) { icon = '📝'; } else if (lowerTitle.includes('test') || lowerTitle.includes('spec')) { icon = '🧪'; } // Format: " 💬 Conversation title (X messages, time ago)" const title = conversation.title.length > 40 ? conversation.title.substring(0, 37) + '...' : conversation.title; const metadata = `(${messageCount} message${messageCount !== 1 ? 's' : ''}, ${chalk.gray(timeAgo)})`; // Create main display line const display = ` ${icon} ${chalk.cyan(title)} ${metadata}`; // Enhanced description with summary if available let description = ''; if (conversation.summary && conversation.summary.trim()) { description = conversation.summary.length > 80 ? conversation.summary.substring(0, 77) + '...' : conversation.summary; } else { // Fallback description based on title analysis if (lowerTitle.includes('bug') || lowerTitle.includes('fix')) { description = 'Bug fix or error resolution'; } else if (lowerTitle.includes('feature') || lowerTitle.includes('implement')) { description = 'Feature development or implementation'; } else if (lowerTitle.includes('doc') || lowerTitle.includes('readme')) { description = 'Documentation or README updates'; } else if (lowerTitle.includes('test')) { description = 'Testing and test improvements'; } else { description = `${messageCount} messages exchanged ${timeAgo}`; } } return { name: display, value: index.toString(), description: description }; } export async function selectClaudeExecutionMode() { const { createPrompt, useState, useKeypress, isEnterKey, usePrefix, isUpKey, isDownKey } = await import('@inquirer/core'); // Custom prompt that handles 'q' key const customSelect = createPrompt((config, done) => { const [selectedIndex, setSelectedIndex] = useState(0); const [status, setStatus] = useState('idle'); const prefix = usePrefix({}); useKeypress((key) => { if (status === 'done') return; if (key.name === 'q' || (key.name === 'c' && key.ctrl)) { setStatus('done'); done(null); // Return null for cancelled return; } if (isEnterKey(key)) { const selectedChoice = config.choices[selectedIndex]; setStatus('done'); done(selectedChoice.value); return; } if (isUpKey(key)) { const newIndex = selectedIndex > 0 ? selectedIndex - 1 : config.choices.length - 1; setSelectedIndex(newIndex); } else if (isDownKey(key)) { const newIndex = selectedIndex < config.choices.length - 1 ? selectedIndex + 1 : 0; setSelectedIndex(newIndex); } }); if (status === 'done') { return ''; } const message = config.message; const choicesDisplay = config.choices.map((choice, index) => { const isSelected = index === selectedIndex; const pointer = isSelected ? '❯' : ' '; const nameColor = isSelected ? chalk.cyan : chalk.reset; return `${pointer} ${nameColor(choice.name)}${isSelected && choice.description ? '\n ' + chalk.gray(choice.description) : ''}`; }).join('\n'); return `${prefix} ${message}\n${choicesDisplay}`; }); const choices = [ { name: '🚀 Normal - Start a new session', value: 'normal', description: 'Launch Claude Code normally' }, { name: '⏭️ Continue - Continue most recent conversation (-c)', value: 'continue', description: 'Continue from the most recent conversation' }, { name: '🔄 Resume - Select conversation to resume (-r)', value: 'resume', description: 'Interactively select a conversation to resume' } ]; const mode = await customSelect({ message: 'Select Claude Code execution mode (q to go back):', choices }); if (mode === null) { // User pressed 'q' or Ctrl+C return null; } const skipPermissions = await confirm({ message: 'Skip permission checks? (--dangerously-skip-permissions)', default: false }); return { mode: mode, skipPermissions }; } function formatTimeAgo(timestamp) { const now = Date.now(); const diff = now - timestamp; const minutes = Math.floor(diff / (1000 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60)); const days = Math.floor(diff / (1000 * 60 * 60 * 24)); const weeks = Math.floor(days / 7); const months = Math.floor(days / 30); if (minutes < 1) { return 'just now'; } else if (minutes < 60) { return `${minutes}m ago`; } else if (hours < 24) { return `${hours}h ago`; } else if (days === 1) { return '1 day ago'; } else if (days < 7) { return `${days} days ago`; } else if (weeks === 1) { return '1 week ago'; } else if (weeks < 4) { return `${weeks} weeks ago`; } else if (months === 1) { return '1 month ago'; } else { return `${months} months ago`; } } /** * Get project icon based on repository name */ function getProjectIcon(repoName) { const lowerName = repoName.toLowerCase(); if (lowerName.includes('app') || lowerName.includes('mobile')) return '📱'; if (lowerName.includes('api') || lowerName.includes('backend')) return '⚡'; if (lowerName.includes('frontend') || lowerName.includes('ui')) return '🎨'; if (lowerName.includes('cli') || lowerName.includes('tool')) return '🛠️'; if (lowerName.includes('bot') || lowerName.includes('ai')) return '🤖'; if (lowerName.includes('web')) return '🌐'; if (lowerName.includes('doc') || lowerName.includes('guide')) return '📚'; return '🚀'; } /** * Determine session category based on git status */ function categorizeSession(sessionInfo) { if (sessionInfo.hasUncommittedChanges) { return { type: 'active', title: '🔥 Active (uncommitted changes)', emoji: '🔥', description: 'Sessions with ongoing work' }; } if (sessionInfo.hasUnpushedCommits) { return { type: 'needs-attention', title: '⚠️ Needs attention', emoji: '⚠️', description: 'Sessions with unpushed commits' }; } return { type: 'ready', title: '✅ Ready to continue', emoji: '✅', description: 'Clean sessions ready to resume' }; } /** * Format session in new compact style */ function formatCompactSessionDisplay(session, sessionInfo, index) { const repo = session.repositoryRoot.split('/').pop() || 'unknown'; const timeAgo = formatTimeAgo(session.timestamp); const branch = session.lastBranch || 'unknown'; const projectIcon = getProjectIcon(repo); // Create status info in parentheses let statusInfo = ''; if (sessionInfo.hasUncommittedChanges) { const count = sessionInfo.uncommittedChangesCount; statusInfo = `📝 ${count} file${count !== 1 ? 's' : ''}`; } else if (sessionInfo.hasUnpushedCommits) { const count = sessionInfo.unpushedCommitsCount; statusInfo = `🔄 ${count} commit${count !== 1 ? 's' : ''}`; } // Format: " 🚀 project-name → branch-name (status, time)" const projectBranch = `${chalk.cyan(repo)}${chalk.green(branch)}`; const padding = Math.max(0, 35 - repo.length - branch.length); const timeAndStatus = statusInfo ? `(${statusInfo}, ${chalk.gray(timeAgo)})` : `(${chalk.gray(timeAgo)})`; const display = ` ${projectIcon} ${projectBranch}${' '.repeat(padding)} ${timeAndStatus}`; return { name: display, value: index.toString(), description: session.lastWorktreePath || '' }; } /** * Group and sort sessions by category and priority */ function groupAndSortSessions(categorizedSessions) { const groups = new Map(); // Initialize groups groups.set('active', []); groups.set('ready', []); groups.set('needs-attention', []); // Group sessions by category for (const session of categorizedSessions) { const group = groups.get(session.category.type) || []; group.push(session); groups.set(session.category.type, group); } // Sort within each group by timestamp (most recent first) for (const [key, sessions] of groups.entries()) { sessions.sort((a, b) => b.session.timestamp - a.session.timestamp); groups.set(key, sessions); } return groups; } /** * Create grouped choices for the prompt */ function createGroupedChoices(groupedSessions) { const choices = []; // Define group order for display const groupOrder = ['active', 'needs-attention', 'ready']; for (const groupType of groupOrder) { const sessions = groupedSessions.get(groupType) || []; if (sessions.length === 0) continue; // Add group header const category = sessions[0]?.category; if (!category) continue; choices.push({ name: ` ${category.title}`, value: `__header_${groupType}__`, disabled: true }); // Add sessions in this group for (const { session, sessionInfo, index } of sessions) { const formatted = formatCompactSessionDisplay(session, sessionInfo, index); choices.push(formatted); } } // Add a separator before cancel option if (choices.length > 0) { choices.push({ name: '', value: '__separator__', disabled: true }); } return choices; } //# sourceMappingURL=prompts.js.map