diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 4b54ae9e2..4ceef4549 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -29,6 +29,7 @@ import { createGetAvailableEditorsHandler, createRefreshEditorsHandler, } from './routes/open-in-editor.js'; +import { createOpenInTerminalHandler } from './routes/open-in-terminal.js'; import { createInitGitHandler } from './routes/init-git.js'; import { createMigrateHandler } from './routes/migrate.js'; import { createStartDevHandler } from './routes/start-dev.js'; @@ -41,6 +42,7 @@ import { createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import { createDiscardChangesHandler } from './routes/discard-changes.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createWorktreeRoutes( @@ -97,6 +99,11 @@ export function createWorktreeRoutes( ); router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler()); router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); + router.post( + '/open-in-terminal', + validatePathParams('worktreePath'), + createOpenInTerminalHandler() + ); router.get('/default-editor', createGetDefaultEditorHandler()); router.get('/available-editors', createGetAvailableEditorsHandler()); router.post('/refresh-editors', createRefreshEditorsHandler()); @@ -125,5 +132,13 @@ export function createWorktreeRoutes( createRunInitScriptHandler(events) ); + // Discard changes route + router.post( + '/discard-changes', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createDiscardChangesHandler() + ); + return router; } diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index 75f43d7f5..314fa8ce5 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -39,7 +39,10 @@ export function createDiffsHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { // Check if worktree exists diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts new file mode 100644 index 000000000..4f15e0537 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -0,0 +1,112 @@ +/** + * POST /discard-changes endpoint - Discard all uncommitted changes in a worktree + * + * This performs a destructive operation that: + * 1. Resets staged changes (git reset HEAD) + * 2. Discards modified tracked files (git checkout .) + * 3. Removes untracked files and directories (git clean -fd) + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createDiscardChangesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Check for uncommitted changes first + const { stdout: status } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (!status.trim()) { + res.json({ + success: true, + result: { + discarded: false, + message: 'No changes to discard', + }, + }); + return; + } + + // Count the files that will be affected + const lines = status.trim().split('\n').filter(Boolean); + const fileCount = lines.length; + + // Get branch name before discarding + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const branchName = branchOutput.trim(); + + // Discard all changes: + // 1. Reset any staged changes + await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there's nothing staged + }); + + // 2. Discard changes in tracked files + await execAsync('git checkout .', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there are no tracked changes + }); + + // 3. Remove untracked files and directories + await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there are no untracked files + }); + + // Verify all changes were discarded + const { stdout: finalStatus } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (finalStatus.trim()) { + // Some changes couldn't be discarded (possibly ignored files or permission issues) + const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount - remainingCount, + filesRemaining: remainingCount, + branch: branchName, + message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + }, + }); + } else { + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount, + filesRemaining: 0, + branch: branchName, + message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + }, + }); + } + } catch (error) { + logError(error, 'Discard changes failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts index 4d29eb26b..f3d4ed1a6 100644 --- a/apps/server/src/routes/worktree/routes/file-diff.ts +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -37,7 +37,10 @@ export function createFileDiffHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); diff --git a/apps/server/src/routes/worktree/routes/info.ts b/apps/server/src/routes/worktree/routes/info.ts index 3d512452c..5c2eb808e 100644 --- a/apps/server/src/routes/worktree/routes/info.ts +++ b/apps/server/src/routes/worktree/routes/info.ts @@ -28,7 +28,10 @@ export function createInfoHandler() { } // Check if worktree exists (git worktrees are stored in project directory) - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { diff --git a/apps/server/src/routes/worktree/routes/open-in-terminal.ts b/apps/server/src/routes/worktree/routes/open-in-terminal.ts new file mode 100644 index 000000000..2270ed6f8 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -0,0 +1,50 @@ +/** + * POST /open-in-terminal endpoint - Open a terminal in a worktree directory + * + * This module uses @automaker/platform for cross-platform terminal launching. + */ + +import type { Request, Response } from 'express'; +import { isAbsolute } from 'path'; +import { openInTerminal } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createOpenInTerminalHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Security: Validate that worktreePath is an absolute path + if (!isAbsolute(worktreePath)) { + res.status(400).json({ + success: false, + error: 'worktreePath must be an absolute path', + }); + return; + } + + // Use the platform utility to open in terminal + const result = await openInTerminal(worktreePath); + res.json({ + success: true, + result: { + message: `Opened terminal in ${worktreePath}`, + terminalName: result.terminalName, + }, + }); + } catch (error) { + logError(error, 'Open in terminal failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/status.ts b/apps/server/src/routes/worktree/routes/status.ts index f9d6bf887..b44c5ae43 100644 --- a/apps/server/src/routes/worktree/routes/status.ts +++ b/apps/server/src/routes/worktree/routes/status.ts @@ -28,7 +28,10 @@ export function createStatusHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 454b7ec0f..472a57f3d 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1504,7 +1504,9 @@ Address the follow-up instructions above. Review the previous work and make the */ async verifyFeature(projectPath: string, featureId: string): Promise { // Worktrees are in project dir - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); let workDir = projectPath; try { @@ -1585,7 +1587,9 @@ Address the follow-up instructions above. Review the previous work and make the } } else { // Fallback: try to find worktree at legacy location - const legacyWorktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(legacyWorktreePath); workDir = legacyWorktreePath; @@ -1790,22 +1794,25 @@ Format your response as a structured markdown document.`; provider?: ModelProvider; title?: string; description?: string; + branchName?: string; }> > { const agents = await Promise.all( Array.from(this.runningFeatures.values()).map(async (rf) => { - // Try to fetch feature data to get title and description + // Try to fetch feature data to get title, description, and branchName let title: string | undefined; let description: string | undefined; + let branchName: string | undefined; try { const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); if (feature) { title = feature.title; description = feature.description; + branchName = feature.branchName; } } catch (error) { - // Silently ignore errors - title/description are optional + // Silently ignore errors - title/description/branchName are optional } return { @@ -1817,6 +1824,7 @@ Format your response as a structured markdown document.`; provider: rf.provider, title, description, + branchName, }; }) ); diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index aebed98bd..a88607034 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -22,29 +22,6 @@ export class ClaudeUsageService { private timeout = 30000; // 30 second timeout private isWindows = os.platform() === 'win32'; private isLinux = os.platform() === 'linux'; - // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode - // Detect Electron by checking for electron-specific env vars or process properties - // When in Electron, always use winpty to avoid ConPTY's AttachConsole errors - private isElectron = - !!(process.versions && (process.versions as Record).electron) || - !!process.env.ELECTRON_RUN_AS_NODE; - private useConptyFallback = false; // Track if we need to use winpty fallback on Windows - - /** - * Kill a PTY process with platform-specific handling. - * Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments. - * On Unix-like systems (macOS, Linux), we can specify the signal. - * - * @param ptyProcess - The PTY process to kill - * @param signal - The signal to send on Unix-like systems (default: 'SIGTERM') - */ - private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void { - if (this.isWindows) { - ptyProcess.kill(); - } else { - ptyProcess.kill(signal); - } - } /** * Check if Claude CLI is available on the system @@ -204,11 +181,11 @@ export class ClaudeUsageService { ? ['/c', 'claude', '--add-dir', workingDirectory] : ['-c', `claude --add-dir "${workingDirectory}"`]; - // Using 'any' for ptyProcess because node-pty types don't include 'killed' property - // eslint-disable-next-line @typescript-eslint/no-explicit-any let ptyProcess: any = null; // Build PTY spawn options + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Always use winpty on Windows to avoid this issue const ptyOptions: pty.IPtyForkOptions = { name: 'xterm-256color', cols: 120, @@ -220,78 +197,31 @@ export class ClaudeUsageService { } as Record, }; - // On Windows, always use winpty instead of ConPTY - // ConPTY requires AttachConsole which fails in many contexts: - // - Electron apps without a console - // - VS Code integrated terminal - // - Spawned from other applications - // The error happens in a subprocess so we can't catch it - must proactively disable if (this.isWindows) { (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; - logger.info( - '[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)' - ); + logger.debug('[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled)'); } try { ptyProcess = pty.spawn(shell, args, ptyOptions); } catch (spawnError) { const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); - // Check for Windows ConPTY-specific errors - if (this.isWindows && errorMessage.includes('AttachConsole failed')) { - // ConPTY failed - try winpty fallback - if (!this.useConptyFallback) { - logger.warn( - '[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback' - ); - this.useConptyFallback = true; - - try { - (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; - ptyProcess = pty.spawn(shell, args, ptyOptions); - logger.info( - '[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback' - ); - } catch (fallbackError) { - const fallbackMessage = - fallbackError instanceof Error ? fallbackError.message : String(fallbackError); - logger.error( - '[executeClaudeUsageCommandPty] Winpty fallback also failed:', - fallbackMessage - ); - reject( - new Error( - `Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}` - ) - ); - return; - } - } else { - logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage); - reject( - new Error( - `Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.` - ) - ); - return; - } - } else { - logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); - reject( - new Error( - `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` - ) - ); - return; - } + // Return a user-friendly error instead of crashing + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; } const timeoutId = setTimeout(() => { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { - this.killPtyProcess(ptyProcess); + ptyProcess.kill(); } // Don't fail if we have data - return it instead if (output.includes('Current session')) { @@ -319,28 +249,20 @@ export class ClaudeUsageService { ptyProcess.onData((data: string) => { output += data; - // Strip ANSI codes for easier matching - // eslint-disable-next-line no-control-regex - const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + // Strip ANSI codes for easier matching - use the same comprehensive pattern + const cleanOutput = this.stripAnsiCodes(output); // Check for specific authentication/permission errors - // Must be very specific to avoid false positives from garbled terminal encoding - // Removed permission_error check as it was causing false positives with winpty encoding - const authChecks = { - oauth: cleanOutput.includes('OAuth token does not meet scope requirement'), - tokenExpired: cleanOutput.includes('token_expired'), - // Only match if it looks like a JSON API error response - authError: - cleanOutput.includes('"type":"authentication_error"') || - cleanOutput.includes('"type": "authentication_error"'), - }; - const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError; - - if (hasAuthError) { + if ( + cleanOutput.includes('OAuth token does not meet scope requirement') || + cleanOutput.includes('permission_error') || + cleanOutput.includes('token_expired') || + cleanOutput.includes('authentication_error') + ) { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { - this.killPtyProcess(ptyProcess); + ptyProcess.kill(); } reject( new Error( @@ -352,27 +274,22 @@ export class ClaudeUsageService { } // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) - // Also check for percentage patterns that appear in usage output - const hasUsageIndicators = - cleanOutput.includes('Current session') || - (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) || - // Additional patterns for winpty - look for percentage patterns - /\d+%\s*(left|used|remaining)/i.test(cleanOutput) || - cleanOutput.includes('Resets in') || - cleanOutput.includes('Current week'); - - if (!hasSeenUsageData && hasUsageIndicators) { + if ( + !hasSeenUsageData && + (cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) + ) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key - // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s - // Windows doesn't support signals, so killPtyProcess handles platform differences + // Fallback: if ESC doesn't exit (Linux), kill the process after 2s + // Windows doesn't support Unix signals, so call kill() without args setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { - this.killPtyProcess(ptyProcess); + ptyProcess.kill(); } }, 2000); } @@ -400,18 +317,10 @@ export class ClaudeUsageService { } // Detect REPL prompt and send /usage command - // On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators - const isReplReady = - cleanOutput.includes('❯') || - cleanOutput.includes('? for shortcuts') || - // Fallback for winpty garbled encoding - detect CLI welcome screen elements - (cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) || - (cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) || - // Detect model indicator which appears when REPL is ready - (cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) || - (cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API')); - - if (!hasSentCommand && isReplReady) { + if ( + !hasSentCommand && + (cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts')) + ) { hasSentCommand = true; // Wait for REPL to fully settle setTimeout(() => { @@ -448,9 +357,11 @@ export class ClaudeUsageService { if (settled) return; settled = true; - // Check for auth errors - must be specific to avoid false positives - // Removed permission_error check as it was causing false positives with winpty encoding - if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) { + if ( + output.includes('token_expired') || + output.includes('authentication_error') || + output.includes('permission_error') + ) { reject(new Error("Authentication required - please run 'claude login'")); return; } @@ -468,10 +379,41 @@ export class ClaudeUsageService { /** * Strip ANSI escape codes from text + * Handles CSI, OSC, and other common ANSI sequences */ private stripAnsiCodes(text: string): string { + // First strip ANSI sequences (colors, etc) and handle CR // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + let clean = text + // CSI sequences: ESC [ ... (letter or @) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '') + // OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC + .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '') + // Other ESC sequences: ESC (letter) + .replace(/\x1B[A-Za-z]/g, '') + // Carriage returns: replace with newline to avoid concatenation + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); + + // Handle backspaces (\x08) by applying them + // If we encounter a backspace, remove the character before it + while (clean.includes('\x08')) { + clean = clean.replace(/[^\x08]\x08/, ''); + clean = clean.replace(/^\x08+/, ''); + } + + // Explicitly strip known "Synchronized Output" and "Window Title" garbage + // even if ESC is missing (seen in some environments) + clean = clean + .replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l + .replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL + .replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence) + + // Strip remaining non-printable control characters (except newline \n) + // ASCII 0-8, 11-31, 127 + clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ''); + + return clean; } /** @@ -550,7 +492,7 @@ export class ClaudeUsageService { sectionLabel: string, type: string ): { percentage: number; resetTime: string; resetText: string } { - let percentage = 0; + let percentage: number | null = null; let resetTime = this.getDefaultResetTime(type); let resetText = ''; @@ -564,7 +506,7 @@ export class ClaudeUsageService { } if (sectionIndex === -1) { - return { percentage, resetTime, resetText }; + return { percentage: 0, resetTime, resetText }; } // Look at the lines following the section header (within a window of 5 lines) @@ -572,7 +514,8 @@ export class ClaudeUsageService { for (const line of searchWindow) { // Extract percentage - only take the first match (avoid picking up next section's data) - if (percentage === 0) { + // Use null to track "not found" since 0% is a valid percentage (100% left = 0% used) + if (percentage === null) { const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); if (percentMatch) { const value = parseInt(percentMatch[1], 10); @@ -584,18 +527,31 @@ export class ClaudeUsageService { // Extract reset time - only take the first match if (!resetText && line.toLowerCase().includes('reset')) { - resetText = line; + // Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes + const match = line.match(/(Resets?.*)$/i); + // If regex fails despite 'includes', likely a complex string issues - verify match before using line + // Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage + if (match) { + resetText = match[1]; + } } } // Parse the reset time if we found one if (resetText) { + // Clean up resetText: remove percentage info if it was matched on the same line + // e.g. "46%used Resets5:59pm" -> " Resets5:59pm" + resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim(); + + // Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm") + resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2'); + resetTime = this.parseResetTime(resetText, type); // Strip timezone like "(Asia/Dubai)" from the display text resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); } - return { percentage, resetTime, resetText }; + return { percentage: percentage ?? 0, resetTime, resetText }; } /** @@ -624,7 +580,9 @@ export class ClaudeUsageService { } // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" - const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + // Note: \s* allows for optional space after "resets" to handle cases where + // ANSI stripping may remove whitespace (e.g., "Resets6pm" from terminal output) + const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); if (simpleTimeMatch) { let hours = parseInt(simpleTimeMatch[1], 10); const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; @@ -649,8 +607,11 @@ export class ClaudeUsageService { } // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" + // The regex explicitly matches only valid 3-letter month abbreviations to avoid + // matching words like "Resets" when there's no space separator. + // Optional "resets\s*" prefix handles cases with or without space after "Resets" const dateMatch = text.match( - /([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i + /(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i ); if (dateMatch) { const monthName = dateMatch[1]; diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 07ad13c92..0cbb06dc2 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -124,6 +124,55 @@ describe('claude-usage-service.ts', () => { expect(result).toBe('Plain text'); }); + + it('should strip OSC sequences (window title changes)', () => { + const service = new ClaudeUsageService(); + // OSC sequences like ]0;Title\x07 are used to set window titles + const input = '\x1B]0;Claude Code\x07Current session 35% used'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session 35% used'); + }); + + it('should strip private mode sequences', () => { + const service = new ClaudeUsageService(); + // Private mode sequences like [?2026h and [?2026l + const input = '\x1B[?2026hCurrent session\x1B[?2026l 35% used'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session 35% used'); + }); + + it('should handle complex real-world output with multiple sequence types', () => { + const service = new ClaudeUsageService(); + // Real example from the bug report + const input = + '\x1B[?2026l\x1B]0;\x1B]0;⚡ Claude Code\x07\x1B[?2026hCurrent session 35%used Resets6pm'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session 35%used Resets6pm'); + }); + + it('should remove carriage returns but keep newlines', () => { + const service = new ClaudeUsageService(); + const input = 'Line 1\r\nLine 2\rLine 3\n'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Line 1\nLine 2Line 3\n'); + }); + + it('should remove backspace characters', () => { + const service = new ClaudeUsageService(); + const input = 'Hello\x08\x08World'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('HelloWorld'); + }); }); describe('parseResetTime', () => { diff --git a/apps/ui/src/components/ui/command.tsx b/apps/ui/src/components/ui/command.tsx index 6c582d0dd..c05bc622e 100644 --- a/apps/ui/src/components/ui/command.tsx +++ b/apps/ui/src/components/ui/command.tsx @@ -65,9 +65,10 @@ function CommandInput({ diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 7928c21ca..0c5afb516 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -60,7 +60,11 @@ import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog'; import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog'; import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog'; import { WorktreePanel } from './board-view/worktree-panel'; -import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types'; +import type { + PRInfo, + WorktreeInfo, + ConflictResolutionSource, +} from './board-view/worktree-panel/types'; import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; import { useBoardFeatures, @@ -414,7 +418,10 @@ export function BoardView() { // Use the branch from selectedWorktree, or fall back to main worktree's branch const selectedWorktreeBranch = currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main'; - + // Get the branch from the main worktree (the far-left "Branch" dropdown) + // This is used for operations like merge and pull/resolve conflicts that should target + // the main worktree's branch, not the highlighted/clicked worktree's branch + const mainWorktreeBranch = worktrees.find((w) => w.isMain)?.branch || 'main'; // Calculate unarchived card counts per branch const branchCardCounts = useMemo(() => { // Use primary worktree branch as default for features without branchName @@ -521,9 +528,10 @@ export function BoardView() { // Empty string clears the branch assignment, moving features to main/current branch finalBranchName = ''; } else if (workMode === 'auto') { - // Auto-generate a branch name based on primary branch (main/master) and timestamp + // Auto-generate a branch name based on current branch and timestamp // Always use primary branch to avoid nested feature/feature/... paths - const baseBranch = getPrimaryWorktreeBranch(currentProject.path) || 'main'; + const baseBranch = + currentWorktreeBranch || getPrimaryWorktreeBranch(currentProject.path) || 'main'; const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substring(2, 6); finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`; @@ -603,6 +611,7 @@ export function BoardView() { selectedFeatureIds, updateFeature, exitSelectionMode, + currentWorktreeBranch, getPrimaryWorktreeBranch, addAndSelectWorktree, setWorktreeRefreshKey, @@ -790,15 +799,18 @@ export function BoardView() { [handleAddFeature, handleStartImplementation, defaultSkipTests] ); - // Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts + // Handler for resolving conflicts - creates a feature to pull from a branch and resolve conflicts + // source: 'worktree' = pull from the worktree's own remote branch (origin/{worktree.branch}) + // source: 'selected' = pull from the main worktree's branch (the far-left "Branch" dropdown) const handleResolveConflicts = useCallback( - async (worktree: WorktreeInfo) => { - const remoteBranch = `origin/${worktree.branch}`; + async (worktree: WorktreeInfo, source: ConflictResolutionSource) => { + const sourceBranch = source === 'worktree' ? worktree.branch : mainWorktreeBranch; + const remoteBranch = `origin/${sourceBranch}`; const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`; // Create the feature const featureData = { - title: `Resolve Merge Conflicts`, + title: `Resolve Merge Conflicts from ${sourceBranch}`, category: 'Maintenance', description, images: [], @@ -830,7 +842,7 @@ export function BoardView() { }); } }, - [handleAddFeature, handleStartImplementation, defaultSkipTests] + [handleAddFeature, handleStartImplementation, defaultSkipTests, mainWorktreeBranch] ); // Handler for "Make" button - creates a feature and immediately starts it @@ -1456,6 +1468,7 @@ export function BoardView() { id: f.id, branchName: f.branchName, }))} + targetBranch={mainWorktreeBranch} /> )} @@ -1662,6 +1675,7 @@ export function BoardView() { featureId={outputFeature?.id || ''} featureStatus={outputFeature?.status} onNumberKeyPress={handleOutputModalNumberKeyPress} + branchName={outputFeature?.branchName} /> {/* Archive All Verified Dialog */} @@ -1812,6 +1826,7 @@ export function BoardView() { onOpenChange={setShowMergeWorktreeDialog} projectPath={currentProject.path} worktree={selectedWorktreeForAction} + targetBranch={mainWorktreeBranch} affectedFeatureCount={ selectedWorktreeForAction ? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length @@ -1852,7 +1867,7 @@ export function BoardView() { onOpenChange={setShowCreatePRDialog} worktree={selectedWorktreeForAction} projectPath={currentProject?.path || null} - defaultBaseBranch={selectedWorktreeBranch} + defaultBaseBranch={mainWorktreeBranch} onCreated={(prUrl) => { // If a PR was created and we have the worktree branch, update all features on that branch with the PR URL if (prUrl && selectedWorktreeForAction?.branch) { diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx index 848706758..918e22be9 100644 --- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -27,6 +27,8 @@ interface AgentOutputModalProps { onNumberKeyPress?: (key: string) => void; /** Project path - if not provided, falls back to window.__currentProject for backward compatibility */ projectPath?: string; + /** Branch name for the feature worktree - used when viewing changes */ + branchName?: string; } type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes'; @@ -39,6 +41,7 @@ export function AgentOutputModal({ featureStatus, onNumberKeyPress, projectPath: projectPathProp, + branchName, }: AgentOutputModalProps) { const isBacklogPlan = featureId.startsWith('backlog-plan:'); const [output, setOutput] = useState(''); @@ -432,7 +435,7 @@ export function AgentOutputModal({ {projectPath ? ( void; + worktree: WorktreeInfo | null; + projectPath: string; +} + +export function ViewWorktreeChangesDialog({ + open, + onOpenChange, + worktree, + projectPath, +}: ViewWorktreeChangesDialogProps) { + if (!worktree) return null; + + return ( + + + + + + View Changes + + + Changes in the{' '} + {worktree.branch} worktree. + {worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && ( + + ({worktree.changedFilesCount} file + {worktree.changedFilesCount > 1 ? 's' : ''} changed) + + )} + + + +
+
+ +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index c9aba7577..599ee7923 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -118,14 +118,15 @@ export function useBoardActions({ const workMode = featureData.workMode || 'current'; // Determine final branch name based on work mode: - // - 'current': No branch name, work on current branch (no worktree) + // - 'current': Use current worktree's branch (or undefined if on main) // - 'auto': Auto-generate branch name based on current branch // - 'custom': Use the provided branch name let finalBranchName: string | undefined; if (workMode === 'current') { - // No worktree isolation - work directly on current branch - finalBranchName = undefined; + // Work directly on current branch - use the current worktree's branch if not on main + // This ensures features created on a non-main worktree are associated with that worktree + finalBranchName = currentWorktreeBranch || undefined; } else if (workMode === 'auto') { // Auto-generate a branch name based on primary branch (main/master) and timestamp // Always use primary branch to avoid nested feature/feature/... paths @@ -282,7 +283,9 @@ export function useBoardActions({ let finalBranchName: string | undefined; if (workMode === 'current') { - finalBranchName = undefined; + // Work directly on current branch - use the current worktree's branch if not on main + // This ensures features updated on a non-main worktree are associated with that worktree + finalBranchName = currentWorktreeBranch || undefined; } else if (workMode === 'auto') { // Auto-generate a branch name based on primary branch (main/master) and timestamp // Always use primary branch to avoid nested feature/feature/... paths diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx index c7e7b7ef3..40fa23e6d 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx @@ -72,7 +72,7 @@ export function BranchSwitchDropdown({ onKeyDown={(e) => e.stopPropagation()} onKeyUp={(e) => e.stopPropagation()} onKeyPress={(e) => e.stopPropagation()} - className="h-7 pl-7 text-xs" + className="h-7 pl-7 text-base" autoFocus /> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx index 859ad34cf..5276d8474 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx @@ -132,11 +132,11 @@ export function DevServerLogsPanel({ return ( !isOpen && onClose()}> {/* Compact Header */} - +
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 459e2ce81..cc12421ad 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -25,11 +25,20 @@ import { AlertCircle, RefreshCw, Copy, + Eye, + Terminal, ScrollText, + Undo2, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; -import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; +import type { + WorktreeInfo, + DevServerInfo, + PRInfo, + GitRepoStatus, + ConflictResolutionSource, +} from '../types'; import { TooltipWrapper } from './tooltip-wrapper'; import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors'; import { getEditorIcon } from '@/components/icons/editor-icons'; @@ -39,6 +48,7 @@ interface WorktreeActionsDropdownProps { isSelected: boolean; aheadCount: number; behindCount: number; + hasRemoteTracking: boolean; isPulling: boolean; isPushing: boolean; isStartingDevServer: boolean; @@ -47,14 +57,19 @@ interface WorktreeActionsDropdownProps { gitRepoStatus: GitRepoStatus; /** When true, renders as a standalone button (not attached to another element) */ standalone?: boolean; + /** Target branch for merge operations (defaults to 'main') */ + targetBranch?: string; onOpenChange: (open: boolean) => void; onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; + onOpenInTerminal: (worktree: WorktreeInfo) => void; + onViewChanges: (worktree: WorktreeInfo) => void; + onDiscardChanges: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; - onResolveConflicts: (worktree: WorktreeInfo) => void; + onResolveConflicts: (worktree: WorktreeInfo, source: ConflictResolutionSource) => void; onMerge: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void; onStartDevServer: (worktree: WorktreeInfo) => void; @@ -70,6 +85,7 @@ export function WorktreeActionsDropdown({ isSelected, aheadCount, behindCount, + hasRemoteTracking, isPulling, isPushing, isStartingDevServer, @@ -77,10 +93,14 @@ export function WorktreeActionsDropdown({ devServerInfo, gitRepoStatus, standalone = false, + targetBranch = 'main', onOpenChange, onPull, onPush, onOpenInEditor, + onOpenInTerminal, + onViewChanges, + onDiscardChanges, onCommit, onCreatePR, onAddressPRComments, @@ -206,7 +226,7 @@ export function WorktreeActionsDropdown({ canPerformGitOps && onPush(worktree)} - disabled={isPushing || aheadCount === 0 || !canPerformGitOps} + disabled={isPushing || (hasRemoteTracking && aheadCount === 0) || !canPerformGitOps} className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')} > @@ -217,22 +237,73 @@ export function WorktreeActionsDropdown({ {aheadCount} ahead )} - - - - canPerformGitOps && onResolveConflicts(worktree)} - disabled={!canPerformGitOps} - className={cn( - 'text-xs text-purple-500 focus:text-purple-600', - !canPerformGitOps && 'opacity-50 cursor-not-allowed' + {canPerformGitOps && !hasRemoteTracking && ( + + new + )} - > - - Pull & Resolve Conflicts - {!canPerformGitOps && } + {/* For main branch (non-worktree), show direct pull & resolve - uses the main worktree's current branch */} + {worktree.isMain ? ( + + canPerformGitOps && onResolveConflicts(worktree, 'selected')} + disabled={!canPerformGitOps} + className={cn( + 'text-xs text-purple-500 focus:text-purple-600', + !canPerformGitOps && 'opacity-50 cursor-not-allowed' + )} + > + + Pull & Resolve Conflicts + {!canPerformGitOps && } + + + ) : ( + /* For worktrees (non-main), show submenu with options */ + + + + + Pull & Resolve Conflicts + {!canPerformGitOps && ( + + )} + + + canPerformGitOps && onResolveConflicts(worktree, 'worktree')} + disabled={!canPerformGitOps} + className="text-xs" + > + Worktree Branch + {worktree.branch} + + canPerformGitOps && onResolveConflicts(worktree, 'selected')} + disabled={!canPerformGitOps} + className="text-xs" + > + Main Branch + {targetBranch} + + + + + )} {!worktree.isMain && ( - Merge to Main + Merge to {targetBranch} {!canPerformGitOps && ( )} @@ -303,6 +374,10 @@ export function WorktreeActionsDropdown({ )} + onOpenInTerminal(worktree)} className="text-xs"> + + Open in Terminal + {!worktree.isMain && hasInitScript && ( onRunInitScript(worktree)} className="text-xs"> @@ -310,6 +385,11 @@ export function WorktreeActionsDropdown({ )} + onViewChanges(worktree)} className="text-xs"> + + View Changes + + {worktree.hasChanges && ( )} + + {worktree.hasChanges && ( + + gitRepoStatus.isGitRepo && onDiscardChanges(worktree)} + disabled={!gitRepoStatus.isGitRepo} + className={cn( + 'text-xs text-destructive focus:text-destructive', + !gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed' + )} + > + + Discard Changes + {!gitRepoStatus.isGitRepo && ( + + )} + + + )} {!worktree.isMain && ( <> - onDeleteWorktree(worktree)} className="text-xs text-destructive focus:text-destructive" diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 5cb379d3d..86b5b06e1 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -3,7 +3,14 @@ import { Button } from '@/components/ui/button'; import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; +import type { + WorktreeInfo, + BranchInfo, + DevServerInfo, + PRInfo, + GitRepoStatus, + ConflictResolutionSource, +} from '../types'; import { BranchSwitchDropdown } from './branch-switch-dropdown'; import { WorktreeActionsDropdown } from './worktree-actions-dropdown'; @@ -27,7 +34,10 @@ interface WorktreeTabProps { isStartingDevServer: boolean; aheadCount: number; behindCount: number; + hasRemoteTracking: boolean; gitRepoStatus: GitRepoStatus; + /** Target branch for merge operations */ + targetBranch?: string; onSelectWorktree: (worktree: WorktreeInfo) => void; onBranchDropdownOpenChange: (open: boolean) => void; onActionsDropdownOpenChange: (open: boolean) => void; @@ -37,10 +47,13 @@ interface WorktreeTabProps { onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; + onOpenInTerminal: (worktree: WorktreeInfo) => void; + onViewChanges: (worktree: WorktreeInfo) => void; + onDiscardChanges: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; - onResolveConflicts: (worktree: WorktreeInfo) => void; + onResolveConflicts: (worktree: WorktreeInfo, source: ConflictResolutionSource) => void; onMerge: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void; onStartDevServer: (worktree: WorktreeInfo) => void; @@ -71,7 +84,9 @@ export function WorktreeTab({ isStartingDevServer, aheadCount, behindCount, + hasRemoteTracking, gitRepoStatus, + targetBranch = 'main', onSelectWorktree, onBranchDropdownOpenChange, onActionsDropdownOpenChange, @@ -81,6 +96,9 @@ export function WorktreeTab({ onPull, onPush, onOpenInEditor, + onOpenInTerminal, + onViewChanges, + onDiscardChanges, onCommit, onCreatePR, onAddressPRComments, @@ -332,16 +350,21 @@ export function WorktreeTab({ isSelected={isSelected} aheadCount={aheadCount} behindCount={behindCount} + hasRemoteTracking={hasRemoteTracking} isPulling={isPulling} isPushing={isPushing} isStartingDevServer={isStartingDevServer} isDevServerRunning={isDevServerRunning} devServerInfo={devServerInfo} gitRepoStatus={gitRepoStatus} + targetBranch={targetBranch} onOpenChange={onActionsDropdownOpenChange} onPull={onPull} onPush={onPush} onOpenInEditor={onOpenInEditor} + onOpenInTerminal={onOpenInTerminal} + onViewChanges={onViewChanges} + onDiscardChanges={onDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts index 1cb1cec6d..328f720ac 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts @@ -9,6 +9,7 @@ export function useBranches() { const [branches, setBranches] = useState([]); const [aheadCount, setAheadCount] = useState(0); const [behindCount, setBehindCount] = useState(0); + const [hasRemoteTracking, setHasRemoteTracking] = useState(false); const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [branchFilter, setBranchFilter] = useState(''); const [gitRepoStatus, setGitRepoStatus] = useState({ @@ -21,6 +22,7 @@ export function useBranches() { setBranches([]); setAheadCount(0); setBehindCount(0); + setHasRemoteTracking(false); }, []); const fetchBranches = useCallback( @@ -37,6 +39,11 @@ export function useBranches() { setBranches(result.result.branches); setAheadCount(result.result.aheadCount || 0); setBehindCount(result.result.behindCount || 0); + // hasRemoteTracking indicates if the branch has a remote tracking branch + // If aheadCount or behindCount are present, we have remote tracking + setHasRemoteTracking( + result.result.aheadCount !== undefined || result.result.behindCount !== undefined + ); setGitRepoStatus({ isGitRepo: true, hasCommits: true }); } else if (result.code === 'NOT_GIT_REPO') { // Not a git repository - clear branches silently without logging an error @@ -76,6 +83,7 @@ export function useBranches() { filteredBranches, aheadCount, behindCount, + hasRemoteTracking, isLoadingBranches, branchFilter, setBranchFilter, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index f1f245dca..b666dea96 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -1,7 +1,9 @@ import { useState, useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { createLogger } from '@automaker/utils/logger'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; import type { WorktreeInfo } from '../types'; const logger = createLogger('WorktreeActions'); @@ -39,6 +41,8 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre const [isPushing, setIsPushing] = useState(false); const [isSwitching, setIsSwitching] = useState(false); const [isActivating, setIsActivating] = useState(false); + const navigate = useNavigate(); + const setPendingTerminalCwd = useAppStore((state) => state.setPendingTerminalCwd); const handleSwitchBranch = useCallback( async (worktree: WorktreeInfo, branchName: string) => { @@ -143,6 +147,17 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre } }, []); + const handleOpenInTerminal = useCallback( + (worktree: WorktreeInfo) => { + // Set the pending terminal cwd to the worktree path + setPendingTerminalCwd(worktree.path); + // Navigate to the terminal page + navigate({ to: '/terminal' }); + logger.info('Opening terminal for worktree:', worktree.path); + }, + [navigate, setPendingTerminalCwd] + ); + return { isPulling, isPushing, @@ -153,5 +168,6 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre handlePull, handlePush, handleOpenInEditor, + handleOpenInTerminal, }; } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index d20400489..971795c02 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -64,6 +64,8 @@ export interface PRInfo { }>; } +export type ConflictResolutionSource = 'worktree' | 'selected'; + export interface WorktreePanelProps { projectPath: string; onCreateWorktree: () => void; @@ -72,11 +74,12 @@ export interface WorktreePanelProps { onCreatePR: (worktree: WorktreeInfo) => void; onCreateBranch: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; - onResolveConflicts: (worktree: WorktreeInfo) => void; + onResolveConflicts: (worktree: WorktreeInfo, source: ConflictResolutionSource) => void; onMerge: (worktree: WorktreeInfo) => void; onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; runningFeatureIds?: string[]; features?: FeatureInfo[]; branchCardCounts?: Record; // Map of branch name to unarchived card count refreshTrigger?: number; + targetBranch?: string; } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 2cc844f4c..9d7a5edd0 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -20,6 +20,9 @@ import { WorktreeActionsDropdown, BranchSwitchDropdown, } from './components'; +import { ViewWorktreeChangesDialog } from '../dialogs'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { Undo2 } from 'lucide-react'; export function WorktreePanel({ projectPath, @@ -36,6 +39,7 @@ export function WorktreePanel({ features = [], branchCardCounts, refreshTrigger = 0, + targetBranch = 'main', }: WorktreePanelProps) { const { isLoading, @@ -62,6 +66,7 @@ export function WorktreePanel({ filteredBranches, aheadCount, behindCount, + hasRemoteTracking, isLoadingBranches, branchFilter, setBranchFilter, @@ -79,6 +84,7 @@ export function WorktreePanel({ handlePull, handlePush, handleOpenInEditor, + handleOpenInTerminal, } = useWorktreeActions({ fetchWorktrees, fetchBranches, @@ -92,6 +98,14 @@ export function WorktreePanel({ // Track whether init script exists for the project const [hasInitScript, setHasInitScript] = useState(false); + // View changes dialog state + const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false); + const [viewChangesWorktree, setViewChangesWorktree] = useState(null); + + // Discard changes confirmation dialog state + const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false); + const [discardChangesWorktree, setDiscardChangesWorktree] = useState(null); + // Log panel state management const [logPanelOpen, setLogPanelOpen] = useState(false); const [logPanelWorktree, setLogPanelWorktree] = useState(null); @@ -178,6 +192,41 @@ export function WorktreePanel({ [projectPath] ); + const handleViewChanges = useCallback((worktree: WorktreeInfo) => { + setViewChangesWorktree(worktree); + setViewChangesDialogOpen(true); + }, []); + + const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => { + setDiscardChangesWorktree(worktree); + setDiscardChangesDialogOpen(true); + }, []); + + const handleConfirmDiscardChanges = useCallback(async () => { + if (!discardChangesWorktree) return; + + try { + const api = getHttpApiClient(); + const result = await api.worktree.discardChanges(discardChangesWorktree.path); + + if (result.success) { + toast.success('Changes discarded', { + description: `Discarded changes in ${discardChangesWorktree.branch}`, + }); + // Refresh worktrees to update the changes status + fetchWorktrees({ silent: true }); + } else { + toast.error('Failed to discard changes', { + description: result.error || 'Unknown error', + }); + } + } catch (error) { + toast.error('Failed to discard changes', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, [discardChangesWorktree, fetchWorktrees]); + // Handle opening the log panel for a specific worktree const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => { setLogPanelWorktree(worktree); @@ -235,16 +284,21 @@ export function WorktreePanel({ standalone={true} aheadCount={aheadCount} behindCount={behindCount} + hasRemoteTracking={hasRemoteTracking} isPulling={isPulling} isPushing={isPushing} isStartingDevServer={isStartingDevServer} isDevServerRunning={isDevServerRunning(selectedWorktree)} devServerInfo={getDevServerInfo(selectedWorktree)} gitRepoStatus={gitRepoStatus} + targetBranch={targetBranch} onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)} onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInTerminal={handleOpenInTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -289,6 +343,36 @@ export function WorktreePanel({ )} + + {/* View Changes Dialog */} + + + {/* Discard Changes Confirmation Dialog */} + + + {/* Dev Server Logs Panel */} +
); } @@ -322,7 +406,9 @@ export function WorktreePanel({ isStartingDevServer={isStartingDevServer} aheadCount={aheadCount} behindCount={behindCount} + hasRemoteTracking={hasRemoteTracking} gitRepoStatus={gitRepoStatus} + targetBranch={targetBranch} onSelectWorktree={handleSelectWorktree} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)} @@ -332,6 +418,9 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInTerminal={handleOpenInTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -380,7 +469,9 @@ export function WorktreePanel({ isStartingDevServer={isStartingDevServer} aheadCount={aheadCount} behindCount={behindCount} + hasRemoteTracking={hasRemoteTracking} gitRepoStatus={gitRepoStatus} + targetBranch={targetBranch} onSelectWorktree={handleSelectWorktree} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)} @@ -390,6 +481,9 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInTerminal={handleOpenInTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -435,6 +529,27 @@ export function WorktreePanel({ )} + {/* View Changes Dialog */} + + + {/* Discard Changes Confirmation Dialog */} + + {/* Dev Server Logs Panel */} {/* Backlog Plan Dialog */} diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index d46729c10..32eb3f88b 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -275,6 +275,7 @@ export function RunningAgentsView() { } featureId={selectedAgent.featureId} featureStatus="running" + branchName={selectedAgent.branchName} /> )} diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 0cad04083..adf22ca6a 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -233,6 +233,7 @@ export function TerminalView() { setTerminalFontFamily, setTerminalLineHeight, updateTerminalPanelSizes, + setPendingTerminalCwd, } = useAppStore(); const [status, setStatus] = useState(null); @@ -526,6 +527,100 @@ export function TerminalView() { } }, [terminalState.isUnlocked, fetchServerSettings]); + // Handle pending terminal cwd (from "open in terminal" action on worktree menu) + // When pendingTerminalCwd is set and we're ready, create a terminal with that cwd + const pendingTerminalCwdRef = useRef(null); + const pendingTerminalCreatedRef = useRef(false); + useEffect(() => { + const pendingCwd = terminalState.pendingTerminalCwd; + + // Skip if no pending cwd + if (!pendingCwd) { + // Reset the created ref when there's no pending cwd + pendingTerminalCreatedRef.current = false; + pendingTerminalCwdRef.current = null; + return; + } + + // Skip if we already created a terminal for this exact cwd + if (pendingCwd === pendingTerminalCwdRef.current && pendingTerminalCreatedRef.current) { + return; + } + + // Skip if still loading or terminal not enabled + if (loading || !status?.enabled) { + logger.debug('Waiting for terminal to be ready before creating terminal for pending cwd'); + return; + } + + // Skip if password is required but not unlocked yet + if (status.passwordRequired && !terminalState.isUnlocked) { + logger.debug('Waiting for terminal unlock before creating terminal for pending cwd'); + return; + } + + // Track that we're processing this cwd + pendingTerminalCwdRef.current = pendingCwd; + + // Create a terminal with the pending cwd + logger.info('Creating terminal from pending cwd:', pendingCwd); + + // Create terminal with the specified cwd + const createTerminalWithCwd = async () => { + try { + const headers: Record = {}; + const authToken = useAppStore.getState().terminalState.authToken; + if (authToken) { + headers['X-Terminal-Token'] = authToken; + } + + const response = await apiFetch('/api/terminal/sessions', 'POST', { + headers, + body: { cwd: pendingCwd, cols: 80, rows: 24 }, + }); + const data = await response.json(); + + if (data.success) { + // Mark as successfully created + pendingTerminalCreatedRef.current = true; + addTerminalToLayout(data.data.id); + // Mark this session as new for running initial command + if (defaultRunScript) { + setNewSessionIds((prev) => new Set(prev).add(data.data.id)); + } + fetchServerSettings(); + toast.success(`Opened terminal in ${pendingCwd.split('/').pop() || pendingCwd}`); + // Clear the pending cwd after successful creation + setPendingTerminalCwd(null); + } else { + logger.error('Failed to create session from pending cwd:', data.error); + toast.error('Failed to open terminal', { + description: data.error || 'Unknown error', + }); + // Clear pending cwd on failure to prevent infinite retries + setPendingTerminalCwd(null); + } + } catch (err) { + logger.error('Create session error from pending cwd:', err); + toast.error('Failed to open terminal'); + // Clear pending cwd on error to prevent infinite retries + setPendingTerminalCwd(null); + } + }; + + createTerminalWithCwd(); + }, [ + terminalState.pendingTerminalCwd, + terminalState.isUnlocked, + loading, + status?.enabled, + status?.passwordRequired, + setPendingTerminalCwd, + addTerminalToLayout, + defaultRunScript, + fetchServerSettings, + ]); + // Handle project switching - save and restore terminal layouts // Uses terminalState.lastActiveProjectPath (persisted in store) instead of a local ref // This ensures terminals persist when navigating away from terminal route and back @@ -817,9 +912,11 @@ export function TerminalView() { // Create a new terminal session // targetSessionId: the terminal to split (if splitting an existing terminal) + // cwd: optional working directory for the terminal (defaults to currentProject.path) const createTerminal = async ( direction?: 'horizontal' | 'vertical', - targetSessionId?: string + targetSessionId?: string, + cwd?: string ) => { if (!canCreateTerminal('[Terminal] Debounced terminal creation')) { return; @@ -831,9 +928,13 @@ export function TerminalView() { headers['X-Terminal-Token'] = terminalState.authToken; } + // Use provided cwd, then pendingTerminalCwd, then currentProject.path + const terminalCwd = + cwd || terminalState.pendingTerminalCwd || currentProject?.path || undefined; + const response = await apiFetch('/api/terminal/sessions', 'POST', { headers, - body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 }, + body: { cwd: terminalCwd, cols: 80, rows: 24 }, }); const data = await response.json(); diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 66bbd537a..eb8e71084 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -220,6 +220,7 @@ export interface RunningAgent { isAutoMode: boolean; title?: string; description?: string; + branchName?: string; } export interface RunningAgentsResult { @@ -1815,6 +1816,17 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, + openInTerminal: async (worktreePath: string) => { + console.log('[Mock] Opening in terminal:', worktreePath); + return { + success: true, + result: { + message: `Opened terminal in ${worktreePath}`, + terminalName: 'Terminal', + }, + }; + }, + getDefaultEditor: async () => { console.log('[Mock] Getting default editor'); return { @@ -1964,6 +1976,20 @@ function createMockWorktreeAPI(): WorktreeAPI { console.log('[Mock] Unsubscribing from init script events'); }; }, + + discardChanges: async (worktreePath: string) => { + console.log('[Mock] Discarding changes:', { worktreePath }); + return { + success: true, + result: { + discarded: true, + filesDiscarded: 0, + filesRemaining: 0, + branch: 'main', + message: 'Mock: Changes discarded successfully', + }, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index d7ac52806..d8640be56 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1763,6 +1763,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/switch-branch', { worktreePath, branchName }), openInEditor: (worktreePath: string, editorCommand?: string) => this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), + openInTerminal: (worktreePath: string) => + this.post('/api/worktree/open-in-terminal', { worktreePath }), getDefaultEditor: () => this.get('/api/worktree/default-editor'), getAvailableEditors: () => this.get('/api/worktree/available-editors'), refreshEditors: () => this.post('/api/worktree/refresh-editors', {}), @@ -1800,6 +1802,8 @@ export class HttpApiClient implements ElectronAPI { this.httpDelete('/api/worktree/init-script', { projectPath }), runInitScript: (projectPath: string, worktreePath: string, branch: string) => this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }), + discardChanges: (worktreePath: string) => + this.post('/api/worktree/discard-changes', { worktreePath }), onInitScriptEvent: ( callback: (event: { type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index b4fbb89b8..a34f794b5 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -438,6 +438,7 @@ export interface TerminalState { lineHeight: number; // Line height multiplier for terminal text maxSessions: number; // Maximum concurrent terminal sessions (server setting) lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches + pendingTerminalCwd: string | null; // Pending cwd to use when creating next terminal (from "open in terminal" action) } // Persisted terminal layout - now includes sessionIds for reconnection @@ -1124,6 +1125,7 @@ export interface AppActions { setTerminalLineHeight: (lineHeight: number) => void; setTerminalMaxSessions: (maxSessions: number) => void; setTerminalLastActiveProjectPath: (projectPath: string | null) => void; + setPendingTerminalCwd: (cwd: string | null) => void; addTerminalTab: (name?: string) => string; removeTerminalTab: (tabId: string) => void; setActiveTerminalTab: (tabId: string) => void; @@ -1338,6 +1340,7 @@ const initialState: AppState = { lineHeight: 1.0, maxSessions: 100, lastActiveProjectPath: null, + pendingTerminalCwd: null, }, terminalLayoutByProject: {}, specCreatingForProject: null, @@ -2705,6 +2708,9 @@ export const useAppStore = create()((set, get) => ({ maxSessions: current.maxSessions, // Preserve lastActiveProjectPath - it will be updated separately when needed lastActiveProjectPath: current.lastActiveProjectPath, + // Preserve pendingTerminalCwd - this is set by "open in terminal" action and should + // survive the clearTerminalState() call that happens during project switching + pendingTerminalCwd: current.pendingTerminalCwd, }, }); }, @@ -2796,6 +2802,13 @@ export const useAppStore = create()((set, get) => ({ }); }, + setPendingTerminalCwd: (cwd) => { + const current = get().terminalState; + set({ + terminalState: { ...current, pendingTerminalCwd: cwd }, + }); + }, + addTerminalTab: (name) => { const current = get().terminalState; const newTabId = `tab-${Date.now()}`; diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 49c1c4ad1..cacb396b5 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -912,6 +912,16 @@ export interface WorktreeAPI { error?: string; }>; + // Open a worktree directory in the terminal + openInTerminal: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + message: string; + terminalName?: string; + }; + error?: string; + }>; + // Get the default code editor name getDefaultEditor: () => Promise<{ success: boolean; @@ -1113,6 +1123,19 @@ export interface WorktreeAPI { payload: unknown; }) => void ) => () => void; + + // Discard all uncommitted changes in a worktree + discardChanges: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + discarded: boolean; + filesDiscarded: number; + filesRemaining: number; + branch: string; + message: string; + }; + error?: string; + }>; } export interface GitAPI { diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index b6daa0228..80c462f33 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -341,3 +341,113 @@ export async function openInFileManager(targetPath: string): Promise<{ editorNam await execFileAsync(fileManager.command, [targetPath]); return { editorName: fileManager.name }; } + +/** + * Open a terminal in the specified directory + * + * Handles cross-platform differences: + * - On macOS, uses Terminal.app via AppleScript with safe path handling + * - On Windows, uses Windows Terminal (wt) or falls back to cmd + * - On Linux, uses common terminal emulators with safe arguments + * + * Security: Uses safe argument passing to prevent command injection. + * + * @param targetPath - The directory path to open the terminal in + * @returns Promise that resolves with terminal info when launched, rejects on error + */ +export async function openInTerminal(targetPath: string): Promise<{ terminalName: string }> { + if (isMac) { + // Use AppleScript to safely open Terminal.app in the specified directory. + // Pass the path as an argument to osascript and use "quoted form of" + // to prevent command injection vulnerabilities. + const script = ` + on run argv + tell application "Terminal" + if not running then + activate + end if + do script "cd " & quoted form of (item 1 of argv) + activate + end tell + end run + `; + try { + await execFileAsync('osascript', ['-e', script, targetPath]); + return { terminalName: 'Terminal' }; + } catch { + // Fallback: 'open -a Terminal /path' is safer but opens a new window + await execFileAsync('open', ['-a', 'Terminal', targetPath]); + return { terminalName: 'Terminal' }; + } + } else if (isWindows) { + // Try Windows Terminal first, passing the directory as a safe argument + try { + return await new Promise((resolve, reject) => { + const child: ChildProcess = spawn('wt.exe', ['-d', targetPath], { + shell: false, // Disable shell for security + stdio: 'ignore', + detached: true, + }); + child.unref(); + + child.on('error', (err) => { + // ENOENT means wt.exe is not in PATH + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + reject(new Error('Windows Terminal not available')); + } else { + reject(err); + } + }); + + // Assume success if it doesn't error out immediately + setTimeout(() => resolve({ terminalName: 'Windows Terminal' }), 200); + }); + } catch { + // Fall back to cmd, using the cwd option for safety + return await new Promise((resolve, reject) => { + const child: ChildProcess = spawn('cmd.exe', ['/c', 'start', 'cmd.exe', '/k'], { + cwd: targetPath, + shell: false, + stdio: 'ignore', + detached: true, + }); + child.unref(); + + child.on('error', reject); + + setTimeout(() => resolve({ terminalName: 'Command Prompt' }), 200); + }); + } + } else { + // Linux: Try common terminal emulators in order, using safe arguments + const terminals = [ + { + name: 'GNOME Terminal', + command: 'gnome-terminal', + args: ['--working-directory', targetPath], + }, + { name: 'Konsole', command: 'konsole', args: ['--workdir', targetPath] }, + { + name: 'xfce4-terminal', + command: 'xfce4-terminal', + args: ['--working-directory', targetPath], + }, + // Use -cd for xterm to avoid shell command interpolation + { name: 'xterm', command: 'xterm', args: ['-cd', targetPath] }, + { + name: 'x-terminal-emulator', + command: 'x-terminal-emulator', + args: ['--working-directory', targetPath], + }, + ]; + + for (const terminal of terminals) { + if (await commandExists(terminal.command)) { + await execFileAsync(terminal.command, terminal.args); + return { terminalName: terminal.name }; + } + } + + throw new Error('No supported terminal emulator found'); + } +} diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index d51845f99..4883e554d 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -175,4 +175,5 @@ export { findEditorByCommand, openInEditor, openInFileManager, + openInTerminal, } from './editor.js';