UNPKG

dmux

Version:

Tmux pane manager with AI agent integration for parallel development workflows

864 lines 44.5 kB
import React, { useState, useEffect } from 'react'; import { Box, Text, useInput, useApp } from 'ink'; import { execSync } from 'child_process'; import path from 'path'; import { createRequire } from 'module'; // Hooks import usePanes from './hooks/usePanes.js'; import useProjectSettings from './hooks/useProjectSettings.js'; import useTerminalWidth from './hooks/useTerminalWidth.js'; import useNavigation from './hooks/useNavigation.js'; import useAutoUpdater from './hooks/useAutoUpdater.js'; import useAgentDetection from './hooks/useAgentDetection.js'; import useAgentStatus from './hooks/useAgentStatus.js'; import useWorktreeActions from './hooks/useWorktreeActions.js'; import usePaneRunner from './hooks/usePaneRunner.js'; import usePaneCreation from './hooks/usePaneCreation.js'; // Utils import { applySmartLayout } from './utils/tmux.js'; import { suggestCommand } from './utils/commands.js'; import { generateSlug } from './utils/slug.js'; import { getMainBranch } from './utils/git.js'; const require = createRequire(import.meta.url); const packageJson = require('../package.json'); import PanesGrid from './components/PanesGrid.js'; import NewPaneDialog from './components/NewPaneDialog.js'; import AgentChoiceDialog from './components/AgentChoiceDialog.js'; import CloseOptionsDialog from './components/CloseOptionsDialog.js'; import MergeConfirmationDialog from './components/MergeConfirmationDialog.js'; import CommandPromptDialog from './components/CommandPromptDialog.js'; import FileCopyPrompt from './components/FileCopyPrompt.js'; import LoadingIndicator from './components/LoadingIndicator.js'; import RunningIndicator from './components/RunningIndicator.js'; import UpdatingIndicator from './components/UpdatingIndicator.js'; import CreatingIndicator from './components/CreatingIndicator.js'; import FooterHelp from './components/FooterHelp.js'; import MergePane from './MergePane.js'; const DmuxApp = ({ panesFile, projectName, sessionName, settingsFile, autoUpdater }) => { /* panes state moved to usePanes */ const [selectedIndex, setSelectedIndex] = useState(0); const [showNewPaneDialog, setShowNewPaneDialog] = useState(false); const [newPanePrompt, setNewPanePrompt] = useState(''); const [statusMessage, setStatusMessage] = useState(''); const [showMergeConfirmation, setShowMergeConfirmation] = useState(false); const [mergedPane, setMergedPane] = useState(null); const [showMergePane, setShowMergePane] = useState(false); const [mergingPane, setMergingPane] = useState(null); const [showCloseOptions, setShowCloseOptions] = useState(false); const [selectedCloseOption, setSelectedCloseOption] = useState(0); const [closingPane, setClosingPane] = useState(null); const [isCreatingPane, setIsCreatingPane] = useState(false); const { projectSettings, saveSettings } = useProjectSettings(settingsFile); const [showCommandPrompt, setShowCommandPrompt] = useState(null); const [commandInput, setCommandInput] = useState(''); const [showFileCopyPrompt, setShowFileCopyPrompt] = useState(false); const [currentCommandType, setCurrentCommandType] = useState(null); const [runningCommand, setRunningCommand] = useState(false); // Update state handled by hook const { updateInfo, showUpdateDialog, isUpdating, performUpdate, skipUpdate, dismissUpdate, updateAvailable } = useAutoUpdater(autoUpdater, setStatusMessage); const { exit } = useApp(); // Agent selection state const { availableAgents } = useAgentDetection(); const [showAgentChoiceDialog, setShowAgentChoiceDialog] = useState(false); const [agentChoice, setAgentChoice] = useState(null); const [pendingPrompt, setPendingPrompt] = useState(''); // Track terminal dimensions for responsive layout const terminalWidth = useTerminalWidth(); // Panes state and persistence const skipLoading = showNewPaneDialog || showMergeConfirmation || showCloseOptions || !!showCommandPrompt || showFileCopyPrompt || showMergePane; const { panes, setPanes, isLoading, loadPanes, savePanes } = usePanes(panesFile, skipLoading); // Worktree actions const { closePane, mergeWorktree, mergeAndPrune, deleteUnsavedChanges, handleCloseOption } = useWorktreeActions({ panes, savePanes, setStatusMessage, setShowMergeConfirmation, setMergedPane, }); // Pane runner const { copyNonGitFiles, runCommandInternal, monitorTestOutput, monitorDevOutput, attachBackgroundWindow } = usePaneRunner({ panes, savePanes, projectSettings, setStatusMessage, setRunningCommand, }); // Pane creation const { openInEditor: openEditor2, createNewPane: createNewPaneHook } = usePaneCreation({ panes, savePanes, projectName, setIsCreatingPane, setStatusMessage, setNewPanePrompt, loadPanes, panesFile, }); // Load panes and settings on mount and refresh periodically useEffect(() => { // Add cleanup handlers for process termination const handleTermination = () => { // Clear screen multiple times to ensure no artifacts process.stdout.write('\x1b[2J\x1b[H'); // Clear screen and move to home process.stdout.write('\x1b[3J'); // Clear scrollback buffer process.stdout.write('\n'.repeat(100)); // Push any remaining content off screen // Clear tmux pane try { execSync('tmux clear-history', { stdio: 'pipe' }); execSync('tmux send-keys C-l', { stdio: 'pipe' }); } catch { } // Wait a moment for clearing to settle, then show goodbye message setTimeout(() => { process.stdout.write('\x1b[2J\x1b[H'); process.stdout.write('\n\n dmux session ended.\n\n'); process.exit(0); }, 100); }; process.on('SIGINT', handleTermination); process.on('SIGTERM', handleTermination); return () => { process.removeListener('SIGINT', handleTermination); process.removeListener('SIGTERM', handleTermination); }; }, []); // Auto-show new pane dialog when starting with no panes useEffect(() => { // Only show the dialog if: // 1. Initial load is complete (!isLoading) // 2. We have no panes // 3. We're not already showing the dialog // 4. We're not showing any other dialogs or prompts if (!isLoading && panes.length === 0 && !showNewPaneDialog && !showMergeConfirmation && !showCloseOptions && !showCommandPrompt && !showFileCopyPrompt && !showAgentChoiceDialog && !isCreatingPane && !runningCommand && !isUpdating) { setShowNewPaneDialog(true); } }, [isLoading, panes.length, showNewPaneDialog, showMergeConfirmation, showCloseOptions, showCommandPrompt, showFileCopyPrompt, showAgentChoiceDialog, isCreatingPane, runningCommand, isUpdating]); // Update checking moved to useAutoUpdater // Set default agent choice when detection completes useEffect(() => { if (agentChoice == null && availableAgents.length > 0) { setAgentChoice(availableAgents[0] || 'claude'); } }, [availableAgents]); // Monitor agent status across panes (returns a map of pane ID to status) const agentStatuses = useAgentStatus({ panes, suspend: showNewPaneDialog || showMergeConfirmation || showCloseOptions || !!showCommandPrompt || showFileCopyPrompt || showMergePane, }); // loadPanes moved to usePanes // getPanePositions moved to utils/tmux // Navigation logic moved to hook const { getCardGridPosition, findCardInDirection } = useNavigation(terminalWidth, panes.length, isLoading); // findCardInDirection provided by useNavigation // savePanes moved to usePanes // applySmartLayout moved to utils/tmux const createNewPane = async (prompt, agent) => { setIsCreatingPane(true); setStatusMessage('Generating slug...'); const slug = await generateSlug(prompt); setStatusMessage(`Creating worktree: ${slug}...`); // Get git root directory for consistent worktree placement let projectRoot; try { projectRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: 'pipe' }).trim(); } catch { // Fallback to current directory if not in a git repo projectRoot = process.cwd(); } // Create worktree path inside .dmux/worktrees directory const worktreePath = path.join(projectRoot, '.dmux', 'worktrees', slug); // Get the original pane ID (where dmux is running) before clearing const originalPaneId = execSync('tmux display-message -p "#{pane_id}"', { encoding: 'utf-8' }).trim(); // Multiple clearing strategies to prevent artifacts // 1. Clear screen with ANSI codes process.stdout.write('\x1b[2J\x1b[H'); // 2. Fill with blank lines to push content off screen process.stdout.write('\n'.repeat(100)); // 3. Clear tmux history and send clear command try { execSync('tmux clear-history', { stdio: 'pipe' }); execSync('tmux send-keys C-l', { stdio: 'pipe' }); } catch { } // Wait a bit for clearing to settle await new Promise(resolve => setTimeout(resolve, 100)); // 4. Force tmux to refresh the display try { execSync('tmux refresh-client', { stdio: 'pipe' }); } catch { } // Get current pane count to determine layout const paneCount = parseInt(execSync('tmux list-panes | wc -l', { encoding: 'utf-8' }).trim()); // Enable pane borders to show titles try { execSync(`tmux set-option -g pane-border-status top`, { stdio: 'pipe' }); } catch { // Ignore if already set or fails } // Create new pane const paneInfo = execSync(`tmux split-window -h -P -F '#{pane_id}'`, { encoding: 'utf-8' }).trim(); // Wait for pane creation to settle await new Promise(resolve => setTimeout(resolve, 500)); // Set pane title to match the slug try { execSync(`tmux select-pane -t '${paneInfo}' -T "${slug}"`, { stdio: 'pipe' }); } catch { // Ignore if setting title fails } // Apply smart layout based on pane count const newPaneCount = paneCount + 1; applySmartLayout(newPaneCount); // Create git worktree and cd into it // This MUST happen before launching Claude to ensure we're in the right directory try { // First, create the worktree and cd into it as a single command // Use ; instead of && to ensure cd runs even if worktree already exists const worktreeCmd = `git worktree add "${worktreePath}" -b ${slug} 2>/dev/null ; cd "${worktreePath}"`; execSync(`tmux send-keys -t '${paneInfo}' '${worktreeCmd}' Enter`, { stdio: 'pipe' }); // Wait longer for worktree creation and cd to complete // This is critical - if we don't wait long enough, Claude will start in the wrong directory await new Promise(resolve => setTimeout(resolve, 2500)); // Verify we're in the worktree directory by sending pwd command execSync(`tmux send-keys -t '${paneInfo}' 'echo "Worktree created at:" && pwd' Enter`, { stdio: 'pipe' }); await new Promise(resolve => setTimeout(resolve, 500)); setStatusMessage(agent ? `Worktree created, launching ${agent === 'opencode' ? 'opencode' : 'Claude'}...` : 'Worktree created.'); } catch (error) { // Log error but continue - worktree creation is essential setStatusMessage(`Warning: Worktree issue: ${error}`); // Even if worktree creation failed, try to cd to the directory in case it exists execSync(`tmux send-keys -t '${paneInfo}' 'cd "${worktreePath}" 2>/dev/null || (echo "ERROR: Failed to create/enter worktree ${slug}" && pwd)' Enter`, { stdio: 'pipe' }); await new Promise(resolve => setTimeout(resolve, 1000)); } // Prepare and send the agent command let escapedCmd = ''; if (agent === 'claude') { // Claude should always be launched AFTER we're in the worktree directory let claudeCmd; if (prompt && prompt.trim()) { const escapedPrompt = prompt .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/`/g, '\\`') .replace(/\$/g, '\\$'); claudeCmd = `claude "${escapedPrompt}" --permission-mode=acceptEdits`; } else { claudeCmd = `claude --permission-mode=acceptEdits`; } // Send Claude command to new pane escapedCmd = claudeCmd.replace(/'/g, "'\\''"); execSync(`tmux send-keys -t '${paneInfo}' '${escapedCmd}'`, { stdio: 'pipe' }); execSync(`tmux send-keys -t '${paneInfo}' Enter`, { stdio: 'pipe' }); } else if (agent === 'opencode') { // opencode: start the TUI, then paste the prompt and submit const openCoderCmd = `opencode`; const escapedOpenCmd = openCoderCmd.replace(/'/g, "'\\''"); execSync(`tmux send-keys -t '${paneInfo}' '${escapedOpenCmd}'`, { stdio: 'pipe' }); execSync(`tmux send-keys -t '${paneInfo}' Enter`, { stdio: 'pipe' }); if (prompt && prompt.trim()) { await new Promise(resolve => setTimeout(resolve, 1500)); const bufName = `dmux_prompt_${Date.now()}`; const promptEsc = prompt.replace(/\\/g, '\\\\').replace(/'/g, "'\\''"); execSync(`tmux set-buffer -b '${bufName}' -- '${promptEsc}'`, { stdio: 'pipe' }); execSync(`tmux paste-buffer -b '${bufName}' -t '${paneInfo}'`, { stdio: 'pipe' }); await new Promise(resolve => setTimeout(resolve, 200)); execSync(`tmux delete-buffer -b '${bufName}'`, { stdio: 'pipe' }); execSync(`tmux send-keys -t '${paneInfo}' Enter`, { stdio: 'pipe' }); } } if (agent === 'claude') { // Monitor for Claude Code trust prompt and auto-respond const autoApproveTrust = async () => { console.error('[TRUST DEBUG] Starting autoApproveTrust monitoring...'); // Wait for Claude to start up before checking for prompts await new Promise(resolve => setTimeout(resolve, 800)); const maxChecks = 100; // 100 checks * 100ms = 10 seconds total const checkInterval = 100; // Check every 100ms let lastContent = ''; let stableContentCount = 0; let promptHandled = false; // More comprehensive trust prompt patterns const trustPromptPatterns = [ /Do you trust the files in this folder\?/i, /Trust the files in this workspace\?/i, /Do you trust the authors of the files/i, /Do you want to trust this workspace\?/i, /trust.*files.*folder/i, /trust.*workspace/i, /Do you trust/i, /Trust this folder/i, /trust.*directory/i, /permission.*grant/i, /allow.*access/i, /workspace.*trust/i, /accept.*edits/i, // Claude's accept edits prompt /permission.*mode/i, // Permission mode prompt /allow.*claude/i, // Allow Claude prompt /\[y\/n\]/i, // Common yes/no prompt pattern /\(y\/n\)/i, /Yes\/No/i, /\[Y\/n\]/i, // Default yes pattern /press.*enter.*accept/i, // Press enter to accept /press.*enter.*continue/i, // Press enter to continue /❯\s*1\.\s*Yes,\s*proceed/i, // New Claude numbered menu format /Enter to confirm.*Esc to exit/i, // New Claude confirmation format /1\.\s*Yes,\s*proceed/i, // Yes proceed option /2\.\s*No,\s*exit/i // No exit option ]; for (let i = 0; i < maxChecks; i++) { await new Promise(resolve => setTimeout(resolve, checkInterval)); try { // Capture the pane content const paneContent = execSync(`tmux capture-pane -t '${paneInfo}' -p -S -30`, // Capture last 30 lines { encoding: 'utf-8', stdio: 'pipe' }); if (i % 10 === 0) { // Log every 10 checks (every second) console.error(`[TRUST DEBUG] Check ${i + 1}/${maxChecks}, content preview: "${paneContent.substring(0, 100).replace(/\n/g, '\\n')}"...`); } // Check if content has stabilized (same for 3 checks = prompt is waiting) if (paneContent === lastContent) { stableContentCount++; } else { stableContentCount = 0; lastContent = paneContent; } // Look for trust prompt in the current content const hasTrustPrompt = trustPromptPatterns.some(pattern => pattern.test(paneContent)); // Also check if we see specific Claude permission text const hasClaudePermissionPrompt = paneContent.includes('Do you trust') || paneContent.includes('trust the files') || paneContent.includes('permission') || paneContent.includes('allow') || (paneContent.includes('folder') && paneContent.includes('?')); if ((hasTrustPrompt || hasClaudePermissionPrompt) && !promptHandled) { console.error(`[TRUST DEBUG] Trust prompt detected! Pattern match: ${hasTrustPrompt}, Permission text: ${hasClaudePermissionPrompt}, Stable count: ${stableContentCount}`); console.error(`[TRUST DEBUG] Content that triggered detection: "${paneContent}"`); // Content is stable and we found a prompt if (stableContentCount >= 2) { console.error('[TRUST DEBUG] Content is stable, attempting to auto-approve trust prompt...'); // Check if this is the new Claude numbered menu format const isNewClaudeFormat = /❯\s*1\.\s*Yes,\s*proceed/i.test(paneContent) || /Enter to confirm.*Esc to exit/i.test(paneContent); if (isNewClaudeFormat) { // For new Claude format, just press Enter to confirm default "Yes, proceed" console.error('[TRUST DEBUG] Detected new Claude format, sending Enter to confirm default option'); execSync(`tmux send-keys -t '${paneInfo}' Enter`, { stdio: 'pipe' }); } else { // Try multiple response methods for older formats // Method 1: Send 'y' followed by Enter (most explicit) console.error('[TRUST DEBUG] Sending "y" + Enter for legacy format'); execSync(`tmux send-keys -t '${paneInfo}' 'y'`, { stdio: 'pipe' }); await new Promise(resolve => setTimeout(resolve, 50)); execSync(`tmux send-keys -t '${paneInfo}' Enter`, { stdio: 'pipe' }); // Method 2: Just Enter (if it's a yes/no with default yes) await new Promise(resolve => setTimeout(resolve, 100)); console.error('[TRUST DEBUG] Sending additional Enter'); execSync(`tmux send-keys -t '${paneInfo}' Enter`, { stdio: 'pipe' }); } // Mark as handled to avoid duplicate responses promptHandled = true; // Wait and check if prompt is gone await new Promise(resolve => setTimeout(resolve, 500)); // Verify the prompt is gone const updatedContent = execSync(`tmux capture-pane -t '${paneInfo}' -p -S -10`, { encoding: 'utf-8', stdio: 'pipe' }); // If trust prompt is gone, check if we need to resend the Claude command const promptGone = !trustPromptPatterns.some(p => p.test(updatedContent)); if (promptGone) { console.error('[TRUST DEBUG] Trust prompt appears to be gone'); // Check if Claude is running or if we need to restart it const claudeRunning = updatedContent.includes('Claude') || updatedContent.includes('claude') || updatedContent.includes('Assistant') || (prompt && updatedContent.includes(prompt.substring(0, Math.min(20, prompt.length)))); console.error(`[TRUST DEBUG] Claude running check: ${claudeRunning}, has $: ${updatedContent.includes('$')}`); if (!claudeRunning && !updatedContent.includes('$')) { console.error('[TRUST DEBUG] Claude not running after trust approval, restarting...'); await new Promise(resolve => setTimeout(resolve, 300)); execSync(`tmux send-keys -t '${paneInfo}' '${escapedCmd}'`, { stdio: 'pipe' }); execSync(`tmux send-keys -t '${paneInfo}' Enter`, { stdio: 'pipe' }); } console.error('[TRUST DEBUG] Successfully handled the trust prompt'); break; } } } // If we see Claude is already running without prompts, we're done if (!hasTrustPrompt && !hasClaudePermissionPrompt && (paneContent.includes('Claude') || paneContent.includes('Assistant'))) { break; } } catch (error) { // Continue checking, errors are non-fatal console.error(`[TRUST DEBUG] Error checking for trust prompt: ${error instanceof Error ? error.message : error}`); } } }; // Start monitoring for trust prompt in background autoApproveTrust().catch(err => { console.error(`[TRUST DEBUG] Error in autoApproveTrust: ${err instanceof Error ? err.message : err}`); }); } // Keep focus on the new pane execSync(`tmux select-pane -t '${paneInfo}'`, { stdio: 'pipe' }); // Save pane info const newPane = { id: `dmux-${Date.now()}`, slug, prompt: prompt ? (prompt.substring(0, 50) + (prompt.length > 50 ? '...' : '')) : 'No initial prompt', paneId: paneInfo, worktreePath, agent }; const updatedPanes = [...panes, newPane]; await savePanes(updatedPanes); // Switch back to the original pane (where dmux is running) execSync(`tmux select-pane -t '${originalPaneId}'`, { stdio: 'pipe' }); // Re-set the title for the dmux pane try { execSync(`tmux select-pane -t '${originalPaneId}' -T "dmux-${projectName}"`, { stdio: 'pipe' }); } catch { // Ignore if setting title fails } // Clear the screen and redraw the UI process.stdout.write('\x1b[2J\x1b[H'); // Reset the creating pane flag and refresh setIsCreatingPane(false); setStatusMessage(''); setNewPanePrompt(''); // Force a reload of panes to ensure UI is up to date await loadPanes(); }; const jumpToPane = (paneId) => { try { // Enable pane borders to show titles (if not already enabled) try { execSync(`tmux set-option -g pane-border-status top`, { stdio: 'pipe' }); } catch { // Ignore if already set or fails } execSync(`tmux select-pane -t '${paneId}'`, { stdio: 'pipe' }); setStatusMessage('Jumped to pane'); setTimeout(() => setStatusMessage(''), 2000); } catch { setStatusMessage('Failed to jump - pane may be closed'); setTimeout(() => setStatusMessage(''), 2000); } }; const runCommand = async (type, pane) => { if (!pane.worktreePath) { setStatusMessage('No worktree path for this pane'); setTimeout(() => setStatusMessage(''), 2000); return; } const command = type === 'test' ? projectSettings.testCommand : projectSettings.devCommand; const isFirstRun = type === 'test' ? !projectSettings.firstTestRun : !projectSettings.firstDevRun; if (!command) { // No command configured, prompt user setShowCommandPrompt(type); return; } // Check if this is the first run and offer to copy non-git files if (isFirstRun) { // Show file copy prompt and wait for response setShowFileCopyPrompt(true); setCurrentCommandType(type); setStatusMessage(`First time running ${type} command...`); // Return here - the actual command will be run after user responds to prompt return; } try { setRunningCommand(true); setStatusMessage(`Starting ${type} in background window...`); // Kill existing window if present const existingWindowId = type === 'test' ? pane.testWindowId : pane.devWindowId; if (existingWindowId) { try { execSync(`tmux kill-window -t '${existingWindowId}'`, { stdio: 'pipe' }); } catch { } } // Create a new background window for the command const windowName = `${pane.slug}-${type}`; const windowId = execSync(`tmux new-window -d -n '${windowName}' -P -F '#{window_id}'`, { encoding: 'utf-8', stdio: 'pipe' }).trim(); // Create a log file to capture output const logFile = `/tmp/dmux-${pane.id}-${type}.log`; // Build the command with output capture const fullCommand = type === 'test' ? `cd "${pane.worktreePath}" && ${command} 2>&1 | tee ${logFile}` : `cd "${pane.worktreePath}" && ${command} 2>&1 | tee ${logFile}`; // Send the command to the new window execSync(`tmux send-keys -t '${windowId}' '${fullCommand.replace(/'/g, "'\\''")}'`, { stdio: 'pipe' }); execSync(`tmux send-keys -t '${windowId}' Enter`, { stdio: 'pipe' }); // Update pane with window info const updatedPane = { ...pane, [type === 'test' ? 'testWindowId' : 'devWindowId']: windowId, [type === 'test' ? 'testStatus' : 'devStatus']: 'running' }; const updatedPanes = panes.map(p => p.id === pane.id ? updatedPane : p); await savePanes(updatedPanes); // Start monitoring the output if (type === 'test') { // For tests, monitor for completion setTimeout(() => monitorTestOutput(pane.id, logFile), 2000); } else { // For dev, monitor for server URL setTimeout(() => monitorDevOutput(pane.id, logFile), 2000); } setRunningCommand(false); setStatusMessage(`${type === 'test' ? 'Test' : 'Dev server'} started in background`); setTimeout(() => setStatusMessage(''), 3000); } catch (error) { setRunningCommand(false); setStatusMessage(`Failed to run ${type} command`); setTimeout(() => setStatusMessage(''), 3000); } }; // Update handling moved to useAutoUpdater // Cleanup function for exit const cleanExit = () => { // Clear screen multiple times to ensure no artifacts process.stdout.write('\x1b[2J\x1b[H'); // Clear screen and move to home process.stdout.write('\x1b[3J'); // Clear scrollback buffer process.stdout.write('\n'.repeat(100)); // Push any remaining content off screen // Clear tmux pane try { execSync('tmux clear-history', { stdio: 'pipe' }); execSync('tmux send-keys C-l', { stdio: 'pipe' }); } catch { } // Wait a moment for clearing to settle setTimeout(() => { // Final clear and show goodbye message process.stdout.write('\x1b[2J\x1b[H'); process.stdout.write('\n\n dmux session ended.\n\n'); // Exit the app exit(); }, 100); }; useInput(async (input, key) => { if (isCreatingPane || runningCommand || isUpdating || isLoading) { // Disable input while performing operations or loading return; } if (showFileCopyPrompt) { if (input === 'y' || input === 'Y') { setShowFileCopyPrompt(false); const selectedPane = panes[selectedIndex]; if (selectedPane && selectedPane.worktreePath && currentCommandType) { await copyNonGitFiles(selectedPane.worktreePath); // Mark as not first run and continue with command const newSettings = { ...projectSettings, [currentCommandType === 'test' ? 'firstTestRun' : 'firstDevRun']: true }; await saveSettings(newSettings); // Now run the actual command await runCommandInternal(currentCommandType, selectedPane); } setCurrentCommandType(null); } else if (input === 'n' || input === 'N' || key.escape) { setShowFileCopyPrompt(false); const selectedPane = panes[selectedIndex]; if (selectedPane && currentCommandType) { // Mark as not first run and continue without copying const newSettings = { ...projectSettings, [currentCommandType === 'test' ? 'firstTestRun' : 'firstDevRun']: true }; await saveSettings(newSettings); // Now run the actual command await runCommandInternal(currentCommandType, selectedPane); } setCurrentCommandType(null); } return; } if (showAgentChoiceDialog) { if (key.escape) { setShowAgentChoiceDialog(false); setShowNewPaneDialog(true); setNewPanePrompt(pendingPrompt); setPendingPrompt(''); } else if (key.leftArrow || input === '1' || (input && input.toLowerCase() === 'c')) { setAgentChoice('claude'); } else if (key.rightArrow || input === '2' || (input && input.toLowerCase() === 'o')) { setAgentChoice('opencode'); } else if (key.return) { const chosen = agentChoice || (availableAgents[0] || 'claude'); const promptValue = pendingPrompt; setShowAgentChoiceDialog(false); setPendingPrompt(''); await createNewPane(promptValue, chosen); setNewPanePrompt(''); } return; } if (showCommandPrompt) { if (key.escape) { setShowCommandPrompt(null); setCommandInput(''); } else if (key.return) { if (commandInput.trim() === '') { // If empty, suggest a default command based on package manager const suggested = await suggestCommand(showCommandPrompt); if (suggested) { setCommandInput(suggested); } } else { // User provided manual command const newSettings = { ...projectSettings, [showCommandPrompt === 'test' ? 'testCommand' : 'devCommand']: commandInput.trim() }; await saveSettings(newSettings); const selectedPane = panes[selectedIndex]; if (selectedPane) { // Check if first run const isFirstRun = showCommandPrompt === 'test' ? !projectSettings.firstTestRun : !projectSettings.firstDevRun; if (isFirstRun) { setCurrentCommandType(showCommandPrompt); setShowCommandPrompt(null); setShowFileCopyPrompt(true); } else { await runCommandInternal(showCommandPrompt, selectedPane); setShowCommandPrompt(null); setCommandInput(''); } } else { setShowCommandPrompt(null); setCommandInput(''); } } } return; } if (showMergePane) { // MergePane handles its own input return; } if (showNewPaneDialog) { if (key.escape) { setShowNewPaneDialog(false); setNewPanePrompt(''); } else if (key.ctrl && input === 'o') { // Open in external editor openEditor2(newPanePrompt, setNewPanePrompt); } // TextInput handles other input events return; } if (showMergeConfirmation) { if (input === 'y' || input === 'Y') { if (mergedPane) { closePane(mergedPane); } setShowMergeConfirmation(false); setMergedPane(null); } else if (input === 'n' || input === 'N' || key.escape) { setShowMergeConfirmation(false); setMergedPane(null); } return; } if (showCloseOptions) { if (key.escape) { setShowCloseOptions(false); setClosingPane(null); setSelectedCloseOption(0); } else if (key.upArrow) { setSelectedCloseOption(Math.max(0, selectedCloseOption - 1)); } else if (key.downArrow) { setSelectedCloseOption(Math.min(3, selectedCloseOption + 1)); } else if (key.return && closingPane) { handleCloseOption(selectedCloseOption, closingPane).then(() => { // Close the dialog after the action is performed setShowCloseOptions(false); setClosingPane(null); setSelectedCloseOption(0); }).catch(error => { setStatusMessage('Failed to close pane'); setTimeout(() => setStatusMessage(''), 2000); // Also close the dialog on error setShowCloseOptions(false); setClosingPane(null); setSelectedCloseOption(0); }); } return; } // Handle directional navigation with spatial awareness based on card grid layout if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { let targetIndex = null; if (key.upArrow) { targetIndex = findCardInDirection(selectedIndex, 'up'); } else if (key.downArrow) { targetIndex = findCardInDirection(selectedIndex, 'down'); } else if (key.leftArrow) { targetIndex = findCardInDirection(selectedIndex, 'left'); } else if (key.rightArrow) { targetIndex = findCardInDirection(selectedIndex, 'right'); } if (targetIndex !== null) { setSelectedIndex(targetIndex); } return; } if (input === 'q') { cleanExit(); } else if (!isLoading && (input === 'n' || (key.return && selectedIndex === panes.length))) { // Clear the prompt and show dialog in next tick to prevent 'n' bleeding through setNewPanePrompt(''); setShowNewPaneDialog(true); return; // Consume the 'n' keystroke so it doesn't propagate } else if (input === 'j' && selectedIndex < panes.length) { jumpToPane(panes[selectedIndex].paneId); } else if (input === 'x' && selectedIndex < panes.length) { setClosingPane(panes[selectedIndex]); setShowCloseOptions(true); setSelectedCloseOption(0); } else if (input === 'm' && selectedIndex < panes.length) { setMergingPane(panes[selectedIndex]); setShowMergePane(true); } else if (input === 't' && selectedIndex < panes.length) { await runCommand('test', panes[selectedIndex]); } else if (input === 'd' && selectedIndex < panes.length) { await runCommand('dev', panes[selectedIndex]); } else if (input === 'o' && selectedIndex < panes.length) { const pane = panes[selectedIndex]; if (pane.testWindowId || pane.devWindowId) { // If both exist, prefer dev (since it's usually more interactive) if (pane.devWindowId && pane.devStatus === 'running') { await attachBackgroundWindow(pane, 'dev'); } else if (pane.testWindowId) { await attachBackgroundWindow(pane, 'test'); } } else { setStatusMessage('No test or dev window to open'); setTimeout(() => setStatusMessage(''), 2000); } } else if (key.return && selectedIndex < panes.length) { jumpToPane(panes[selectedIndex].paneId); } }); // If showing merge pane, render only that if (showMergePane && mergingPane) { return (React.createElement(MergePane, { pane: mergingPane, mainBranch: getMainBranch(), onComplete: () => { // Close the pane after successful merge closePane(mergingPane); setShowMergePane(false); setMergingPane(null); }, onCancel: () => { setShowMergePane(false); setMergingPane(null); } })); } return (React.createElement(Box, { flexDirection: "column" }, React.createElement(Box, { marginBottom: 1 }, React.createElement(Text, { bold: true, color: "cyan" }, "dmux - ", projectName)), React.createElement(PanesGrid, { panes: panes, selectedIndex: selectedIndex, isLoading: isLoading, showNewPaneDialog: showNewPaneDialog, agentStatuses: agentStatuses }), isLoading && (React.createElement(LoadingIndicator, null)), showNewPaneDialog && !showAgentChoiceDialog && (React.createElement(NewPaneDialog, { value: newPanePrompt, onChange: setNewPanePrompt, onSubmit: (value) => { const promptValue = value; const agents = availableAgents; if (agents.length === 0) { setShowNewPaneDialog(false); setNewPanePrompt(''); createNewPaneHook(promptValue); } else if (agents.length === 1) { setShowNewPaneDialog(false); setNewPanePrompt(''); createNewPaneHook(promptValue, agents[0]); } else { setPendingPrompt(promptValue); setShowNewPaneDialog(false); setNewPanePrompt(''); setShowAgentChoiceDialog(true); setAgentChoice(agentChoice || 'claude'); } } })), showAgentChoiceDialog && (React.createElement(AgentChoiceDialog, { agentChoice: agentChoice })), isCreatingPane && (React.createElement(CreatingIndicator, { message: statusMessage })), showMergeConfirmation && mergedPane && (React.createElement(MergeConfirmationDialog, { pane: mergedPane })), showCloseOptions && closingPane && (React.createElement(CloseOptionsDialog, { pane: closingPane, selectedIndex: selectedCloseOption })), showCommandPrompt && (React.createElement(CommandPromptDialog, { type: showCommandPrompt, value: commandInput, onChange: setCommandInput })), showFileCopyPrompt && (React.createElement(FileCopyPrompt, null)), runningCommand && (React.createElement(RunningIndicator, null)), isUpdating && (React.createElement(UpdatingIndicator, null)), statusMessage && (React.createElement(Box, { marginTop: 1 }, React.createElement(Text, { color: "green" }, statusMessage))), React.createElement(FooterHelp, { show: !showNewPaneDialog && !showCommandPrompt, gridInfo: (() => { if (!process.env.DEBUG_DMUX) return undefined; const cols = Math.max(1, Math.floor(terminalWidth / 37)); const rows = Math.ceil((panes.length + 1) / cols); const pos = getCardGridPosition(selectedIndex); return `Grid: ${cols} cols × ${rows} rows | Selected: row ${pos.row}, col ${pos.col} | Terminal: ${terminalWidth}w`; })() }), React.createElement(Box, { marginTop: 1 }, React.createElement(Text, { dimColor: true }, "dmux v", packageJson.version, updateAvailable && updateInfo && (React.createElement(Text, { color: "yellow" }, " \u2022 New version ", updateInfo.latestVersion, " available! Run: npm i -g dmux@latest")))))); }; export default DmuxApp; //# sourceMappingURL=DmuxApp.js.map