feat: Enhance session management and tool settings for Claude and Cursor

- Updated ClaudeStatus component to accept a provider prop for better flexibility.
- Added CursorLogo component for displaying cursor sessions.
- Modified MainContent to conditionally display session names based on provider.
- Updated Shell component to show session names and summaries based on provider.
- Enhanced Sidebar to handle both Claude and Cursor sessions, including sorting and displaying session icons.
- Introduced new ToolsSettings functionality to manage tools for both Claude and Cursor, including allowed and disallowed commands.
- Implemented fetching and saving of Cursor-specific settings and commands.
- Added UI elements for managing Cursor tools, including permission settings and command lists.
This commit is contained in:
simos
2025-08-12 10:49:04 +03:00
parent ece52adac2
commit cf6f0e7321
15 changed files with 3146 additions and 94 deletions

View File

@@ -21,10 +21,11 @@ import ReactMarkdown from 'react-markdown';
import { useDropzone } from 'react-dropzone';
import TodoList from './TodoList';
import ClaudeLogo from './ClaudeLogo.jsx';
import CursorLogo from './CursorLogo.jsx';
import ClaudeStatus from './ClaudeStatus';
import { MicButton } from './MicButton.jsx';
import { api } from '../utils/api';
import { api, authenticatedFetch } from '../utils/api';
// Safe localStorage utility to handle quota exceeded errors
const safeLocalStorage = {
@@ -189,11 +190,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
<ClaudeLogo className="w-full h-full" />
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
<CursorLogo className="w-full h-full" />
) : (
<ClaudeLogo className="w-full h-full" />
)}
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? 'Error' : 'Claude'}
{message.type === 'error' ? 'Error' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')}
</div>
</div>
)}
@@ -1143,6 +1148,48 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [slashPosition, setSlashPosition] = useState(-1);
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
const [claudeStatus, setClaudeStatus] = useState(null);
const [provider, setProvider] = useState(() => {
return localStorage.getItem('selected-provider') || 'claude';
});
const [cursorModel, setCursorModel] = useState(() => {
return localStorage.getItem('cursor-model') || 'gpt-5';
});
// When selecting a session from Sidebar, auto-switch provider to match session's origin
useEffect(() => {
if (selectedSession && selectedSession.__provider && selectedSession.__provider !== provider) {
setProvider(selectedSession.__provider);
localStorage.setItem('selected-provider', selectedSession.__provider);
}
}, [selectedSession]);
// Load Cursor default model from config
useEffect(() => {
if (provider === 'cursor') {
fetch('/api/cursor/config', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth-token')}`
}
})
.then(res => res.json())
.then(data => {
if (data.success && data.config?.model?.modelId) {
// Map Cursor model IDs to our simplified names
const modelMap = {
'gpt-5': 'gpt-5',
'claude-4-sonnet': 'sonnet-4',
'sonnet-4': 'sonnet-4',
'claude-4-opus': 'opus-4.1',
'opus-4.1': 'opus-4.1'
};
const mappedModel = modelMap[data.config.model.modelId] || data.config.model.modelId;
if (!localStorage.getItem('cursor-model')) {
setCursorModel(mappedModel);
}
}
})
.catch(err => console.error('Error loading Cursor config:', err));
}
}, [provider]);
// Memoized diff calculation to prevent recalculating on every render
@@ -1184,6 +1231,97 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, []);
// Load Cursor session messages from SQLite via backend
const loadCursorSessionMessages = useCallback(async (projectPath, sessionId) => {
if (!projectPath || !sessionId) return [];
setIsLoadingSessionMessages(true);
try {
const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`;
const res = await authenticatedFetch(url);
if (!res.ok) return [];
const data = await res.json();
const blobs = data?.session?.messages || [];
const converted = [];
const now = Date.now();
let idx = 0;
for (const blob of blobs) {
const content = blob.content;
let text = '';
let role = 'assistant';
try {
if (typeof content === 'string') {
// Attempt to extract embedded JSON first
const cleaned = content.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
let extractedTexts = [];
const start = cleaned.indexOf('{');
const end = cleaned.lastIndexOf('}');
if (start !== -1 && end !== -1 && end > 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);
}
}
}
} catch (_) {
// JSON parse failed; fall back to cleaned text
}
}
if (extractedTexts.length > 0) {
extractedTexts.forEach(t => converted.push({ type: 'assistant', content: t, timestamp: new Date(now + (idx++)) }));
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
.map(p => (typeof p === 'string' ? p : (p?.text || '')))
.filter(Boolean)
.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);
}
if (text && text.trim()) {
converted.push({
type: role,
content: text,
timestamp: new Date(now + (idx++))
});
}
}
return converted;
} catch (e) {
console.error('Error loading Cursor session messages:', e);
return [];
} finally {
setIsLoadingSessionMessages(false);
}
}, []);
// Actual diff calculation function
const calculateDiff = (oldStr, newStr) => {
const oldLines = oldStr.split('\n');
@@ -1349,31 +1487,47 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Load session messages when session changes
const loadMessages = async () => {
if (selectedSession && selectedProject) {
setCurrentSessionId(selectedSession.id);
const provider = localStorage.getItem('selected-provider') || 'claude';
// Only load messages from API if this is a user-initiated session change
// For system-initiated changes, preserve existing messages and rely on WebSocket
if (!isSystemSessionChange) {
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id);
setSessionMessages(messages);
// convertedMessages will be automatically updated via useMemo
// Scroll to bottom after loading session messages if auto-scroll is enabled
if (autoScrollToBottom) {
setTimeout(() => scrollToBottom(), 200);
}
if (provider === 'cursor') {
// For Cursor, set the session ID for resuming
setCurrentSessionId(selectedSession.id);
sessionStorage.setItem('cursorSessionId', selectedSession.id);
// Load historical messages for Cursor session from SQLite
const projectPath = selectedProject.fullPath || selectedProject.path;
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
setSessionMessages([]);
setChatMessages(converted);
} else {
// Reset the flag after handling system session change
setIsSystemSessionChange(false);
// For Claude, load messages normally
setCurrentSessionId(selectedSession.id);
// Only load messages from API if this is a user-initiated session change
// For system-initiated changes, preserve existing messages and rely on WebSocket
if (!isSystemSessionChange) {
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id);
setSessionMessages(messages);
// convertedMessages will be automatically updated via useMemo
// Scroll to bottom after loading session messages if auto-scroll is enabled
if (autoScrollToBottom) {
setTimeout(() => scrollToBottom(), 200);
}
} else {
// Reset the flag after handling system session change
setIsSystemSessionChange(false);
}
}
} else {
setChatMessages([]);
setSessionMessages([]);
setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId');
}
};
loadMessages();
}, [selectedSession, selectedProject, loadSessionMessages, scrollToBottom, isSystemSessionChange]);
}, [selectedSession, selectedProject, loadSessionMessages, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
// Update chatMessages when convertedMessages changes
useEffect(() => {
@@ -1441,6 +1595,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
case 'claude-response':
const messageData = latestMessage.data.message || latestMessage.data;
// Handle Cursor streaming format (content_block_delta / content_block_stop)
if (messageData && typeof messageData === 'object' && messageData.type) {
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
setChatMessages(prev => [...prev, {
type: 'assistant',
content: messageData.delta.text,
timestamp: new Date()
}]);
return;
}
if (messageData.type === 'content_block_stop') {
// Nothing specific to do; leave as-is
return;
}
}
// Handle Claude CLI session duplication bug workaround:
// When resuming a session, Claude CLI creates a new session instead of resuming.
// We detect this by checking for system/init messages with session_id that differs
@@ -1605,6 +1775,113 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}]);
break;
case 'cursor-system':
// Handle Cursor system/init messages similar to Claude
try {
const cdata = latestMessage.data;
if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) {
// If we already have a session and this differs, switch (duplication/redirect)
if (currentSessionId && cdata.session_id !== currentSessionId) {
console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id });
setIsSystemSessionChange(true);
if (onNavigateToSession) {
onNavigateToSession(cdata.session_id);
}
return;
}
// If we don't yet have a session, adopt this one
if (!currentSessionId) {
console.log('🔄 Cursor new session init detected:', { newSession: cdata.session_id });
setIsSystemSessionChange(true);
if (onNavigateToSession) {
onNavigateToSession(cdata.session_id);
}
return;
}
}
// 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);
}
break;
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;
case 'cursor-tool-use':
// Handle Cursor tool use messages
setChatMessages(prev => [...prev, {
type: 'assistant',
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''}`,
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.tool,
toolInput: latestMessage.input
}]);
break;
case 'cursor-error':
// Show Cursor errors as error messages in chat
setChatMessages(prev => [...prev, {
type: 'error',
content: `Cursor error: ${latestMessage.error || 'Unknown error'}`,
timestamp: new Date()
}]);
break;
case 'cursor-result':
// Handle Cursor completion and final result text
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
try {
const r = latestMessage.data || {};
const textResult = typeof r.result === 'string' ? r.result : '';
if (textResult && textResult.trim()) {
setChatMessages(prev => [...prev, {
type: r.is_error ? 'error' : 'assistant',
content: textResult,
timestamp: new Date()
}]);
}
} catch (e) {
console.warn('Error handling cursor-result message:', e);
}
// Mark session as inactive
const cursorSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId');
if (cursorSessionId && onSessionInactive) {
onSessionInactive(cursorSessionId);
}
// Store session ID for future use
if (cursorSessionId && !currentSessionId) {
setCurrentSessionId(cursorSessionId);
sessionStorage.removeItem('pendingSessionId');
}
break;
case 'cursor-output':
// Handle Cursor raw terminal output; strip ANSI and ignore empty control-only payloads
try {
const raw = String(latestMessage.data ?? '');
const cleaned = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim();
if (cleaned) {
setChatMessages(prev => [...prev, {
type: 'assistant',
content: cleaned,
timestamp: new Date()
}]);
}
} catch (e) {
console.warn('Error handling cursor-output message:', e);
}
break;
case 'claude-complete':
setIsLoading(false);
setCanAbortSession(false);
@@ -2027,10 +2304,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
onSessionActive(sessionToActivate);
}
// Get tools settings from localStorage
// Get tools settings from localStorage based on provider
const getToolsSettings = () => {
try {
const savedSettings = safeLocalStorage.getItem('claude-tools-settings');
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-tools-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
}
@@ -2046,20 +2324,40 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const toolsSettings = getToolsSettings();
// Send command to Claude CLI via WebSocket with images
sendMessage({
type: 'claude-command',
command: input,
options: {
projectPath: selectedProject.path,
cwd: selectedProject.fullPath,
// Send command based on provider
if (provider === 'cursor') {
// Send Cursor command (always use cursor-command; include resume/sessionId when replying)
sendMessage({
type: 'cursor-command',
command: input,
sessionId: currentSessionId,
resume: !!currentSessionId,
toolsSettings: toolsSettings,
permissionMode: permissionMode,
images: uploadedImages // Pass images to backend
}
});
options: {
// Prefer fullPath (actual cwd for project), fallback to path
cwd: selectedProject.fullPath || selectedProject.path,
projectPath: selectedProject.fullPath || selectedProject.path,
sessionId: currentSessionId,
resume: !!currentSessionId,
model: cursorModel,
skipPermissions: toolsSettings?.skipPermissions || false,
toolsSettings: toolsSettings
}
});
} else {
// Send Claude command (existing code)
sendMessage({
type: 'claude-command',
command: input,
options: {
projectPath: selectedProject.path,
cwd: selectedProject.fullPath,
sessionId: currentSessionId,
resume: !!currentSessionId,
toolsSettings: toolsSettings,
permissionMode: permissionMode,
images: uploadedImages // Pass images to backend
}
});
}
setInput('');
setAttachedImages([]);
@@ -2211,7 +2509,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (currentSessionId && canAbortSession) {
sendMessage({
type: 'abort-session',
sessionId: currentSessionId
sessionId: currentSessionId,
provider: provider
});
}
};
@@ -2303,10 +2602,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="chat-message assistant">
<div className="w-full">
<div className="flex items-center space-x-3 mb-2">
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
C
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-gray-600">
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
<CursorLogo className="w-full h-full" />
) : (
<ClaudeLogo className="w-full h-full" />
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">Claude</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude'}</div>
{/* Abort button removed - functionality not yet implemented at backend */}
</div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
@@ -2329,12 +2632,66 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
}`}>
{/* Claude Working Status - positioned above the input form */}
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={handleAbortSession}
/>
{/* Provider Selection and Working Status - positioned above the input form */}
<div className="max-w-4xl mx-auto mb-2">
<div className="flex items-center justify-between gap-3">
{/* Provider & Model Selection or Fixed Provider for existing session */}
<div className="flex items-center gap-2">
{selectedSession?.__provider ? (
<div className="flex items-center gap-2 px-2 py-1 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg">
{selectedSession.__provider === 'cursor' ? (
<CursorLogo className="w-4 h-4" />
) : (
<ClaudeLogo className="w-4 h-4" />
)}
<span className="text-sm capitalize">{selectedSession.__provider}</span>
</div>
) : (
<>
<select
value={provider}
onChange={(e) => {
const newProvider = e.target.value;
setProvider(newProvider);
localStorage.setItem('selected-provider', newProvider);
}}
className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isLoading}
>
<option value="claude">Claude</option>
<option value="cursor">Cursor</option>
</select>
{provider === 'cursor' && (
<select
value={cursorModel}
onChange={(e) => {
const newModel = e.target.value;
setCursorModel(newModel);
localStorage.setItem('cursor-model', newModel);
}}
className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isLoading}
>
<option value="gpt-5">GPT-5</option>
<option value="sonnet-4">Sonnet-4</option>
<option value="opus-4.1">Opus 4.1</option>
</select>
)}
</>
)}
</div>
{/* Status Display */}
<div className="flex-1">
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={handleAbortSession}
provider={provider}
/>
</div>
</div>
</div>
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
<div className="max-w-4xl mx-auto mb-3">

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
function ClaudeStatus({ status, onAbort, isLoading }) {
function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
const [fakeTokens, setFakeTokens] = useState(0);

View File

@@ -0,0 +1,9 @@
import React from 'react';
const CursorLogo = ({ className = 'w-5 h-5' }) => {
return (
<img src="/icons/cursor.svg" alt="Cursor" className={className} />
);
};
export default CursorLogo;

View File

@@ -157,7 +157,7 @@ function MainContent({
{activeTab === 'chat' && selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
{selectedSession.summary}
{selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session')}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName} <span className="hidden sm:inline"> {selectedSession.id}</span>

View File

@@ -530,11 +530,16 @@ function Shell({ selectedProject, selectedSession, isActive }) {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (
<span className="text-xs text-blue-300">
({selectedSession.summary.slice(0, 30)}...)
</span>
)}
{selectedSession && (() => {
const displaySessionName = selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
return (
<span className="text-xs text-blue-300">
({displaySessionName.slice(0, 30)}...)
</span>
);
})()}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
)}
@@ -601,7 +606,12 @@ function Shell({ selectedProject, selectedSession, isActive }) {
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{selectedSession ?
`Resume session: ${selectedSession.summary.slice(0, 50)}...` :
(() => {
const displaySessionName = selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
return `Resume session: ${displaySessionName.slice(0, 50)}...`;
})() :
'Start a new Claude session'
}
</p>

View File

@@ -7,6 +7,7 @@ import { Input } from './ui/input';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
import { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo.jsx';
import { api } from '../utils/api';
// Move formatTimeAgo outside component to avoid recreation on every render
@@ -202,9 +203,12 @@ function Sidebar({
// Helper function to get all sessions for a project (initial + additional)
const getAllSessions = (project) => {
const initialSessions = project.sessions || [];
const additional = additionalSessions[project.name] || [];
return [...initialSessions, ...additional];
// Combine Claude and Cursor sessions; Sidebar will display icon per row
const claudeSessions = [...(project.sessions || []), ...(additionalSessions[project.name] || [])].map(s => ({ ...s, __provider: 'claude' }));
const cursorSessions = (project.cursorSessions || []).map(s => ({ ...s, __provider: 'cursor' }));
// Sort by most recent activity/date
const normalizeDate = (s) => new Date(s.__provider === 'cursor' ? s.createdAt : s.lastActivity);
return [...claudeSessions, ...cursorSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a));
};
// Helper function to get the last activity date for a project
@@ -979,11 +983,19 @@ function Sidebar({
</div>
) : (
getAllSessions(project).map((session) => {
// Handle both Claude and Cursor session formats
const isCursorSession = session.__provider === 'cursor';
// Calculate if session is active (within last 10 minutes)
const sessionDate = new Date(session.lastActivity);
const sessionDate = new Date(isCursorSession ? session.createdAt : session.lastActivity);
const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60));
const isActive = diffInMinutes < 10;
// Get session display values
const sessionName = isCursorSession ? (session.name || 'Untitled Session') : (session.summary || 'New Session');
const sessionTime = isCursorSession ? session.createdAt : session.lastActivity;
const messageCount = session.messageCount || 0;
return (
<div key={session.id} className="group relative">
{/* Active session indicator dot */}
@@ -1014,38 +1026,49 @@ function Sidebar({
"w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
)}>
<MessageSquare className={cn(
"w-3 h-3",
selectedSession?.id === session.id ? "text-primary" : "text-muted-foreground"
)} />
{isCursorSession ? (
<CursorLogo className="w-3 h-3" />
) : (
<ClaudeLogo className="w-3 h-3" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate text-foreground">
{session.summary || 'New Session'}
{sessionName}
</div>
<div className="flex items-center gap-1 mt-0.5">
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(session.lastActivity, currentTime)}
{formatTimeAgo(sessionTime, currentTime)}
</span>
{session.messageCount > 0 && (
{messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
{session.messageCount}
{messageCount}
</Badge>
)}
{/* Provider tiny icon */}
<span className="ml-1 opacity-70">
{isCursorSession ? (
<CursorLogo className="w-3 h-3" />
) : (
<ClaudeLogo className="w-3 h-3" />
)}
</span>
</div>
</div>
{/* Mobile delete button */}
<button
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id);
}}
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
>
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button>
{/* Mobile delete button - only for Claude sessions */}
{!isCursorSession && (
<button
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id);
}}
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
>
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button>
)}
</div>
</div>
</div>
@@ -1062,26 +1085,39 @@ function Sidebar({
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
>
<div className="flex items-start gap-2 min-w-0 w-full">
<MessageSquare className="w-3 h-3 text-muted-foreground mt-0.5 flex-shrink-0" />
{isCursorSession ? (
<CursorLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
) : (
<ClaudeLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate text-foreground">
{session.summary || 'New Session'}
{sessionName}
</div>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(session.lastActivity, currentTime)}
{formatTimeAgo(sessionTime, currentTime)}
</span>
{session.messageCount > 0 && (
{messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
{session.messageCount}
{messageCount}
</Badge>
)}
{/* Provider tiny icon */}
<span className="ml-1 opacity-70">
{isCursorSession ? (
<CursorLogo className="w-3 h-3" />
) : (
<ClaudeLogo className="w-3 h-3" />
)}
</span>
</div>
</div>
</div>
</Button>
{/* Desktop hover buttons */}
{/* Desktop hover buttons - only for Claude sessions */}
{!isCursorSession && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
{editingSession === session.id ? (
<>
@@ -1168,6 +1204,7 @@ function Sidebar({
</>
)}
</div>
)}
</div>
</div>
);

View File

@@ -41,7 +41,16 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
const [mcpToolsLoading, setMcpToolsLoading] = useState({});
const [activeTab, setActiveTab] = useState('tools');
const [jsonValidationError, setJsonValidationError] = useState('');
// Common tool patterns
const [toolsProvider, setToolsProvider] = useState('claude'); // 'claude' or 'cursor'
// Cursor-specific states
const [cursorAllowedCommands, setCursorAllowedCommands] = useState([]);
const [cursorDisallowedCommands, setCursorDisallowedCommands] = useState([]);
const [cursorSkipPermissions, setCursorSkipPermissions] = useState(false);
const [newCursorCommand, setNewCursorCommand] = useState('');
const [newCursorDisallowedCommand, setNewCursorDisallowedCommand] = useState('');
const [cursorMcpServers, setCursorMcpServers] = useState([]);
// Common tool patterns for Claude
const commonTools = [
'Bash(git log:*)',
'Bash(git diff:*)',
@@ -58,7 +67,45 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
'WebFetch',
'WebSearch'
];
// Common shell commands for Cursor
const commonCursorCommands = [
'Shell(ls)',
'Shell(mkdir)',
'Shell(cd)',
'Shell(cat)',
'Shell(echo)',
'Shell(git status)',
'Shell(git diff)',
'Shell(git log)',
'Shell(npm install)',
'Shell(npm run)',
'Shell(python)',
'Shell(node)'
];
// Fetch Cursor MCP servers
const fetchCursorMcpServers = async () => {
try {
const token = localStorage.getItem('auth-token');
const response = await fetch('/api/cursor/mcp', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setCursorMcpServers(data.servers || []);
} else {
console.error('Failed to fetch Cursor MCP servers');
}
} catch (error) {
console.error('Error fetching Cursor MCP servers:', error);
}
};
// MCP API functions
const fetchMcpServers = async () => {
try {
@@ -268,7 +315,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
const loadSettings = async () => {
try {
// Load from localStorage
// Load Claude settings from localStorage
const savedSettings = localStorage.getItem('claude-tools-settings');
if (savedSettings) {
@@ -284,9 +331,27 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
setSkipPermissions(false);
setProjectSortOrder('name');
}
// Load Cursor settings from localStorage
const savedCursorSettings = localStorage.getItem('cursor-tools-settings');
if (savedCursorSettings) {
const cursorSettings = JSON.parse(savedCursorSettings);
setCursorAllowedCommands(cursorSettings.allowedCommands || []);
setCursorDisallowedCommands(cursorSettings.disallowedCommands || []);
setCursorSkipPermissions(cursorSettings.skipPermissions || false);
} else {
// Set Cursor defaults
setCursorAllowedCommands([]);
setCursorDisallowedCommands([]);
setCursorSkipPermissions(false);
}
// Load MCP servers from API
await fetchMcpServers();
// Load Cursor MCP servers
await fetchCursorMcpServers();
} catch (error) {
console.error('Error loading tool settings:', error);
// Set defaults on error
@@ -302,7 +367,8 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
setSaveStatus(null);
try {
const settings = {
// Save Claude settings
const claudeSettings = {
allowedTools,
disallowedTools,
skipPermissions,
@@ -310,9 +376,17 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
lastUpdated: new Date().toISOString()
};
// Save Cursor settings
const cursorSettings = {
allowedCommands: cursorAllowedCommands,
disallowedCommands: cursorDisallowedCommands,
skipPermissions: cursorSkipPermissions,
lastUpdated: new Date().toISOString()
};
// Save to localStorage
localStorage.setItem('claude-tools-settings', JSON.stringify(settings));
localStorage.setItem('claude-tools-settings', JSON.stringify(claudeSettings));
localStorage.setItem('cursor-tools-settings', JSON.stringify(cursorSettings));
setSaveStatus('success');
@@ -635,6 +709,36 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
{activeTab === 'tools' && (
<div className="space-y-6 md:space-y-8">
{/* Provider Tabs */}
<div className="border-b border-gray-300 dark:border-gray-600">
<div className="flex gap-4">
<button
onClick={() => setToolsProvider('claude')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
toolsProvider === 'claude'
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Claude Tools
</button>
<button
onClick={() => setToolsProvider('cursor')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
toolsProvider === 'cursor'
? 'border-purple-600 text-purple-600 dark:text-purple-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Cursor Tools
</button>
</div>
</div>
{/* Claude Tools Content */}
{toolsProvider === 'claude' && (
<div className="space-y-6 md:space-y-8">
{/* Skip Permissions */}
<div className="space-y-4">
<div className="flex items-center gap-3">
@@ -1360,6 +1464,216 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
)}
</div>
)}
{/* Cursor Tools Content */}
{toolsProvider === 'cursor' && (
<div className="space-y-6 md:space-y-8">
{/* Skip Permissions for Cursor */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-medium text-foreground">
Cursor Permission Settings
</h3>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={cursorSkipPermissions}
onChange={(e) => setCursorSkipPermissions(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
Skip permission prompts (use with caution)
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
Equivalent to -f flag in Cursor CLI
</div>
</div>
</label>
</div>
</div>
{/* Allowed Shell Commands */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">
Allowed Shell Commands
</h3>
</div>
<p className="text-sm text-muted-foreground">
Shell commands that are automatically allowed without prompting for permission
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newCursorCommand}
onChange={(e) => setNewCursorCommand(e.target.value)}
placeholder='e.g., "Shell(ls)" or "Shell(git status)"'
onKeyPress={(e) => {
if (e.key === 'Enter') {
if (newCursorCommand && !cursorAllowedCommands.includes(newCursorCommand)) {
setCursorAllowedCommands([...cursorAllowedCommands, newCursorCommand]);
setNewCursorCommand('');
}
}
}}
className="flex-1 h-10 touch-manipulation"
style={{ fontSize: '16px' }}
/>
<Button
onClick={() => {
if (newCursorCommand && !cursorAllowedCommands.includes(newCursorCommand)) {
setCursorAllowedCommands([...cursorAllowedCommands, newCursorCommand]);
setNewCursorCommand('');
}
}}
disabled={!newCursorCommand}
size="sm"
className="h-10 px-4 touch-manipulation"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add Command</span>
</Button>
</div>
{/* Common commands quick add */}
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Quick add common commands:
</p>
<div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2">
{commonCursorCommands.map(cmd => (
<Button
key={cmd}
variant="outline"
size="sm"
onClick={() => {
if (!cursorAllowedCommands.includes(cmd)) {
setCursorAllowedCommands([...cursorAllowedCommands, cmd]);
}
}}
disabled={cursorAllowedCommands.includes(cmd)}
className="text-xs h-8 touch-manipulation truncate"
>
{cmd}
</Button>
))}
</div>
</div>
<div className="space-y-2">
{cursorAllowedCommands.map(cmd => (
<div key={cmd} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
<span className="font-mono text-sm text-green-800 dark:text-green-200">
{cmd}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCursorAllowedCommands(cursorAllowedCommands.filter(c => c !== cmd))}
className="text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{cursorAllowedCommands.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No allowed shell commands configured
</div>
)}
</div>
</div>
{/* Disallowed Shell Commands */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-medium text-foreground">
Disallowed Shell Commands
</h3>
</div>
<p className="text-sm text-muted-foreground">
Shell commands that should always be denied
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newCursorDisallowedCommand}
onChange={(e) => setNewCursorDisallowedCommand(e.target.value)}
placeholder='e.g., "Shell(rm -rf)" or "Shell(sudo)"'
onKeyPress={(e) => {
if (e.key === 'Enter') {
if (newCursorDisallowedCommand && !cursorDisallowedCommands.includes(newCursorDisallowedCommand)) {
setCursorDisallowedCommands([...cursorDisallowedCommands, newCursorDisallowedCommand]);
setNewCursorDisallowedCommand('');
}
}
}}
className="flex-1 h-10 touch-manipulation"
style={{ fontSize: '16px' }}
/>
<Button
onClick={() => {
if (newCursorDisallowedCommand && !cursorDisallowedCommands.includes(newCursorDisallowedCommand)) {
setCursorDisallowedCommands([...cursorDisallowedCommands, newCursorDisallowedCommand]);
setNewCursorDisallowedCommand('');
}
}}
disabled={!newCursorDisallowedCommand}
size="sm"
className="h-10 px-4 touch-manipulation"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add Command</span>
</Button>
</div>
<div className="space-y-2">
{cursorDisallowedCommands.map(cmd => (
<div key={cmd} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<span className="font-mono text-sm text-red-800 dark:text-red-200">
{cmd}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCursorDisallowedCommands(cursorDisallowedCommands.filter(c => c !== cmd))}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{cursorDisallowedCommands.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No disallowed shell commands configured
</div>
)}
</div>
</div>
{/* Help Section */}
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<h4 className="font-medium text-purple-900 dark:text-purple-100 mb-2">
Cursor Shell Command Examples:
</h4>
<ul className="text-sm text-purple-800 dark:text-purple-200 space-y-1">
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(ls)"</code> - Allow ls command</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(git status)"</code> - Allow git status command</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(mkdir)"</code> - Allow mkdir command</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"-f"</code> flag - Skip all permission prompts (dangerous)</li>
</ul>
</div>
</div>
)}
</div>
)}
</div>
</div>