diff --git a/server/cursor-cli.js b/server/cursor-cli.js index ffd20c3..f5fe7db 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -1,84 +1,124 @@ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; -import { promises as fs } from 'fs'; -import path from 'path'; -import os from 'os'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; let activeCursorProcesses = new Map(); // Track active processes by session ID +const WORKSPACE_TRUST_PATTERNS = [ + /workspace trust required/i, + /do you trust the contents of this directory/i, + /working with untrusted contents/i, + /pass --trust,\s*--yolo,\s*or -f/i +]; + +function isWorkspaceTrustPrompt(text = '') { + if (!text || typeof text !== 'string') { + return false; + } + + return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text)); +} + async function spawnCursor(command, options = {}, ws) { return new Promise(async (resolve, reject) => { - const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event - let messageBuffer = ''; // Buffer for accumulating assistant messages - + let hasRetriedWithTrust = false; + let settled = false; + // Use tools settings passed from frontend, or defaults const settings = toolsSettings || { allowedShellCommands: [], skipPermissions: false }; - + // Build Cursor CLI command - const args = []; - + const baseArgs = []; + // Build flags allowing both resume and prompt together (reply in existing session) // Treat presence of sessionId as intention to resume, regardless of resume flag if (sessionId) { - args.push('--resume=' + sessionId); + baseArgs.push('--resume=' + sessionId); } if (command && command.trim()) { // Provide a prompt (works for both new and resumed sessions) - args.push('-p', command); + baseArgs.push('-p', command); // Add model flag if specified (only meaningful for new sessions; harmless on resume) if (!sessionId && model) { - args.push('--model', model); + baseArgs.push('--model', model); } // Request streaming JSON when we are providing a prompt - args.push('--output-format', 'stream-json'); + baseArgs.push('--output-format', 'stream-json'); } - + // Add skip permissions flag if enabled if (skipPermissions || settings.skipPermissions) { - args.push('-f'); - console.log('⚠️ Using -f flag (skip permissions)'); + baseArgs.push('-f'); + console.log('Using -f flag (skip permissions)'); } - + // Use cwd (actual project directory) instead of projectPath const workingDir = cwd || projectPath || process.cwd(); - - console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' ')); - console.log('Working directory:', workingDir); - console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); - - const cursorProcess = spawnFunction('cursor-agent', args, { - cwd: workingDir, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env } // Inherit all environment variables - }); - + // Store process reference for potential abort const processKey = capturedSessionId || Date.now().toString(); - activeCursorProcesses.set(processKey, cursorProcess); - - // Handle stdout (streaming JSON responses) - cursorProcess.stdout.on('data', (data) => { - const rawOutput = data.toString(); - console.log('📤 Cursor CLI stdout:', rawOutput); - - const lines = rawOutput.split('\n').filter(line => line.trim()); - - for (const line of lines) { + + const settleOnce = (callback) => { + if (settled) { + return; + } + settled = true; + callback(); + }; + + const runCursorProcess = (args, runReason = 'initial') => { + const isTrustRetry = runReason === 'trust-retry'; + let runSawWorkspaceTrustPrompt = false; + let stdoutLineBuffer = ''; + + if (isTrustRetry) { + console.log('Retrying Cursor CLI with --trust after workspace trust prompt'); + } + + console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' ')); + console.log('Working directory:', workingDir); + console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); + + const cursorProcess = spawnFunction('cursor-agent', args, { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env } // Inherit all environment variables + }); + + activeCursorProcesses.set(processKey, cursorProcess); + + const shouldSuppressForTrustRetry = (text) => { + if (hasRetriedWithTrust || args.includes('--trust')) { + return false; + } + if (!isWorkspaceTrustPrompt(text)) { + return false; + } + + runSawWorkspaceTrustPrompt = true; + return true; + }; + + const processCursorOutputLine = (line) => { + if (!line || !line.trim()) { + return; + } + try { const response = JSON.parse(line); - console.log('📄 Parsed JSON response:', response); - + console.log('Parsed JSON response:', response); + // Handle different message types switch (response.type) { case 'system': @@ -86,14 +126,14 @@ async function spawnCursor(command, options = {}, ws) { // Capture session ID if (response.session_id && !capturedSessionId) { capturedSessionId = response.session_id; - console.log('📝 Captured session ID:', capturedSessionId); - + console.log('Captured session ID:', capturedSessionId); + // Update process key with captured session ID if (processKey !== capturedSessionId) { activeCursorProcesses.delete(processKey); activeCursorProcesses.set(capturedSessionId, cursorProcess); } - + // Set session ID on writer (for API endpoint compatibility) if (ws.setSessionId && typeof ws.setSessionId === 'function') { ws.setSessionId(capturedSessionId); @@ -110,7 +150,7 @@ async function spawnCursor(command, options = {}, ws) { }); } } - + // Send system info to frontend ws.send({ type: 'cursor-system', @@ -119,7 +159,7 @@ async function spawnCursor(command, options = {}, ws) { }); } break; - + case 'user': // Forward user message ws.send({ @@ -128,13 +168,12 @@ async function spawnCursor(command, options = {}, ws) { sessionId: capturedSessionId || sessionId || null }); break; - + case 'assistant': // Accumulate assistant message chunks if (response.message && response.message.content && response.message.content.length > 0) { const textContent = response.message.content[0].text; - messageBuffer += textContent; - + // Send as Claude-compatible format for frontend ws.send({ type: 'claude-response', @@ -149,23 +188,14 @@ async function spawnCursor(command, options = {}, ws) { }); } break; - + case 'result': // Session complete console.log('Cursor session result:', response); - - // Send final message if we have buffered content - if (messageBuffer) { - ws.send({ - type: 'claude-response', - data: { - type: 'content_block_stop' - }, - sessionId: capturedSessionId || sessionId || null - }); - } - - // Send completion event + + // Do not emit an extra content_block_stop here. + // The UI already finalizes the streaming message in cursor-result handling, + // and emitting both can produce duplicate assistant messages. ws.send({ type: 'cursor-result', sessionId: capturedSessionId || sessionId, @@ -173,7 +203,7 @@ async function spawnCursor(command, options = {}, ws) { success: response.subtype === 'success' }); break; - + default: // Forward any other message types ws.send({ @@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) { }); } } catch (parseError) { - console.log('📄 Non-JSON response:', line); + console.log('Non-JSON response:', line); + + if (shouldSuppressForTrustRetry(line)) { + return; + } + // If not JSON, send as raw text ws.send({ type: 'cursor-output', @@ -191,67 +226,106 @@ async function spawnCursor(command, options = {}, ws) { sessionId: capturedSessionId || sessionId || null }); } - } - }); - - // Handle stderr - cursorProcess.stderr.on('data', (data) => { - console.error('Cursor CLI stderr:', data.toString()); - ws.send({ - type: 'cursor-error', - error: data.toString(), - sessionId: capturedSessionId || sessionId || null - }); - }); - - // Handle process completion - cursorProcess.on('close', async (code) => { - console.log(`Cursor CLI process exited with code ${code}`); - - // Clean up process reference - const finalSessionId = capturedSessionId || sessionId || processKey; - activeCursorProcesses.delete(finalSessionId); + }; - ws.send({ - type: 'claude-complete', - sessionId: finalSessionId, - exitCode: code, - isNewSession: !sessionId && !!command // Flag to indicate this was a new session - }); - - if (code === 0) { - resolve(); - } else { - reject(new Error(`Cursor CLI exited with code ${code}`)); - } - }); - - // Handle process errors - cursorProcess.on('error', (error) => { - console.error('Cursor CLI process error:', error); - - // Clean up process reference on error - const finalSessionId = capturedSessionId || sessionId || processKey; - activeCursorProcesses.delete(finalSessionId); + // Handle stdout (streaming JSON responses) + cursorProcess.stdout.on('data', (data) => { + const rawOutput = data.toString(); + console.log('Cursor CLI stdout:', rawOutput); - ws.send({ - type: 'cursor-error', - error: error.message, - sessionId: capturedSessionId || sessionId || null + // Stream chunks can split JSON objects across packets; keep trailing partial line. + stdoutLineBuffer += rawOutput; + const completeLines = stdoutLineBuffer.split(/\r?\n/); + stdoutLineBuffer = completeLines.pop() || ''; + + completeLines.forEach((line) => { + processCursorOutputLine(line.trim()); + }); }); - reject(error); - }); - - // Close stdin since Cursor doesn't need interactive input - cursorProcess.stdin.end(); + // Handle stderr + cursorProcess.stderr.on('data', (data) => { + const stderrText = data.toString(); + console.error('Cursor CLI stderr:', stderrText); + + if (shouldSuppressForTrustRetry(stderrText)) { + return; + } + + ws.send({ + type: 'cursor-error', + error: stderrText, + sessionId: capturedSessionId || sessionId || null + }); + }); + + // Handle process completion + cursorProcess.on('close', async (code) => { + console.log(`Cursor CLI process exited with code ${code}`); + + const finalSessionId = capturedSessionId || sessionId || processKey; + activeCursorProcesses.delete(finalSessionId); + + // Flush any final unterminated stdout line before completion handling. + if (stdoutLineBuffer.trim()) { + processCursorOutputLine(stdoutLineBuffer.trim()); + stdoutLineBuffer = ''; + } + + if ( + runSawWorkspaceTrustPrompt && + code !== 0 && + !hasRetriedWithTrust && + !args.includes('--trust') + ) { + hasRetriedWithTrust = true; + runCursorProcess([...args, '--trust'], 'trust-retry'); + return; + } + + ws.send({ + type: 'claude-complete', + sessionId: finalSessionId, + exitCode: code, + isNewSession: !sessionId && !!command // Flag to indicate this was a new session + }); + + if (code === 0) { + settleOnce(() => resolve()); + } else { + settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`))); + } + }); + + // Handle process errors + cursorProcess.on('error', (error) => { + console.error('Cursor CLI process error:', error); + + // Clean up process reference on error + const finalSessionId = capturedSessionId || sessionId || processKey; + activeCursorProcesses.delete(finalSessionId); + + ws.send({ + type: 'cursor-error', + error: error.message, + sessionId: capturedSessionId || sessionId || null + }); + + settleOnce(() => reject(error)); + }); + + // Close stdin since Cursor doesn't need interactive input + cursorProcess.stdin.end(); + }; + + runCursorProcess(baseArgs, 'initial'); }); } function abortCursorSession(sessionId) { const process = activeCursorProcesses.get(sessionId); if (process) { - console.log(`🛑 Aborting Cursor session: ${sessionId}`); + console.log(`Aborting Cursor session: ${sessionId}`); process.kill('SIGTERM'); activeCursorProcesses.delete(sessionId); return true; diff --git a/server/index.js b/server/index.js index 521197c..169d042 100755 --- a/server/index.js +++ b/server/index.js @@ -1730,8 +1730,14 @@ function handleShellConnection(ws) { shellCommand = 'cursor-agent'; } } else if (provider === 'codex') { + // Use codex command; attempt to resume and fall back to a new session when the resume fails. if (hasSession && sessionId) { - shellCommand = `codex resume "${sessionId}" || codex`; + if (os.platform() === 'win32') { + // PowerShell syntax for fallback + shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; + } else { + shellCommand = `codex resume "${sessionId}" || codex`; + } } else { shellCommand = 'codex'; } @@ -1765,7 +1771,11 @@ function handleShellConnection(ws) { // Claude (default provider) const command = initialCommand || 'claude'; if (hasSession && sessionId) { - shellCommand = `claude --resume "${sessionId}" || claude`; + if (os.platform() === 'win32') { + shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; + } else { + shellCommand = `claude --resume "${sessionId}" || claude`; + } } else { shellCommand = command; } diff --git a/server/routes/git.js b/server/routes/git.js index 24a8b82..701c3be 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -7,6 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; const router = express.Router(); +const COMMIT_DIFF_CHARACTER_LIMIT = 500_000; function spawnAsync(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -107,8 +108,7 @@ async function getActualProjectPath(projectName) { projectPath = await extractProjectDirectory(projectName); } catch (error) { console.error(`Error extracting project directory for ${projectName}:`, error); - // Fallback to the old method - projectPath = projectName.replace(/-/g, '/'); + throw new Error(`Unable to resolve project path for "${projectName}"`); } return validateProjectPath(projectPath); } @@ -166,6 +166,127 @@ async function validateGitRepository(projectPath) { } } +function getGitErrorDetails(error) { + return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`; +} + +function isMissingHeadRevisionError(error) { + const errorDetails = getGitErrorDetails(error).toLowerCase(); + return errorDetails.includes('unknown revision') + || errorDetails.includes('ambiguous argument') + || errorDetails.includes('needed a single revision') + || errorDetails.includes('bad revision'); +} + +async function getCurrentBranchName(projectPath) { + try { + // symbolic-ref works even when the repository has no commits. + const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath }); + const branchName = stdout.trim(); + if (branchName) { + return branchName; + } + } catch (error) { + // Fall back to rev-parse for detached HEAD and older git edge cases. + } + + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); + return stdout.trim(); +} + +async function repositoryHasCommits(projectPath) { + try { + await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath }); + return true; + } catch (error) { + if (isMissingHeadRevisionError(error)) { + return false; + } + throw error; + } +} + +async function getRepositoryRootPath(projectPath) { + const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath }); + return stdout.trim(); +} + +function normalizeRepositoryRelativeFilePath(filePath) { + return String(filePath) + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/^\/+/, '') + .trim(); +} + +function parseStatusFilePaths(statusOutput) { + return statusOutput + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim()) + .map((line) => { + const statusPath = line.substring(3); + const renamedFilePath = statusPath.split(' -> ')[1]; + return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath); + }) + .filter(Boolean); +} + +function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) { + const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath); + const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath)); + const candidates = [normalizedFilePath]; + + if ( + projectRelativePath + && projectRelativePath !== '.' + && !normalizedFilePath.startsWith(`${projectRelativePath}/`) + ) { + candidates.push(`${projectRelativePath}/${normalizedFilePath}`); + } + + return Array.from(new Set(candidates.filter(Boolean))); +} + +async function resolveRepositoryFilePath(projectPath, filePath) { + validateFilePath(filePath); + + const repositoryRootPath = await getRepositoryRootPath(projectPath); + const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath); + + for (const candidateFilePath of candidateFilePaths) { + const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath }); + if (stdout.trim()) { + return { + repositoryRootPath, + repositoryRelativeFilePath: candidateFilePath, + }; + } + } + + // If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files. + const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath); + if (!normalizedFilePath.includes('/')) { + const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath }); + const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput); + const suffixMatches = changedFilePaths.filter( + (changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`), + ); + + if (suffixMatches.length === 1) { + return { + repositoryRootPath, + repositoryRelativeFilePath: suffixMatches[0], + }; + } + } + + return { + repositoryRootPath, + repositoryRelativeFilePath: candidateFilePaths[0], + }; +} + // Get git status for a project router.get('/status', async (req, res) => { const { project } = req.query; @@ -180,21 +301,8 @@ router.get('/status', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); - // Get current branch - handle case where there are no commits yet - let branch = 'main'; - let hasCommits = true; - try { - const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); - branch = branchOutput.trim(); - } catch (error) { - // No HEAD exists - repository has no commits yet - if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) { - hasCommits = false; - branch = 'main'; - } else { - throw error; - } - } + const branch = await getCurrentBranchName(projectPath); + const hasCommits = await repositoryHasCommits(projectPath); // Get git status const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath }); @@ -255,47 +363,65 @@ router.get('/diff', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); - - // Validate file path - validateFilePath(file, projectPath); + + const { + repositoryRootPath, + repositoryRelativeFilePath, + } = await resolveRepositoryFilePath(projectPath, file); // Check if file is untracked or deleted - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync( + 'git', + ['status', '--porcelain', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); let diff; if (isUntracked) { // For untracked files, show the entire file content as additions - const filePath = path.join(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { // For directories, show a simple message - diff = `Directory: ${file}\n(Cannot show diff for directories)`; + diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`; } else { const fileContent = await fs.readFile(filePath, 'utf-8'); const lines = fileContent.split('\n'); - diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` + + diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` + lines.map(line => `+${line}`).join('\n'); } } else if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions - const { stdout: fileContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); + const { stdout: fileContent } = await spawnAsync( + 'git', + ['show', `HEAD:${repositoryRelativeFilePath}`], + { cwd: repositoryRootPath }, + ); const lines = fileContent.split('\n'); - diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + + diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + lines.map(line => `-${line}`).join('\n'); } else { // Get diff for tracked files // First check for unstaged changes (working tree vs index) - const { stdout: unstagedDiff } = await spawnAsync('git', ['diff', '--', file], { cwd: projectPath }); + const { stdout: unstagedDiff } = await spawnAsync( + 'git', + ['diff', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); if (unstagedDiff) { // Show unstaged changes if they exist diff = stripDiffHeaders(unstagedDiff); } else { // If no unstaged changes, check for staged changes (index vs HEAD) - const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath }); + const { stdout: stagedDiff } = await spawnAsync( + 'git', + ['diff', '--cached', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); diff = stripDiffHeaders(stagedDiff) || ''; } } @@ -321,11 +447,17 @@ router.get('/file-with-diff', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); - // Validate file path - validateFilePath(file, projectPath); + const { + repositoryRootPath, + repositoryRelativeFilePath, + } = await resolveRepositoryFilePath(projectPath, file); // Check file status - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync( + 'git', + ['status', '--porcelain', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -334,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => { if (isDeleted) { // For deleted files, get content from HEAD - const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); + const { stdout: headContent } = await spawnAsync( + 'git', + ['show', `HEAD:${repositoryRelativeFilePath}`], + { cwd: repositoryRootPath }, + ); oldContent = headContent; currentContent = headContent; // Show the deleted content in editor } else { // Get current file content - const filePath = path.join(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -352,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => { if (!isUntracked) { // Get the old content from HEAD for tracked files try { - const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); + const { stdout: headContent } = await spawnAsync( + 'git', + ['show', `HEAD:${repositoryRelativeFilePath}`], + { cwd: repositoryRootPath }, + ); oldContent = headContent; } catch (error) { // File might be newly added to git (staged but not committed) @@ -430,15 +570,16 @@ router.post('/commit', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); + const repositoryRootPath = await getRepositoryRootPath(projectPath); // Stage selected files for (const file of files) { - validateFilePath(file, projectPath); - await spawnAsync('git', ['add', file], { cwd: projectPath }); + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); + await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } // Commit with message - const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath }); res.json({ success: true, output: stdout }); } catch (error) { @@ -447,6 +588,53 @@ router.post('/commit', async (req, res) => { } }); +// Revert latest local commit (keeps changes staged) +router.post('/revert-local-commit', async (req, res) => { + const { project } = req.body; + + if (!project) { + return res.status(400).json({ error: 'Project name is required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + + try { + await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath }); + } catch (error) { + return res.status(400).json({ + error: 'No local commit to revert', + details: 'This repository has no commit yet.', + }); + } + + try { + // Soft reset rewinds one commit while preserving all file changes in the index. + await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath }); + } catch (error) { + const errorDetails = `${error.stderr || ''} ${error.message || ''}`; + const isInitialCommit = errorDetails.includes('HEAD~1') && + (errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument')); + + if (!isInitialCommit) { + throw error; + } + + // Initial commit has no parent; deleting HEAD uncommits it and keeps files staged. + await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath }); + } + + res.json({ + success: true, + output: 'Latest local commit reverted successfully. Changes were kept staged.', + }); + } catch (error) { + console.error('Git revert local commit error:', error); + res.status(500).json({ error: error.message }); + } +}); + // Get list of branches router.get('/branches', async (req, res) => { const { project } = req.query; @@ -609,8 +797,13 @@ router.get('/commit-diff', async (req, res) => { 'git', ['show', commit], { cwd: projectPath } ); - - res.json({ diff: stdout }); + + const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT; + const diff = isTruncated + ? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...` + : stdout; + + res.json({ diff, isTruncated }); } catch (error) { console.error('Git commit diff error:', error); res.json({ error: error.message }); @@ -632,18 +825,20 @@ router.post('/generate-commit-message', async (req, res) => { try { const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + const repositoryRootPath = await getRepositoryRootPath(projectPath); // Get diff for selected files let diffContext = ''; for (const file of files) { try { - validateFilePath(file, projectPath); + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); const { stdout } = await spawnAsync( - 'git', ['diff', 'HEAD', '--', file], - { cwd: projectPath } + 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath } ); if (stdout) { - diffContext += `\n--- ${file} ---\n${stdout}`; + diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`; } } catch (error) { console.error(`Error getting diff for ${file}:`, error); @@ -655,14 +850,15 @@ router.post('/generate-commit-message', async (req, res) => { // Try to get content of untracked files for (const file of files) { try { - const filePath = path.join(projectPath, file); + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (!stats.isDirectory()) { const content = await fs.readFile(filePath, 'utf-8'); - diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`; + diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`; } else { - diffContext += `\n--- ${file} (new directory) ---\n`; + diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`; } } catch (error) { console.error(`Error reading file ${file}:`, error); @@ -831,9 +1027,30 @@ router.get('/remote-status', async (req, res) => { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); - // Get current branch - const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); - const branch = currentBranch.trim(); + const branch = await getCurrentBranchName(projectPath); + const hasCommits = await repositoryHasCommits(projectPath); + + const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath }); + const remotes = remoteOutput.trim().split('\n').filter(r => r.trim()); + const hasRemote = remotes.length > 0; + const fallbackRemoteName = hasRemote + ? (remotes.includes('origin') ? 'origin' : remotes[0]) + : null; + + // Repositories initialized with `git init` can have a branch but no commits. + // Return a non-error state so the UI can show the initial-commit workflow. + if (!hasCommits) { + return res.json({ + hasRemote, + hasUpstream: false, + branch, + remoteName: fallbackRemoteName, + ahead: 0, + behind: 0, + isUpToDate: false, + message: 'Repository has no commits yet' + }); + } // Check if there's a remote tracking branch (smart detection) let trackingBranch; @@ -843,25 +1060,11 @@ router.get('/remote-status', async (req, res) => { trackingBranch = stdout.trim(); remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") } catch (error) { - // No upstream branch configured - but check if we have remotes - let hasRemote = false; - let remoteName = null; - try { - const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath }); - const remotes = stdout.trim().split('\n').filter(r => r.trim()); - if (remotes.length > 0) { - hasRemote = true; - remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; - } - } catch (remoteError) { - // No remotes configured - } - return res.json({ hasRemote, hasUpstream: false, branch, - remoteName, + remoteName: fallbackRemoteName, message: 'No remote tracking branch configured' }); } @@ -903,8 +1106,7 @@ router.post('/fetch', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); - const branch = currentBranch.trim(); + const branch = await getCurrentBranchName(projectPath); let remoteName = 'origin'; // fallback try { @@ -945,8 +1147,7 @@ router.post('/pull', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); - const branch = currentBranch.trim(); + const branch = await getCurrentBranchName(projectPath); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback @@ -1014,8 +1215,7 @@ router.post('/push', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); - const branch = currentBranch.trim(); + const branch = await getCurrentBranchName(projectPath); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback @@ -1089,8 +1289,7 @@ router.post('/publish', async (req, res) => { validateBranchName(branch); // Get current branch to verify it matches the requested branch - const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); - const currentBranchName = currentBranch.trim(); + const currentBranchName = await getCurrentBranchName(projectPath); if (currentBranchName !== branch) { return res.status(400).json({ @@ -1164,12 +1363,17 @@ router.post('/discard', async (req, res) => { try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); - - // Validate file path - validateFilePath(file, projectPath); + const { + repositoryRootPath, + repositoryRelativeFilePath, + } = await resolveRepositoryFilePath(projectPath, file); // Check file status to determine correct discard command - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync( + 'git', + ['status', '--porcelain', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); @@ -1179,7 +1383,7 @@ router.post('/discard', async (req, res) => { if (status === '??') { // Untracked file or directory - delete it - const filePath = path.join(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -1189,13 +1393,13 @@ router.post('/discard', async (req, res) => { } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD - await spawnAsync('git', ['restore', file], { cwd: projectPath }); + await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } else if (status.includes('A')) { // Added file - unstage it - await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath }); + await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } - res.json({ success: true, message: `Changes discarded for ${file}` }); + res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` }); } catch (error) { console.error('Git discard error:', error); res.status(500).json({ error: error.message }); @@ -1213,12 +1417,17 @@ router.post('/delete-untracked', async (req, res) => { try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); - - // Validate file path - validateFilePath(file, projectPath); + const { + repositoryRootPath, + repositoryRelativeFilePath, + } = await resolveRepositoryFilePath(projectPath, file); // Check if file is actually untracked - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync( + 'git', + ['status', '--porcelain', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' }); @@ -1231,16 +1440,16 @@ router.post('/delete-untracked', async (req, res) => { } // Delete the untracked file or directory - const filePath = path.join(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { // Use rm with recursive option for directories await fs.rm(filePath, { recursive: true, force: true }); - res.json({ success: true, message: `Untracked directory ${file} deleted successfully` }); + res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` }); } else { await fs.unlink(filePath); - res.json({ success: true, message: `Untracked file ${file} deleted successfully` }); + res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` }); } } catch (error) { console.error('Git delete untracked error:', error); diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index f2806c6..5649c0c 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -40,7 +40,7 @@ export default function AppContent() { setIsInputFocused, setShowSettings, openSettings, - fetchProjects, + refreshProjectsSilently, sidebarSharedProps, } = useProjectsState({ sessionId, @@ -51,14 +51,16 @@ export default function AppContent() { }); useEffect(() => { - window.refreshProjects = fetchProjects; + // Expose a non-blocking refresh for chat/session flows. + // Full loading refreshes are still available through direct fetchProjects calls. + window.refreshProjects = refreshProjectsSilently; return () => { - if (window.refreshProjects === fetchProjects) { + if (window.refreshProjects === refreshProjectsSilently) { delete window.refreshProjects; } }; - }, [fetchProjects]); + }, [refreshProjectsSilently]); useEffect(() => { window.openSettings = openSettings; diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 73c305d..d543359 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -692,14 +692,28 @@ export function useChatRealtimeHandlers({ const updated = [...previous]; const lastIndex = updated.length - 1; const last = updated[lastIndex]; + const normalizedTextResult = textResult.trim(); + if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { const finalContent = - textResult && textResult.trim() + normalizedTextResult ? textResult : `${last.content || ''}${pendingChunk || ''}`; // Clone the message instead of mutating in place so React can reliably detect state updates. updated[lastIndex] = { ...last, content: finalContent, isStreaming: false }; - } else if (textResult && textResult.trim()) { + } else if (normalizedTextResult) { + const lastAssistantText = + last && last.type === 'assistant' && !last.isToolUse + ? String(last.content || '').trim() + : ''; + + // Cursor can emit the same final text through both streaming and result payloads. + // Skip adding a second assistant bubble when the final text is unchanged. + const isDuplicateFinalText = lastAssistantText === normalizedTextResult; + if (isDuplicateFinalText) { + return updated; + } + updated.push({ type: resultData.is_error ? 'error' : 'assistant', content: textResult, diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts index b4a5250..cf38e44 100644 --- a/src/components/chat/utils/messageTransforms.ts +++ b/src/components/chat/utils/messageTransforms.ts @@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => { } }; +const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [ + /[\s\S]*?<\/user_info>/gi, + /[\s\S]*?<\/agent_skills>/gi, + /[\s\S]*?<\/available_skills>/gi, + /[\s\S]*?<\/environment_context>/gi, + /[\s\S]*?<\/environment_info>/gi, +]; + +const extractCursorUserQuery = (rawText: string): string => { + const userQueryMatches = [...rawText.matchAll(/([\s\S]*?)<\/user_query>/gi)]; + if (userQueryMatches.length === 0) { + return ''; + } + + return userQueryMatches + .map((match) => (match[1] || '').trim()) + .filter(Boolean) + .join('\n') + .trim(); +}; + +const sanitizeCursorUserMessageText = (rawText: string): string => { + const decodedText = decodeHtmlEntities(rawText || '').trim(); + if (!decodedText) { + return ''; + } + + // Cursor stores user-visible text inside and prepends hidden context blocks + // (, , etc). We only render the actual query in chat history. + const extractedUserQuery = extractCursorUserQuery(decodedText); + if (extractedUserQuery) { + return extractedUserQuery; + } + + let sanitizedText = decodedText; + CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => { + sanitizedText = sanitizedText.replace(pattern, ''); + }); + + return sanitizedText.trim(); +}; + const toAbsolutePath = (projectPath: string, filePath?: string) => { if (!filePath) { return filePath; @@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s console.log('Error parsing blob content:', error); } + if (role === 'user') { + text = sanitizeCursorUserMessageText(text); + } + if (text && text.trim()) { const message: ChatMessage = { type: role, diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts index 5defa41..420f955 100644 --- a/src/components/git-panel/constants/constants.ts +++ b/src/components/git-panel/constants/constants.ts @@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record = { pull: 'Confirm Pull', push: 'Confirm Push', publish: 'Publish Branch', + revertLocalCommit: 'Revert Local Commit', }; export const CONFIRMATION_ACTION_LABELS: Record = { @@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record = { pull: 'Pull', push: 'Push', publish: 'Publish', + revertLocalCommit: 'Revert Commit', }; export const CONFIRMATION_BUTTON_CLASSES: Record = { @@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record = { pull: 'bg-green-600 hover:bg-green-700', push: 'bg-orange-600 hover:bg-orange-700', publish: 'bg-purple-600 hover:bg-purple-700', + revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700', }; export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record = { pull: 'text-yellow-600 dark:text-yellow-400', push: 'text-yellow-600 dark:text-yellow-400', publish: 'text-yellow-600 dark:text-yellow-400', + revertLocalCommit: 'text-yellow-600 dark:text-yellow-400', }; diff --git a/src/components/git-panel/hooks/useRevertLocalCommit.ts b/src/components/git-panel/hooks/useRevertLocalCommit.ts new file mode 100644 index 0000000..3c3ea91 --- /dev/null +++ b/src/components/git-panel/hooks/useRevertLocalCommit.ts @@ -0,0 +1,48 @@ +import { useCallback, useState } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; +import type { GitOperationResponse } from '../types/types'; + +type UseRevertLocalCommitOptions = { + projectName: string | null; + onSuccess?: () => void; +}; + +async function readJson(response: Response): Promise { + return (await response.json()) as T; +} + +export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) { + const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false); + + const revertLatestLocalCommit = useCallback(async () => { + if (!projectName) { + return; + } + + setIsRevertingLocalCommit(true); + try { + const response = await authenticatedFetch('/api/git/revert-local-commit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project: projectName }), + }); + const data = await readJson(response); + + if (!data.success) { + console.error('Revert local commit failed:', data.error || data.details || 'Unknown error'); + return; + } + + onSuccess?.(); + } catch (error) { + console.error('Error reverting local commit:', error); + } finally { + setIsRevertingLocalCommit(false); + } + }, [onSuccess, projectName]); + + return { + isRevertingLocalCommit, + revertLatestLocalCommit, + }; +} diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index 452f779..c8188e9 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -3,7 +3,7 @@ import type { Project } from '../../../types/app'; export type GitPanelView = 'changes' | 'history'; export type FileStatusCode = 'M' | 'A' | 'D' | 'U'; export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked'; -export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish'; +export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit'; export type FileDiffInfo = { old_string: string; diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index c08c284..d670f65 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { useGitPanelController } from '../hooks/useGitPanelController'; +import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit'; import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types'; import ChangesView from '../view/changes/ChangesView'; import HistoryView from '../view/history/HistoryView'; @@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onFileOpen, }); + const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({ + projectName: selectedProject?.name ?? null, + onSuccess: refreshAll, + }); + const executeConfirmedAction = useCallback(async () => { if (!confirmAction) { return; @@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isPulling={isPulling} isPushing={isPushing} isPublishing={isPublishing} + isRevertingLocalCommit={isRevertingLocalCommit} onRefresh={refreshAll} + onRevertLocalCommit={revertLatestLocalCommit} onSwitchBranch={switchBranch} onCreateBranch={createBranch} onFetch={handleFetch} diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx index 78b8be0..2710d4b 100644 --- a/src/components/git-panel/view/GitPanelHeader.tsx +++ b/src/components/git-panel/view/GitPanelHeader.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react'; +import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import type { ConfirmationRequest, GitRemoteStatus } from '../types/types'; import NewBranchModal from './modals/NewBranchModal'; @@ -14,7 +14,9 @@ type GitPanelHeaderProps = { isPulling: boolean; isPushing: boolean; isPublishing: boolean; + isRevertingLocalCommit: boolean; onRefresh: () => void; + onRevertLocalCommit: () => Promise; onSwitchBranch: (branchName: string) => Promise; onCreateBranch: (branchName: string) => Promise; onFetch: () => Promise; @@ -35,7 +37,9 @@ export default function GitPanelHeader({ isPulling, isPushing, isPublishing, + isRevertingLocalCommit, onRefresh, + onRevertLocalCommit, onSwitchBranch, onCreateBranch, onFetch, @@ -88,6 +92,14 @@ export default function GitPanelHeader({ }); }; + const requestRevertLocalCommitConfirmation = () => { + onRequestConfirmation({ + type: 'revertLocalCommit', + message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.', + onConfirm: onRevertLocalCommit, + }); + }; + const handleSwitchBranch = async (branchName: string) => { try { const success = await onSwitchBranch(branchName); @@ -240,6 +252,17 @@ export default function GitPanelHeader({ )} + +