Files
claudecodeui/server/cursor-cli.js
Haileyesus 53af032d88 fix(cursor-chat): stabilize first-run UX and clean cursor message rendering
Fix three Cursor chat regressions observed on first message runs:

1. Full-screen UI refresh/flicker after first response.
2. Internal wrapper tags rendered in user messages.
3. Duplicate assistant message on response finalization.

Root causes
- Project refresh from chat completion used the global loading path,
  toggling app-level loading UI.
- Cursor history conversion rendered raw internal wrapper payloads
  as user-visible message text.
- Cursor response handling could finalize through overlapping stream/
  result paths, and stdout chunk parsing could split JSON lines.

Changes
- Added non-blocking project refresh plumbing for chat/session flows.
- Introduced fetch options in useProjectsState (showLoadingState flag).
- Added refreshProjectsSilently() to update metadata without global loading UI.
- Wired window.refreshProjects to refreshProjectsSilently in AppContent.

- Added Cursor user-message sanitization during history conversion.
- Added extractCursorUserQuery() to keep only <user_query> payload.
- Added sanitizeCursorUserMessageText() to strip internal wrappers:
  <user_info>, <agent_skills>, <available_skills>,
  <environment_context>, <environment_info>.
- Applied sanitization only for role === 'user' in
  convertCursorSessionMessages().

- Hardened Cursor backend stream parsing and finalization.
- Added line-buffered stdout parser for chunk-split JSON payloads.
- Flushed trailing unterminated stdout line on process close.
- Removed redundant content_block_stop emission on Cursor result.

- Added frontend duplicate guard in cursor-result handling.
- Skips a second assistant bubble when final result text equals
  already-rendered streamed content.

Code comments
- Added focused comments describing silent refresh behavior,
  tag stripping rationale, duplicate guard behavior, and line buffering.

Validation
- ESLint passes for touched files.
- Production build succeeds.

Files
- server/cursor-cli.js
- src/components/app/AppContent.tsx
- src/components/chat/hooks/useChatRealtimeHandlers.ts
- src/components/chat/utils/messageTransforms.ts
- src/hooks/useProjectsState.ts
2026-03-10 23:22:24 +03:00

350 lines
11 KiB
JavaScript

import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
// 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 } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
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 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) {
baseArgs.push('--resume=' + sessionId);
}
if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions)
baseArgs.push('-p', command);
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
if (!sessionId && model) {
baseArgs.push('--model', model);
}
// Request streaming JSON when we are providing a prompt
baseArgs.push('--output-format', 'stream-json');
}
// Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) {
baseArgs.push('-f');
console.log('Using -f flag (skip permissions)');
}
// Use cwd (actual project directory) instead of projectPath
const workingDir = cwd || projectPath || process.cwd();
// Store process reference for potential abort
const processKey = capturedSessionId || Date.now().toString();
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);
// Handle different message types
switch (response.type) {
case 'system':
if (response.subtype === 'init') {
// Capture session ID
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
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);
}
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send({
type: 'session-created',
sessionId: capturedSessionId,
model: response.model,
cwd: response.cwd
});
}
}
// Send system info to frontend
ws.send({
type: 'cursor-system',
data: response,
sessionId: capturedSessionId || sessionId || null
});
}
break;
case 'user':
// Forward user message
ws.send({
type: 'cursor-user',
data: response,
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;
// Send as Claude-compatible format for frontend
ws.send({
type: 'claude-response',
data: {
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: textContent
}
},
sessionId: capturedSessionId || sessionId || null
});
}
break;
case 'result':
// Session complete
console.log('Cursor session result:', response);
// 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,
data: response,
success: response.subtype === 'success'
});
break;
default:
// Forward any other message types
ws.send({
type: 'cursor-response',
data: response,
sessionId: capturedSessionId || sessionId || null
});
}
} catch (parseError) {
console.log('Non-JSON response:', line);
if (shouldSuppressForTrustRetry(line)) {
return;
}
// If not JSON, send as raw text
ws.send({
type: 'cursor-output',
data: line,
sessionId: capturedSessionId || sessionId || null
});
}
};
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('Cursor CLI stdout:', rawOutput);
// 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());
});
});
// 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}`);
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;
}
return false;
}
function isCursorSessionActive(sessionId) {
return activeCursorProcesses.has(sessionId);
}
function getActiveCursorSessions() {
return Array.from(activeCursorProcesses.keys());
}
export {
spawnCursor,
abortCursorSession,
isCursorSessionActive,
getActiveCursorSessions
};