diff --git a/server/projects.js b/server/projects.js index b7189d3..1f22895 100755 --- a/server/projects.js +++ b/server/projects.js @@ -2,6 +2,10 @@ import { promises as fs } from 'fs'; import fsSync from 'fs'; import path from 'path'; import readline from 'readline'; +import crypto from 'crypto'; +import sqlite3 from 'sqlite3'; +import { open } from 'sqlite'; +import os from 'os'; // Cache for extracted project directories const projectDirectoryCache = new Map(); @@ -207,6 +211,14 @@ async function getProjects() { console.warn(`Could not load sessions for project ${entry.name}:`, e.message); } + // Also fetch Cursor sessions for this project + try { + project.cursorSessions = await getCursorSessions(actualProjectDir); + } catch (e) { + console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); + project.cursorSessions = []; + } + projects.push(project); } } @@ -236,9 +248,17 @@ async function getProjects() { fullPath: actualProjectDir, isCustomName: !!projectConfig.displayName, isManuallyAdded: true, - sessions: [] + sessions: [], + cursorSessions: [] }; + // Try to fetch Cursor sessions for manual projects too + try { + project.cursorSessions = await getCursorSessions(actualProjectDir); + } catch (e) { + console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message); + } + projects.push(project); } } @@ -615,6 +635,117 @@ async function addProjectManually(projectPath, displayName = null) { }; } +// Fetch Cursor sessions for a given project path +async function getCursorSessions(projectPath) { + try { + // Calculate cwdID hash for the project path (Cursor uses MD5 hash) + const cwdId = crypto.createHash('md5').update(projectPath).digest('hex'); + const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId); + + // Check if the directory exists + try { + await fs.access(cursorChatsPath); + } catch (error) { + // No sessions for this project + return []; + } + + // List all session directories + const sessionDirs = await fs.readdir(cursorChatsPath); + const sessions = []; + + for (const sessionId of sessionDirs) { + const sessionPath = path.join(cursorChatsPath, sessionId); + const storeDbPath = path.join(sessionPath, 'store.db'); + + try { + // Check if store.db exists + await fs.access(storeDbPath); + + // Capture store.db mtime as a reliable fallback timestamp + let dbStatMtimeMs = null; + try { + const stat = await fs.stat(storeDbPath); + dbStatMtimeMs = stat.mtimeMs; + } catch (_) {} + + // Open SQLite database + const db = await open({ + filename: storeDbPath, + driver: sqlite3.Database, + mode: sqlite3.OPEN_READONLY + }); + + // Get metadata from meta table + const metaRows = await db.all(` + SELECT key, value FROM meta + `); + + // Parse metadata + let metadata = {}; + for (const row of metaRows) { + if (row.value) { + try { + // Try to decode as hex-encoded JSON + const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/); + if (hexMatch) { + const jsonStr = Buffer.from(row.value, 'hex').toString('utf8'); + metadata[row.key] = JSON.parse(jsonStr); + } else { + metadata[row.key] = row.value.toString(); + } + } catch (e) { + metadata[row.key] = row.value.toString(); + } + } + } + + // Get message count + const messageCountResult = await db.get(` + SELECT COUNT(*) as count FROM blobs + `); + + await db.close(); + + // Extract session info + const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session'; + + // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime + let createdAt = null; + if (metadata.createdAt) { + createdAt = new Date(metadata.createdAt).toISOString(); + } else if (dbStatMtimeMs) { + createdAt = new Date(dbStatMtimeMs).toISOString(); + } else { + createdAt = new Date().toISOString(); + } + + sessions.push({ + id: sessionId, + name: sessionName, + createdAt: createdAt, + lastActivity: createdAt, // For compatibility with Claude sessions + messageCount: messageCountResult.count || 0, + projectPath: projectPath + }); + + } catch (error) { + console.warn(`Could not read Cursor session ${sessionId}:`, error.message); + } + } + + // Sort sessions by creation time (newest first) + sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + // Return only the first 5 sessions for performance + return sessions.slice(0, 5); + + } catch (error) { + console.error('Error fetching Cursor sessions:', error); + return []; + } +} + export { getProjects, diff --git a/server/routes/cursor.js b/server/routes/cursor.js index 61d201e..5ff5da5 100644 --- a/server/routes/cursor.js +++ b/server/routes/cursor.js @@ -622,15 +622,18 @@ router.get('/sessions/:sessionId', async (req, res) => { } } - // Parse blob data to extract messages + // Parse blob data to extract messages - only include blobs with valid JSON const messages = []; for (const blob of blobs) { try { // Attempt direct JSON parse first const raw = blob.data.toString('utf8'); let parsed; + let isValidJson = false; + try { parsed = JSON.parse(raw); + isValidJson = true; } catch (_) { // If not JSON, try to extract JSON from within binary-looking string const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, ''); @@ -640,20 +643,28 @@ router.get('/sessions/:sessionId', async (req, res) => { const jsonStr = cleaned.slice(start, end + 1); try { parsed = JSON.parse(jsonStr); + isValidJson = true; } catch (_) { parsed = null; + isValidJson = false; } } } - if (parsed) { + + // Only include blobs that contain valid JSON data + if (isValidJson && parsed) { + // Filter out ONLY system messages at the server level + // Check both direct role and nested message.role + const role = parsed?.role || parsed?.message?.role; + if (role === 'system') { + continue; // Skip only system messages + } messages.push({ id: blob.id, content: parsed }); - } else { - // Fallback to cleaned text content - const text = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '').trim(); - messages.push({ id: blob.id, content: text }); } + // Skip non-JSON blobs (binary data) completely } catch (e) { - messages.push({ id: blob.id, content: blob.data.toString() }); + // Skip blobs that cause errors + console.log(`Skipping blob ${blob.id}: ${e.message}`); } } diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 85fcbc0..58f761d 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -27,6 +27,7 @@ import ClaudeStatus from './ClaudeStatus'; import { MicButton } from './MicButton.jsx'; import { api, authenticatedFetch } from '../utils/api'; + // Safe localStorage utility to handle quota exceeded errors const safeLocalStorage = { setItem: (key, value) => { @@ -180,7 +181,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile )} ) : ( - /* Claude/Error messages on the left */ + /* Claude/Error/Tool messages on the left */
{!isGrouped && (
@@ -188,6 +189,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
!
+ ) : message.type === 'tool' ? ( +
+ 🔧 +
) : (
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( @@ -198,7 +203,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)}
- {message.type === 'error' ? 'Error' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')} + {message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')}
)} @@ -330,11 +335,9 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile })()} {message.toolInput && message.toolName !== 'Edit' && (() => { // Debug log to see what we're dealing with - console.log('Tool display - name:', message.toolName, 'input type:', typeof message.toolInput); // Special handling for Write tool if (message.toolName === 'Write') { - console.log('Write tool detected, toolInput:', message.toolInput); try { let input; // Handle both JSON string and already parsed object @@ -344,7 +347,6 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile input = message.toolInput; } - console.log('Parsed Write input:', input); if (input.file_path && input.content !== undefined) { return ( @@ -1003,6 +1005,20 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
) : (
+ {/* Thinking accordion for reasoning */} + {message.reasoning && ( +
+ + 💭 Thinking... + +
+
+ {message.reasoning} +
+
+
+ )} + {message.type === 'assistant' ? (
start) { - const jsonStr = cleaned.slice(start, end + 1); - try { - const parsed = JSON.parse(jsonStr); - if (parsed && parsed.content && Array.isArray(parsed.content)) { - for (const part of parsed.content) { - if (part?.type === 'text' && part.text) { - extractedTexts.push(part.text); + // Handle different Cursor message formats + if (content?.role && content?.content) { + // Direct format: {"role":"user","content":[{"type":"text","text":"..."}]} + // Skip system messages + if (content.role === 'system') { + continue; + } + + // Handle tool messages + if (content.role === 'tool') { + // Tool result format - find the matching tool use message and update it + if (Array.isArray(content.content)) { + for (const item of content.content) { + if (item?.type === 'tool-result') { + // Map ApplyPatch to Edit for consistency + let toolName = item.toolName || 'Unknown Tool'; + if (toolName === 'ApplyPatch') { + toolName = 'Edit'; + } + const toolCallId = item.toolCallId || content.id; + const result = item.result || ''; + + // Store the tool result to be linked later + if (toolUseMap[toolCallId]) { + toolUseMap[toolCallId].toolResult = { + content: result, + isError: false + }; + } else { + // No matching tool use found, create a standalone result message + converted.push({ + type: 'assistant', + content: '', + timestamp: new Date(Date.now() + blobIdx), + blobId: blob.id, + isToolUse: true, + toolName: toolName, + toolId: toolCallId, + toolInput: null, + toolResult: { + content: result, + isError: false + } + }); } } } - } catch (_) { - // JSON parse failed; fall back to cleaned text + } + continue; // Don't add tool messages as regular messages + } else { + // User or assistant messages + role = content.role === 'user' ? 'user' : 'assistant'; + + if (Array.isArray(content.content)) { + // Extract text, reasoning, and tool calls from content array + const textParts = []; + + for (const part of content.content) { + if (part?.type === 'text' && part?.text) { + textParts.push(part.text); + } else if (part?.type === 'reasoning' && part?.text) { + // Handle reasoning type - will be displayed in a collapsible section + reasoningText = part.text; + } else if (part?.type === 'tool-call') { + // First, add any text/reasoning we've collected so far as a message + if (textParts.length > 0 || reasoningText) { + converted.push({ + type: role, + content: textParts.join('\n'), + reasoning: reasoningText, + timestamp: new Date(Date.now() + blobIdx), + blobId: blob.id + }); + textParts.length = 0; + reasoningText = null; + } + + // Tool call in assistant message - format like Claude Code + // Map ApplyPatch to Edit for consistency with Claude Code + let toolName = part.toolName || 'Unknown Tool'; + if (toolName === 'ApplyPatch') { + toolName = 'Edit'; + } + const toolId = part.toolCallId || `tool_${blobIdx}`; + + // Create a tool use message with Claude Code format + // Map Cursor args format to Claude Code format + let toolInput = part.args; + + if (toolName === 'Edit' && part.args) { + // ApplyPatch uses 'patch' format, convert to Edit format + if (part.args.patch) { + // Parse the patch to extract old and new content + const patchLines = part.args.patch.split('\n'); + let oldLines = []; + let newLines = []; + let inPatch = false; + + for (const line of patchLines) { + if (line.startsWith('@@')) { + inPatch = true; + } else if (inPatch) { + if (line.startsWith('-')) { + oldLines.push(line.substring(1)); + } else if (line.startsWith('+')) { + newLines.push(line.substring(1)); + } else if (line.startsWith(' ')) { + // Context line - add to both + oldLines.push(line.substring(1)); + newLines.push(line.substring(1)); + } + } + } + + const filePath = part.args.file_path; + const absolutePath = filePath && !filePath.startsWith('/') + ? `${projectPath}/${filePath}` + : filePath; + toolInput = { + file_path: absolutePath, + old_string: oldLines.join('\n') || part.args.patch, + new_string: newLines.join('\n') || part.args.patch + }; + } else { + // Direct edit format + toolInput = part.args; + } + } else if (toolName === 'Read' && part.args) { + // Map 'path' to 'file_path' + // Convert relative path to absolute if needed + const filePath = part.args.path || part.args.file_path; + const absolutePath = filePath && !filePath.startsWith('/') + ? `${projectPath}/${filePath}` + : filePath; + toolInput = { + file_path: absolutePath + }; + } else if (toolName === 'Write' && part.args) { + // Map fields for Write tool + const filePath = part.args.path || part.args.file_path; + const absolutePath = filePath && !filePath.startsWith('/') + ? `${projectPath}/${filePath}` + : filePath; + toolInput = { + file_path: absolutePath, + content: part.args.contents || part.args.content + }; + } + + const toolMessage = { + type: 'assistant', + content: '', + timestamp: new Date(Date.now() + blobIdx), + blobId: blob.id, + isToolUse: true, + toolName: toolName, + toolId: toolId, + toolInput: toolInput ? JSON.stringify(toolInput) : null, + toolResult: null // Will be filled when we get the tool result + }; + converted.push(toolMessage); + toolUseMap[toolId] = toolMessage; // Store for linking results + } else if (part?.type === 'tool_use') { + // Old format support + if (textParts.length > 0 || reasoningText) { + converted.push({ + type: role, + content: textParts.join('\n'), + reasoning: reasoningText, + timestamp: new Date(Date.now() + blobIdx), + blobId: blob.id + }); + textParts.length = 0; + reasoningText = null; + } + + const toolName = part.name || 'Unknown Tool'; + const toolId = part.id || `tool_${blobIdx}`; + + const toolMessage = { + type: 'assistant', + content: '', + timestamp: new Date(Date.now() + blobIdx), + blobId: blob.id, + isToolUse: true, + toolName: toolName, + toolId: toolId, + toolInput: part.input ? JSON.stringify(part.input) : null, + toolResult: null + }; + converted.push(toolMessage); + toolUseMap[toolId] = toolMessage; + } else if (typeof part === 'string') { + textParts.push(part); + } + } + + // Add any remaining text/reasoning + if (textParts.length > 0) { + text = textParts.join('\n'); + if (reasoningText && !text) { + // Just reasoning, no text + converted.push({ + type: role, + content: '', + reasoning: reasoningText, + timestamp: new Date(Date.now() + blobIdx), + blobId: blob.id + }); + text = ''; // Clear to avoid duplicate + } + } else { + text = ''; + } + } else if (typeof content.content === 'string') { + text = content.content; } } - if (extractedTexts.length > 0) { - extractedTexts.forEach(t => converted.push({ type: 'assistant', content: t, timestamp: new Date(now + (idx++)) })); + } else if (content?.message?.role && content?.message?.content) { + // Nested message format + if (content.message.role === 'system') { continue; } - // No JSON; use cleaned readable text if any - const readable = cleaned.trim(); - if (readable) { - // Heuristic: short single token like 'hey' → user, otherwise assistant - const isLikelyUser = /^[a-zA-Z0-9.,!?\s]{1,10}$/.test(readable) && readable.toLowerCase().includes('hey'); - role = isLikelyUser ? 'user' : 'assistant'; - text = readable; - } else { - text = ''; - } - } else if (content?.message?.role && content?.message?.content) { role = content.message.role === 'user' ? 'user' : 'assistant'; if (Array.isArray(content.message.content)) { text = content.message.content @@ -1322,26 +1528,38 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess .join('\n'); } else if (typeof content.message.content === 'string') { text = content.message.content; - } else { - text = JSON.stringify(content.message.content); } - } else if (content?.content) { - // Some Cursor blobs may have { content: string } - text = typeof content.content === 'string' ? content.content : JSON.stringify(content.content); - } else { - text = JSON.stringify(content); } } catch (e) { - text = String(content); + console.log('Error parsing blob content:', e); } if (text && text.trim()) { - converted.push({ + const message = { type: role, content: text, - timestamp: new Date(now + (idx++)) - }); + timestamp: new Date(Date.now() + blobIdx), + blobId: blob.id + }; + + // Add reasoning if we have it + if (reasoningText) { + message.reasoning = reasoningText; + } + + converted.push(message); } } + + // Sort messages by blob ID to maintain chronological order + converted.sort((a, b) => { + // First sort by blobId if available + if (a.blobId && b.blobId) { + return parseInt(a.blobId) - parseInt(b.blobId); + } + // Fallback to timestamp + return new Date(a.timestamp) - new Date(b.timestamp); + }); + return converted; } catch (e) { console.error('Error loading Cursor session messages:', e); @@ -1865,7 +2083,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } } // For other cursor-system messages, avoid dumping raw objects to chat - console.log('Cursor system message:', latestMessage.data); } catch (e) { console.warn('Error handling cursor-system message:', e); } @@ -1873,7 +2090,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess case 'cursor-user': // Handle Cursor user messages (usually echoes) - console.log('Cursor user message:', latestMessage.data); // Don't add user messages as they're already shown from input break; @@ -1994,7 +2210,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess case 'claude-status': // Handle Claude working status messages - console.log('🔔 Received claude-status message:', latestMessage); const statusData = latestMessage.data; if (statusData) { // Parse the status message to extract relevant information @@ -2025,7 +2240,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess statusInfo.can_interrupt = statusData.can_interrupt; } - console.log('📊 Setting claude status:', statusInfo); setClaudeStatus(statusInfo); setIsLoading(true); setCanAbortSession(statusInfo.can_interrupt); @@ -2622,12 +2836,118 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
) : chatMessages.length === 0 ? (
-
-

Start a conversation with Claude

-

- Ask questions about your code, request changes, or get help with development tasks -

-
+ {!selectedSession && ( +
+

Choose Your AI Assistant

+

+ Select a provider to start a new conversation +

+ +
+ {/* Claude Button */} + + + {/* Cursor Button */} + +
+ + {/* Model Selection for Cursor - Always reserve space to prevent jumping */} +
+ + +
+ +

+ {provider === 'claude' + ? 'Ready to use Claude AI. Start typing your message below.' + : provider === 'cursor' + ? `Ready to use Cursor with ${cursorModel}. Start typing your message below.` + : 'Select a provider above to begin' + } +

+
+ )} + {selectedSession && ( +
+

Continue your conversation

+

+ Ask questions about your code, request changes, or get help with development tasks +

+
+ )}
) : ( <> @@ -2734,6 +3054,56 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess )} {selectedSession.__provider}
+ ) : chatMessages.length === 0 ? ( +
+ + + {/* Always reserve space for model dropdown to prevent jumping */} +
+ +
+
) : ( <> - {provider === 'cursor' && ( + {/* Always reserve space for model dropdown to prevent jumping */} +
- )} +
)} diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx index 06d6217..b71d2ef 100644 --- a/src/components/GitPanel.jsx +++ b/src/components/GitPanel.jsx @@ -60,14 +60,12 @@ function GitPanel({ selectedProject, isMobile }) { const fetchGitStatus = async () => { if (!selectedProject) return; - console.log('Fetching git status for project:', selectedProject.name, 'path:', selectedProject.path); setIsLoading(true); try { const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`); const data = await response.json(); - console.log('Git status response:', data); if (data.error) { console.error('Git status error:', data.error); diff --git a/test.html b/test.html new file mode 100644 index 0000000..7f005fd --- /dev/null +++ b/test.html @@ -0,0 +1,2 @@ +world world 3 +world world 4