mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-10 14:59:46 +00:00
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:
25
.gitignore
vendored
25
.gitignore
vendored
@@ -98,10 +98,31 @@ temp/
|
|||||||
# Local Netlify folder
|
# Local Netlify folder
|
||||||
.netlify
|
.netlify
|
||||||
|
|
||||||
# Claude specific
|
# AI specific
|
||||||
.claude/
|
.claude/
|
||||||
|
.cursor/
|
||||||
|
.roo/
|
||||||
|
.taskmaster/
|
||||||
|
.cline/
|
||||||
|
.windsurf/
|
||||||
|
|
||||||
# Database files
|
# Database files
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
logs
|
||||||
|
dev-debug.log
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
# OS specific
|
||||||
|
|
||||||
|
# Task files
|
||||||
|
tasks.json
|
||||||
|
tasks/
|
||||||
|
|||||||
1345
package-lock.json
generated
1345
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,8 @@
|
|||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
|
"sqlite": "^5.1.1",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
@@ -68,4 +70,4 @@
|
|||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
public/icons/cursor.svg
Normal file
1
public/icons/cursor.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z" fill="url(#lobe-icons-cursorundefined-fill-0)"></path><path d="M22.35 18V6L11.925 0v12l10.425 6z" fill="url(#lobe-icons-cursorundefined-fill-1)"></path><path d="M11.925 0L1.5 6v12l10.425-6V0z" fill="url(#lobe-icons-cursorundefined-fill-2)"></path><path d="M22.35 6L11.925 24V12L22.35 6z" fill="#555"></path><path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="#000"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-0" x1="11.925" x2="11.925" y1="12" y2="24"><stop offset=".16" stop-color="#000" stop-opacity=".39"></stop><stop offset=".658" stop-color="#000" stop-opacity=".8"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-1" x1="22.35" x2="11.925" y1="6.037" y2="12.15"><stop offset=".182" stop-color="#000" stop-opacity=".31"></stop><stop offset=".715" stop-color="#000" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-2" x1="11.925" x2="1.5" y1="0" y2="18"><stop stop-color="#000" stop-opacity=".6"></stop><stop offset=".667" stop-color="#000" stop-opacity=".22"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
250
server/cursor-cli.js
Normal file
250
server/cursor-cli.js
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
async function spawnCursor(command, options = {}, ws) {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = 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
|
||||||
|
|
||||||
|
// Use tools settings passed from frontend, or defaults
|
||||||
|
const settings = toolsSettings || {
|
||||||
|
allowedShellCommands: [],
|
||||||
|
skipPermissions: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build Cursor CLI command
|
||||||
|
const args = [];
|
||||||
|
|
||||||
|
// Build flags allowing both resume and prompt together (reply in existing session)
|
||||||
|
if (resume && sessionId) {
|
||||||
|
// Resume existing session
|
||||||
|
args.push('--resume=' + sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command && command.trim()) {
|
||||||
|
// Provide a prompt (works for both new and resumed sessions)
|
||||||
|
args.push('-p', command);
|
||||||
|
|
||||||
|
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||||
|
if (!resume && model) {
|
||||||
|
args.push('--model', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request streaming JSON when we are providing a prompt
|
||||||
|
args.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)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send session-created event only once for new sessions
|
||||||
|
if (!sessionId && !sessionCreatedSent) {
|
||||||
|
sessionCreatedSent = true;
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'session-created',
|
||||||
|
sessionId: capturedSessionId,
|
||||||
|
model: response.model,
|
||||||
|
cwd: response.cwd
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send system info to frontend
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'cursor-system',
|
||||||
|
data: response
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user':
|
||||||
|
// Forward user message
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'cursor-user',
|
||||||
|
data: response
|
||||||
|
}));
|
||||||
|
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(JSON.stringify({
|
||||||
|
type: 'claude-response',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
delta: {
|
||||||
|
type: 'text_delta',
|
||||||
|
text: textContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'result':
|
||||||
|
// Session complete
|
||||||
|
console.log('Cursor session result:', response);
|
||||||
|
|
||||||
|
// Send final message if we have buffered content
|
||||||
|
if (messageBuffer) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'claude-response',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_stop'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send completion event
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'cursor-result',
|
||||||
|
data: response,
|
||||||
|
success: response.subtype === 'success'
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Forward any other message types
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'cursor-response',
|
||||||
|
data: response
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.log('📄 Non-JSON response:', line);
|
||||||
|
// If not JSON, send as raw text
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'cursor-output',
|
||||||
|
data: line
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle stderr
|
||||||
|
cursorProcess.stderr.on('data', (data) => {
|
||||||
|
console.error('Cursor CLI stderr:', data.toString());
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'cursor-error',
|
||||||
|
error: data.toString()
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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(JSON.stringify({
|
||||||
|
type: 'claude-complete',
|
||||||
|
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);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'cursor-error',
|
||||||
|
error: error.message
|
||||||
|
}));
|
||||||
|
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close stdin since Cursor doesn't need interactive input
|
||||||
|
cursorProcess.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
spawnCursor,
|
||||||
|
abortCursorSession
|
||||||
|
};
|
||||||
@@ -38,9 +38,11 @@ import mime from 'mime-types';
|
|||||||
|
|
||||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||||
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
|
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
|
||||||
|
import { spawnCursor, abortCursorSession } from './cursor-cli.js';
|
||||||
import gitRoutes from './routes/git.js';
|
import gitRoutes from './routes/git.js';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import mcpRoutes from './routes/mcp.js';
|
import mcpRoutes from './routes/mcp.js';
|
||||||
|
import cursorRoutes from './routes/cursor.js';
|
||||||
import { initializeDatabase } from './database/db.js';
|
import { initializeDatabase } from './database/db.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
|
|
||||||
@@ -175,6 +177,9 @@ app.use('/api/git', authenticateToken, gitRoutes);
|
|||||||
// MCP API Routes (protected)
|
// MCP API Routes (protected)
|
||||||
app.use('/api/mcp', authenticateToken, mcpRoutes);
|
app.use('/api/mcp', authenticateToken, mcpRoutes);
|
||||||
|
|
||||||
|
// Cursor API Routes (protected)
|
||||||
|
app.use('/api/cursor', authenticateToken, cursorRoutes);
|
||||||
|
|
||||||
// Static files served after API routes
|
// Static files served after API routes
|
||||||
app.use(express.static(path.join(__dirname, '../dist')));
|
app.use(express.static(path.join(__dirname, '../dist')));
|
||||||
|
|
||||||
@@ -460,12 +465,39 @@ function handleChatConnection(ws) {
|
|||||||
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
|
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
|
||||||
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||||
await spawnClaude(data.command, data.options, ws);
|
await spawnClaude(data.command, data.options, ws);
|
||||||
|
} else if (data.type === 'cursor-command') {
|
||||||
|
console.log('🖱️ Cursor message:', data.command || '[Continue/Resume]');
|
||||||
|
console.log('📁 Project:', data.options?.cwd || 'Unknown');
|
||||||
|
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||||
|
console.log('🤖 Model:', data.options?.model || 'default');
|
||||||
|
await spawnCursor(data.command, data.options, ws);
|
||||||
|
} else if (data.type === 'cursor-resume') {
|
||||||
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
||||||
|
console.log('🖱️ Cursor resume session (compat):', data.sessionId);
|
||||||
|
await spawnCursor('', {
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
resume: true,
|
||||||
|
cwd: data.options?.cwd
|
||||||
|
}, ws);
|
||||||
} else if (data.type === 'abort-session') {
|
} else if (data.type === 'abort-session') {
|
||||||
console.log('🛑 Abort session request:', data.sessionId);
|
console.log('🛑 Abort session request:', data.sessionId);
|
||||||
const success = abortClaudeSession(data.sessionId);
|
const provider = data.provider || 'claude';
|
||||||
|
const success = provider === 'cursor'
|
||||||
|
? abortCursorSession(data.sessionId)
|
||||||
|
: abortClaudeSession(data.sessionId);
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'session-aborted',
|
type: 'session-aborted',
|
||||||
sessionId: data.sessionId,
|
sessionId: data.sessionId,
|
||||||
|
provider,
|
||||||
|
success
|
||||||
|
}));
|
||||||
|
} else if (data.type === 'cursor-abort') {
|
||||||
|
console.log('🛑 Abort Cursor session:', data.sessionId);
|
||||||
|
const success = abortCursorSession(data.sessionId);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'session-aborted',
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
provider: 'cursor',
|
||||||
success
|
success
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
637
server/routes/cursor.js
Normal file
637
server/routes/cursor.js
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import { open } from 'sqlite';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/cursor/config - Read Cursor CLI configuration
|
||||||
|
router.get('/config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configContent = await fs.readFile(configPath, 'utf8');
|
||||||
|
const config = JSON.parse(configContent);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
config: config,
|
||||||
|
path: configPath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Config doesn't exist or is invalid
|
||||||
|
console.log('Cursor config not found or invalid:', error.message);
|
||||||
|
|
||||||
|
// Return default config
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
config: {
|
||||||
|
version: 1,
|
||||||
|
model: {
|
||||||
|
modelId: "gpt-5",
|
||||||
|
displayName: "GPT-5"
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
allow: [],
|
||||||
|
deny: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isDefault: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading Cursor config:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to read Cursor configuration',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/cursor/config - Update Cursor CLI configuration
|
||||||
|
router.post('/config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { permissions, model } = req.body;
|
||||||
|
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||||
|
|
||||||
|
// Read existing config or create default
|
||||||
|
let config = {
|
||||||
|
version: 1,
|
||||||
|
editor: {
|
||||||
|
vimMode: false
|
||||||
|
},
|
||||||
|
hasChangedDefaultModel: false,
|
||||||
|
privacyCache: {
|
||||||
|
ghostMode: false,
|
||||||
|
privacyMode: 3,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await fs.readFile(configPath, 'utf8');
|
||||||
|
config = JSON.parse(existing);
|
||||||
|
} catch (error) {
|
||||||
|
// Config doesn't exist, use defaults
|
||||||
|
console.log('Creating new Cursor config');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update permissions if provided
|
||||||
|
if (permissions) {
|
||||||
|
config.permissions = {
|
||||||
|
allow: permissions.allow || [],
|
||||||
|
deny: permissions.deny || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update model if provided
|
||||||
|
if (model) {
|
||||||
|
config.model = model;
|
||||||
|
config.hasChangedDefaultModel = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const configDir = path.dirname(configPath);
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
|
||||||
|
// Write updated config
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
config: config,
|
||||||
|
message: 'Cursor configuration updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating Cursor config:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to update Cursor configuration',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/cursor/mcp - Read Cursor MCP servers configuration
|
||||||
|
router.get('/mcp', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mcpContent = await fs.readFile(mcpPath, 'utf8');
|
||||||
|
const mcpConfig = JSON.parse(mcpContent);
|
||||||
|
|
||||||
|
// Convert to UI-friendly format
|
||||||
|
const servers = [];
|
||||||
|
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
|
||||||
|
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
|
||||||
|
const server = {
|
||||||
|
id: name,
|
||||||
|
name: name,
|
||||||
|
type: 'stdio',
|
||||||
|
scope: 'cursor',
|
||||||
|
config: {},
|
||||||
|
raw: config
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine transport type and extract config
|
||||||
|
if (config.command) {
|
||||||
|
server.type = 'stdio';
|
||||||
|
server.config.command = config.command;
|
||||||
|
server.config.args = config.args || [];
|
||||||
|
server.config.env = config.env || {};
|
||||||
|
} else if (config.url) {
|
||||||
|
server.type = config.transport || 'http';
|
||||||
|
server.config.url = config.url;
|
||||||
|
server.config.headers = config.headers || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.push(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
servers: servers,
|
||||||
|
path: mcpPath
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// MCP config doesn't exist
|
||||||
|
console.log('Cursor MCP config not found:', error.message);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
servers: [],
|
||||||
|
isDefault: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading Cursor MCP config:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to read Cursor MCP configuration',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration
|
||||||
|
router.post('/mcp/add', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
|
||||||
|
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||||
|
|
||||||
|
console.log(`➕ Adding MCP server to Cursor config: ${name}`);
|
||||||
|
|
||||||
|
// Read existing config or create new
|
||||||
|
let mcpConfig = { mcpServers: {} };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||||
|
mcpConfig = JSON.parse(existing);
|
||||||
|
if (!mcpConfig.mcpServers) {
|
||||||
|
mcpConfig.mcpServers = {};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Creating new Cursor MCP config');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build server config based on type
|
||||||
|
let serverConfig = {};
|
||||||
|
|
||||||
|
if (type === 'stdio') {
|
||||||
|
serverConfig = {
|
||||||
|
command: command,
|
||||||
|
args: args,
|
||||||
|
env: env
|
||||||
|
};
|
||||||
|
} else if (type === 'http' || type === 'sse') {
|
||||||
|
serverConfig = {
|
||||||
|
url: url,
|
||||||
|
transport: type,
|
||||||
|
headers: headers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add server to config
|
||||||
|
mcpConfig.mcpServers[name] = serverConfig;
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const mcpDir = path.dirname(mcpPath);
|
||||||
|
await fs.mkdir(mcpDir, { recursive: true });
|
||||||
|
|
||||||
|
// Write updated config
|
||||||
|
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `MCP server "${name}" added to Cursor configuration`,
|
||||||
|
config: mcpConfig
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding MCP server to Cursor:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to add MCP server',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration
|
||||||
|
router.delete('/mcp/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||||
|
|
||||||
|
console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);
|
||||||
|
|
||||||
|
// Read existing config
|
||||||
|
let mcpConfig = { mcpServers: {} };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||||
|
mcpConfig = JSON.parse(existing);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Cursor MCP configuration not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server exists
|
||||||
|
if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `MCP server "${name}" not found in Cursor configuration`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove server from config
|
||||||
|
delete mcpConfig.mcpServers[name];
|
||||||
|
|
||||||
|
// Write updated config
|
||||||
|
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `MCP server "${name}" removed from Cursor configuration`,
|
||||||
|
config: mcpConfig
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing MCP server from Cursor:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to remove MCP server',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/cursor/mcp/add-json - Add MCP server using JSON format
|
||||||
|
router.post('/mcp/add-json', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, jsonConfig } = req.body;
|
||||||
|
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||||
|
|
||||||
|
console.log(`➕ Adding MCP server to Cursor config via JSON: ${name}`);
|
||||||
|
|
||||||
|
// Validate and parse JSON config
|
||||||
|
let parsedConfig;
|
||||||
|
try {
|
||||||
|
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
|
||||||
|
} catch (parseError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid JSON configuration',
|
||||||
|
details: parseError.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing config or create new
|
||||||
|
let mcpConfig = { mcpServers: {} };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||||
|
mcpConfig = JSON.parse(existing);
|
||||||
|
if (!mcpConfig.mcpServers) {
|
||||||
|
mcpConfig.mcpServers = {};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Creating new Cursor MCP config');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add server to config
|
||||||
|
mcpConfig.mcpServers[name] = parsedConfig;
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const mcpDir = path.dirname(mcpPath);
|
||||||
|
await fs.mkdir(mcpDir, { recursive: true });
|
||||||
|
|
||||||
|
// Write updated config
|
||||||
|
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `MCP server "${name}" added to Cursor configuration via JSON`,
|
||||||
|
config: mcpConfig
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding MCP server to Cursor via JSON:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to add MCP server',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/cursor/sessions - Get Cursor sessions from SQLite database
|
||||||
|
router.get('/sessions', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { projectPath } = req.query;
|
||||||
|
|
||||||
|
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
|
||||||
|
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||||
|
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||||
|
|
||||||
|
console.log(`🔍 Looking for Cursor sessions in: ${cursorChatsPath}`);
|
||||||
|
|
||||||
|
// Check if the directory exists
|
||||||
|
try {
|
||||||
|
await fs.access(cursorChatsPath);
|
||||||
|
} catch (error) {
|
||||||
|
// No sessions for this project
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
sessions: [],
|
||||||
|
cwdId: cwdId,
|
||||||
|
path: cursorChatsPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
`);
|
||||||
|
|
||||||
|
let sessionData = {
|
||||||
|
id: sessionId,
|
||||||
|
name: 'Untitled Session',
|
||||||
|
createdAt: null,
|
||||||
|
mode: null,
|
||||||
|
projectPath: projectPath,
|
||||||
|
lastMessage: null,
|
||||||
|
messageCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse meta table entries
|
||||||
|
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');
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
|
||||||
|
if (row.key === 'agent') {
|
||||||
|
sessionData.name = data.name || sessionData.name;
|
||||||
|
sessionData.createdAt = data.createdAt;
|
||||||
|
sessionData.mode = data.mode;
|
||||||
|
sessionData.agentId = data.agentId;
|
||||||
|
sessionData.latestRootBlobId = data.latestRootBlobId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If not hex, use raw value for simple keys
|
||||||
|
if (row.key === 'name') {
|
||||||
|
sessionData.name = row.value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Could not parse meta value for key ${row.key}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get message count from blobs table
|
||||||
|
try {
|
||||||
|
const blobCount = await db.get(`
|
||||||
|
SELECT COUNT(*) as count FROM blobs
|
||||||
|
`);
|
||||||
|
sessionData.messageCount = blobCount.count;
|
||||||
|
|
||||||
|
// Get the most recent blob for preview
|
||||||
|
const lastBlob = await db.get(`
|
||||||
|
SELECT data FROM blobs
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (lastBlob && lastBlob.data) {
|
||||||
|
try {
|
||||||
|
// Try to extract readable preview from blob (may contain binary with embedded JSON)
|
||||||
|
const raw = lastBlob.data.toString('utf8');
|
||||||
|
let preview = '';
|
||||||
|
// Attempt direct JSON parse
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed?.content) {
|
||||||
|
if (Array.isArray(parsed.content)) {
|
||||||
|
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
|
||||||
|
preview = firstText;
|
||||||
|
} else if (typeof parsed.content === 'string') {
|
||||||
|
preview = parsed.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (!preview) {
|
||||||
|
// Strip non-printable and try to find JSON chunk
|
||||||
|
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
|
||||||
|
const s = cleaned;
|
||||||
|
const start = s.indexOf('{');
|
||||||
|
const end = s.lastIndexOf('}');
|
||||||
|
if (start !== -1 && end > start) {
|
||||||
|
const jsonStr = s.slice(start, end + 1);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
if (parsed?.content) {
|
||||||
|
if (Array.isArray(parsed.content)) {
|
||||||
|
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
|
||||||
|
preview = firstText;
|
||||||
|
} else if (typeof parsed.content === 'string') {
|
||||||
|
preview = parsed.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
preview = s;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
preview = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preview && preview.length > 0) {
|
||||||
|
sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Could not parse blob data:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Could not read blobs:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.close();
|
||||||
|
|
||||||
|
sessions.push(sessionData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Could not read session ${sessionId}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort sessions by creation date (newest first)
|
||||||
|
sessions.sort((a, b) => {
|
||||||
|
if (!a.createdAt) return 1;
|
||||||
|
if (!b.createdAt) return -1;
|
||||||
|
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
sessions: sessions,
|
||||||
|
cwdId: cwdId,
|
||||||
|
path: cursorChatsPath
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading Cursor sessions:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to read Cursor sessions',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite
|
||||||
|
router.get('/sessions/:sessionId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { projectPath } = req.query;
|
||||||
|
|
||||||
|
// Calculate cwdID hash for the project path
|
||||||
|
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||||
|
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
||||||
|
|
||||||
|
console.log(`📖 Reading Cursor session from: ${storeDbPath}`);
|
||||||
|
|
||||||
|
// Open SQLite database
|
||||||
|
const db = await open({
|
||||||
|
filename: storeDbPath,
|
||||||
|
driver: sqlite3.Database,
|
||||||
|
mode: sqlite3.OPEN_READONLY
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all blobs (conversation data)
|
||||||
|
const blobs = await db.all(`
|
||||||
|
SELECT id, data FROM blobs
|
||||||
|
ORDER BY id ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse blob data to extract messages
|
||||||
|
const messages = [];
|
||||||
|
for (const blob of blobs) {
|
||||||
|
try {
|
||||||
|
// Attempt direct JSON parse first
|
||||||
|
const raw = blob.data.toString('utf8');
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
// If not JSON, try to extract JSON from within binary-looking string
|
||||||
|
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
|
||||||
|
const start = cleaned.indexOf('{');
|
||||||
|
const end = cleaned.lastIndexOf('}');
|
||||||
|
if (start !== -1 && end > start) {
|
||||||
|
const jsonStr = cleaned.slice(start, end + 1);
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonStr);
|
||||||
|
} catch (_) {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
messages.push({ id: blob.id, content: blob.data.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.close();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
session: {
|
||||||
|
id: sessionId,
|
||||||
|
projectPath: projectPath,
|
||||||
|
messages: messages,
|
||||||
|
metadata: metadata,
|
||||||
|
cwdId: cwdId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading Cursor session:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to read Cursor session',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
49
src/App.jsx
49
src/App.jsx
@@ -31,7 +31,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
|||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||||
import { api } from './utils/api';
|
import { api, authenticatedFetch } from './utils/api';
|
||||||
|
|
||||||
|
|
||||||
// Main App component with routing
|
// Main App component with routing
|
||||||
@@ -192,6 +192,27 @@ function AppContent() {
|
|||||||
const response = await api.projects();
|
const response = await api.projects();
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Always fetch Cursor sessions for each project so we can combine views
|
||||||
|
for (let project of data) {
|
||||||
|
try {
|
||||||
|
const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`;
|
||||||
|
const cursorResponse = await authenticatedFetch(url);
|
||||||
|
if (cursorResponse.ok) {
|
||||||
|
const cursorData = await cursorResponse.json();
|
||||||
|
if (cursorData.success && cursorData.sessions) {
|
||||||
|
project.cursorSessions = cursorData.sessions;
|
||||||
|
} else {
|
||||||
|
project.cursorSessions = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
project.cursorSessions = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching Cursor sessions for project ${project.name}:`, error);
|
||||||
|
project.cursorSessions = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Optimize to preserve object references when data hasn't changed
|
// Optimize to preserve object references when data hasn't changed
|
||||||
setProjects(prevProjects => {
|
setProjects(prevProjects => {
|
||||||
// If no previous projects, just set the new data
|
// If no previous projects, just set the new data
|
||||||
@@ -210,7 +231,8 @@ function AppContent() {
|
|||||||
newProject.displayName !== prevProject.displayName ||
|
newProject.displayName !== prevProject.displayName ||
|
||||||
newProject.fullPath !== prevProject.fullPath ||
|
newProject.fullPath !== prevProject.fullPath ||
|
||||||
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
|
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
|
||||||
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions)
|
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) ||
|
||||||
|
JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions)
|
||||||
);
|
);
|
||||||
}) || data.length !== prevProjects.length;
|
}) || data.length !== prevProjects.length;
|
||||||
|
|
||||||
@@ -236,16 +258,26 @@ function AppContent() {
|
|||||||
const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
|
const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
|
||||||
// Find the session across all projects
|
// Find the session across all projects
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const session = project.sessions?.find(s => s.id === sessionId);
|
let session = project.sessions?.find(s => s.id === sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
setSelectedProject(project);
|
setSelectedProject(project);
|
||||||
setSelectedSession(session);
|
setSelectedSession({ ...session, __provider: 'claude' });
|
||||||
// Only switch to chat tab if we're loading a different session
|
// Only switch to chat tab if we're loading a different session
|
||||||
if (shouldSwitchTab) {
|
if (shouldSwitchTab) {
|
||||||
setActiveTab('chat');
|
setActiveTab('chat');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Also check Cursor sessions
|
||||||
|
const cSession = project.cursorSessions?.find(s => s.id === sessionId);
|
||||||
|
if (cSession) {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setSelectedSession({ ...cSession, __provider: 'cursor' });
|
||||||
|
if (shouldSwitchTab) {
|
||||||
|
setActiveTab('chat');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If session not found, it might be a newly created session
|
// If session not found, it might be a newly created session
|
||||||
@@ -270,6 +302,15 @@ function AppContent() {
|
|||||||
if (activeTab !== 'git' && activeTab !== 'preview') {
|
if (activeTab !== 'git' && activeTab !== 'preview') {
|
||||||
setActiveTab('chat');
|
setActiveTab('chat');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Cursor sessions, we need to set the session ID differently
|
||||||
|
// since they're persistent and not created by Claude
|
||||||
|
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||||||
|
if (provider === 'cursor') {
|
||||||
|
// Cursor sessions have persistent IDs
|
||||||
|
sessionStorage.setItem('cursorSessionId', session.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import TodoList from './TodoList';
|
import TodoList from './TodoList';
|
||||||
import ClaudeLogo from './ClaudeLogo.jsx';
|
import ClaudeLogo from './ClaudeLogo.jsx';
|
||||||
|
import CursorLogo from './CursorLogo.jsx';
|
||||||
|
|
||||||
import ClaudeStatus from './ClaudeStatus';
|
import ClaudeStatus from './ClaudeStatus';
|
||||||
import { MicButton } from './MicButton.jsx';
|
import { MicButton } from './MicButton.jsx';
|
||||||
import { api } from '../utils/api';
|
import { api, authenticatedFetch } from '../utils/api';
|
||||||
|
|
||||||
// Safe localStorage utility to handle quota exceeded errors
|
// Safe localStorage utility to handle quota exceeded errors
|
||||||
const safeLocalStorage = {
|
const safeLocalStorage = {
|
||||||
@@ -189,11 +190,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1143,6 +1148,48 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const [slashPosition, setSlashPosition] = useState(-1);
|
const [slashPosition, setSlashPosition] = useState(-1);
|
||||||
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
|
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
|
||||||
const [claudeStatus, setClaudeStatus] = useState(null);
|
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
|
// 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
|
// Actual diff calculation function
|
||||||
const calculateDiff = (oldStr, newStr) => {
|
const calculateDiff = (oldStr, newStr) => {
|
||||||
const oldLines = oldStr.split('\n');
|
const oldLines = oldStr.split('\n');
|
||||||
@@ -1349,31 +1487,47 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Load session messages when session changes
|
// Load session messages when session changes
|
||||||
const loadMessages = async () => {
|
const loadMessages = async () => {
|
||||||
if (selectedSession && selectedProject) {
|
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
|
if (provider === 'cursor') {
|
||||||
// For system-initiated changes, preserve existing messages and rely on WebSocket
|
// For Cursor, set the session ID for resuming
|
||||||
if (!isSystemSessionChange) {
|
setCurrentSessionId(selectedSession.id);
|
||||||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id);
|
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||||
setSessionMessages(messages);
|
|
||||||
// convertedMessages will be automatically updated via useMemo
|
// Load historical messages for Cursor session from SQLite
|
||||||
// Scroll to bottom after loading session messages if auto-scroll is enabled
|
const projectPath = selectedProject.fullPath || selectedProject.path;
|
||||||
if (autoScrollToBottom) {
|
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
||||||
setTimeout(() => scrollToBottom(), 200);
|
setSessionMessages([]);
|
||||||
}
|
setChatMessages(converted);
|
||||||
} else {
|
} else {
|
||||||
// Reset the flag after handling system session change
|
// For Claude, load messages normally
|
||||||
setIsSystemSessionChange(false);
|
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 {
|
} else {
|
||||||
setChatMessages([]);
|
setChatMessages([]);
|
||||||
setSessionMessages([]);
|
setSessionMessages([]);
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
|
sessionStorage.removeItem('cursorSessionId');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadMessages();
|
loadMessages();
|
||||||
}, [selectedSession, selectedProject, loadSessionMessages, scrollToBottom, isSystemSessionChange]);
|
}, [selectedSession, selectedProject, loadSessionMessages, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
|
||||||
|
|
||||||
// Update chatMessages when convertedMessages changes
|
// Update chatMessages when convertedMessages changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1441,6 +1595,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
case 'claude-response':
|
case 'claude-response':
|
||||||
const messageData = latestMessage.data.message || latestMessage.data;
|
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:
|
// Handle Claude CLI session duplication bug workaround:
|
||||||
// When resuming a session, Claude CLI creates a new session instead of resuming.
|
// 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
|
// 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;
|
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':
|
case 'claude-complete':
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setCanAbortSession(false);
|
setCanAbortSession(false);
|
||||||
@@ -2027,10 +2304,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
onSessionActive(sessionToActivate);
|
onSessionActive(sessionToActivate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tools settings from localStorage
|
// Get tools settings from localStorage based on provider
|
||||||
const getToolsSettings = () => {
|
const getToolsSettings = () => {
|
||||||
try {
|
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) {
|
if (savedSettings) {
|
||||||
return JSON.parse(savedSettings);
|
return JSON.parse(savedSettings);
|
||||||
}
|
}
|
||||||
@@ -2046,20 +2324,40 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
const toolsSettings = getToolsSettings();
|
const toolsSettings = getToolsSettings();
|
||||||
|
|
||||||
// Send command to Claude CLI via WebSocket with images
|
// Send command based on provider
|
||||||
sendMessage({
|
if (provider === 'cursor') {
|
||||||
type: 'claude-command',
|
// Send Cursor command (always use cursor-command; include resume/sessionId when replying)
|
||||||
command: input,
|
sendMessage({
|
||||||
options: {
|
type: 'cursor-command',
|
||||||
projectPath: selectedProject.path,
|
command: input,
|
||||||
cwd: selectedProject.fullPath,
|
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
resume: !!currentSessionId,
|
options: {
|
||||||
toolsSettings: toolsSettings,
|
// Prefer fullPath (actual cwd for project), fallback to path
|
||||||
permissionMode: permissionMode,
|
cwd: selectedProject.fullPath || selectedProject.path,
|
||||||
images: uploadedImages // Pass images to backend
|
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('');
|
setInput('');
|
||||||
setAttachedImages([]);
|
setAttachedImages([]);
|
||||||
@@ -2211,7 +2509,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
if (currentSessionId && canAbortSession) {
|
if (currentSessionId && canAbortSession) {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'abort-session',
|
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="chat-message assistant">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<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">
|
<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">
|
||||||
C
|
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
|
||||||
|
<CursorLogo className="w-full h-full" />
|
||||||
|
) : (
|
||||||
|
<ClaudeLogo className="w-full h-full" />
|
||||||
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Abort button removed - functionality not yet implemented at backend */}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
<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 ${
|
<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'
|
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 */}
|
{/* Provider Selection and Working Status - positioned above the input form */}
|
||||||
<ClaudeStatus
|
<div className="max-w-4xl mx-auto mb-2">
|
||||||
status={claudeStatus}
|
<div className="flex items-center justify-between gap-3">
|
||||||
isLoading={isLoading}
|
{/* Provider & Model Selection or Fixed Provider for existing session */}
|
||||||
onAbort={handleAbortSession}
|
<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 */}
|
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
|
||||||
<div className="max-w-4xl mx-auto mb-3">
|
<div className="max-w-4xl mx-auto mb-3">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
function ClaudeStatus({ status, onAbort, isLoading }) {
|
function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
const [elapsedTime, setElapsedTime] = useState(0);
|
||||||
const [animationPhase, setAnimationPhase] = useState(0);
|
const [animationPhase, setAnimationPhase] = useState(0);
|
||||||
const [fakeTokens, setFakeTokens] = useState(0);
|
const [fakeTokens, setFakeTokens] = useState(0);
|
||||||
|
|||||||
9
src/components/CursorLogo.jsx
Normal file
9
src/components/CursorLogo.jsx
Normal 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;
|
||||||
@@ -157,7 +157,7 @@ function MainContent({
|
|||||||
{activeTab === 'chat' && selectedSession ? (
|
{activeTab === 'chat' && selectedSession ? (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
|
<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>
|
</h2>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
{selectedProject.displayName} <span className="hidden sm:inline">• {selectedSession.id}</span>
|
{selectedProject.displayName} <span className="hidden sm:inline">• {selectedSession.id}</span>
|
||||||
|
|||||||
@@ -530,11 +530,16 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
{selectedSession && (
|
{selectedSession && (() => {
|
||||||
<span className="text-xs text-blue-300">
|
const displaySessionName = selectedSession.__provider === 'cursor'
|
||||||
({selectedSession.summary.slice(0, 30)}...)
|
? (selectedSession.name || 'Untitled Session')
|
||||||
</span>
|
: (selectedSession.summary || 'New Session');
|
||||||
)}
|
return (
|
||||||
|
<span className="text-xs text-blue-300">
|
||||||
|
({displaySessionName.slice(0, 30)}...)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{!selectedSession && (
|
{!selectedSession && (
|
||||||
<span className="text-xs text-gray-400">(New Session)</span>
|
<span className="text-xs text-gray-400">(New Session)</span>
|
||||||
)}
|
)}
|
||||||
@@ -601,7 +606,12 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
</button>
|
</button>
|
||||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||||
{selectedSession ?
|
{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'
|
'Start a new Claude session'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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 { 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 { cn } from '../lib/utils';
|
||||||
import ClaudeLogo from './ClaudeLogo';
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
|
import CursorLogo from './CursorLogo.jsx';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
|
||||||
// Move formatTimeAgo outside component to avoid recreation on every render
|
// 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)
|
// Helper function to get all sessions for a project (initial + additional)
|
||||||
const getAllSessions = (project) => {
|
const getAllSessions = (project) => {
|
||||||
const initialSessions = project.sessions || [];
|
// Combine Claude and Cursor sessions; Sidebar will display icon per row
|
||||||
const additional = additionalSessions[project.name] || [];
|
const claudeSessions = [...(project.sessions || []), ...(additionalSessions[project.name] || [])].map(s => ({ ...s, __provider: 'claude' }));
|
||||||
return [...initialSessions, ...additional];
|
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
|
// Helper function to get the last activity date for a project
|
||||||
@@ -979,11 +983,19 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
getAllSessions(project).map((session) => {
|
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)
|
// 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 diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60));
|
||||||
const isActive = diffInMinutes < 10;
|
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 (
|
return (
|
||||||
<div key={session.id} className="group relative">
|
<div key={session.id} className="group relative">
|
||||||
{/* Active session indicator dot */}
|
{/* Active session indicator dot */}
|
||||||
@@ -1014,38 +1026,49 @@ function Sidebar({
|
|||||||
"w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
|
"w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
|
||||||
selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
|
selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
|
||||||
)}>
|
)}>
|
||||||
<MessageSquare className={cn(
|
{isCursorSession ? (
|
||||||
"w-3 h-3",
|
<CursorLogo className="w-3 h-3" />
|
||||||
selectedSession?.id === session.id ? "text-primary" : "text-muted-foreground"
|
) : (
|
||||||
)} />
|
<ClaudeLogo className="w-3 h-3" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-xs font-medium truncate text-foreground">
|
<div className="text-xs font-medium truncate text-foreground">
|
||||||
{session.summary || 'New Session'}
|
{sessionName}
|
||||||
</div>
|
</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" />
|
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatTimeAgo(session.lastActivity, currentTime)}
|
{formatTimeAgo(sessionTime, currentTime)}
|
||||||
</span>
|
</span>
|
||||||
{session.messageCount > 0 && (
|
{messageCount > 0 && (
|
||||||
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
||||||
{session.messageCount}
|
{messageCount}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
{/* Mobile delete button */}
|
{/* Mobile delete button - only for Claude sessions */}
|
||||||
<button
|
{!isCursorSession && (
|
||||||
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"
|
<button
|
||||||
onClick={(e) => {
|
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"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
deleteSession(project.name, session.id);
|
e.stopPropagation();
|
||||||
}}
|
deleteSession(project.name, session.id);
|
||||||
onTouchEnd={handleTouchClick(() => 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>
|
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1062,26 +1085,39 @@ function Sidebar({
|
|||||||
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
|
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2 min-w-0 w-full">
|
<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="min-w-0 flex-1">
|
||||||
<div className="text-xs font-medium truncate text-foreground">
|
<div className="text-xs font-medium truncate text-foreground">
|
||||||
{session.summary || 'New Session'}
|
{sessionName}
|
||||||
</div>
|
</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" />
|
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatTimeAgo(session.lastActivity, currentTime)}
|
{formatTimeAgo(sessionTime, currentTime)}
|
||||||
</span>
|
</span>
|
||||||
{session.messageCount > 0 && (
|
{messageCount > 0 && (
|
||||||
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
||||||
{session.messageCount}
|
{messageCount}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</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">
|
<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 ? (
|
{editingSession === session.id ? (
|
||||||
<>
|
<>
|
||||||
@@ -1168,6 +1204,7 @@ function Sidebar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,7 +41,16 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
const [mcpToolsLoading, setMcpToolsLoading] = useState({});
|
const [mcpToolsLoading, setMcpToolsLoading] = useState({});
|
||||||
const [activeTab, setActiveTab] = useState('tools');
|
const [activeTab, setActiveTab] = useState('tools');
|
||||||
const [jsonValidationError, setJsonValidationError] = useState('');
|
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 = [
|
const commonTools = [
|
||||||
'Bash(git log:*)',
|
'Bash(git log:*)',
|
||||||
'Bash(git diff:*)',
|
'Bash(git diff:*)',
|
||||||
@@ -58,7 +67,45 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
'WebFetch',
|
'WebFetch',
|
||||||
'WebSearch'
|
'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
|
// MCP API functions
|
||||||
const fetchMcpServers = async () => {
|
const fetchMcpServers = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -268,7 +315,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// Load from localStorage
|
// Load Claude settings from localStorage
|
||||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
const savedSettings = localStorage.getItem('claude-tools-settings');
|
||||||
|
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
@@ -284,9 +331,27 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
setSkipPermissions(false);
|
setSkipPermissions(false);
|
||||||
setProjectSortOrder('name');
|
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
|
// Load MCP servers from API
|
||||||
await fetchMcpServers();
|
await fetchMcpServers();
|
||||||
|
|
||||||
|
// Load Cursor MCP servers
|
||||||
|
await fetchCursorMcpServers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading tool settings:', error);
|
console.error('Error loading tool settings:', error);
|
||||||
// Set defaults on error
|
// Set defaults on error
|
||||||
@@ -302,7 +367,8 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
setSaveStatus(null);
|
setSaveStatus(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = {
|
// Save Claude settings
|
||||||
|
const claudeSettings = {
|
||||||
allowedTools,
|
allowedTools,
|
||||||
disallowedTools,
|
disallowedTools,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
@@ -310,9 +376,17 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
lastUpdated: new Date().toISOString()
|
lastUpdated: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Save Cursor settings
|
||||||
|
const cursorSettings = {
|
||||||
|
allowedCommands: cursorAllowedCommands,
|
||||||
|
disallowedCommands: cursorDisallowedCommands,
|
||||||
|
skipPermissions: cursorSkipPermissions,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
// Save to localStorage
|
// 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');
|
setSaveStatus('success');
|
||||||
|
|
||||||
@@ -635,6 +709,36 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
{activeTab === 'tools' && (
|
{activeTab === 'tools' && (
|
||||||
<div className="space-y-6 md:space-y-8">
|
<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 */}
|
{/* Skip Permissions */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1360,6 +1464,216 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user