From d3a522f3e87cb22af091cba0243a0e3253b56397 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sat, 13 Dec 2025 22:44:42 +0800 Subject: [PATCH] feat: add support for Claude CLI tool and enhance memory features - Added new CLI tool "Claude" with command handling in cli-executor.ts. - Implemented session discovery for Claude in native-session-discovery.ts. - Enhanced memory view with active memory controls, including sync functionality and configuration options. - Introduced zoom and fit view controls for memory graph visualization. - Updated i18n.js for new memory-related translations. - Improved error handling and migration for CLI history store. --- ccw/src/cli.ts | 4 +- ccw/src/commands/memory.ts | 11 +- ccw/src/core/dashboard-generator-patch.ts | 1 + ccw/src/core/server.ts | 628 +++++++++++++++--- .../templates/dashboard-css/07-managers.css | 235 +++++++ ccw/src/templates/dashboard-css/10-cli.css | 23 + ccw/src/templates/dashboard-css/11-memory.css | 415 +++++++++++- .../dashboard-js/components/cli-status.js | 12 +- .../components/task-queue-sidebar.js | 277 +++++++- ccw/src/templates/dashboard-js/i18n.js | 46 ++ .../templates/dashboard-js/views/explorer.js | 18 +- .../templates/dashboard-js/views/memory.js | 501 ++++++++++++-- ccw/src/tools/cli-executor.ts | 31 +- ccw/src/tools/cli-history-store.ts | 28 +- ccw/src/tools/native-session-discovery.ts | 94 ++- 15 files changed, 2087 insertions(+), 237 deletions(-) diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index 1ec4a468..f4a8bfae 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -151,12 +151,12 @@ export function run(argv: string[]): void { // CLI command program .command('cli [subcommand] [args...]') - .description('Unified CLI tool executor (gemini/qwen/codex)') + .description('Unified CLI tool executor (gemini/qwen/codex/claude)') .option('--tool ', 'CLI tool to use', 'gemini') .option('--mode ', 'Execution mode: analysis, write, auto', 'analysis') .option('--model ', 'Model override') .option('--cd ', 'Working directory') - .option('--includeDirs ', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex)') + .option('--includeDirs ', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex/claude)') .option('--timeout ', 'Timeout in milliseconds', '300000') .option('--no-stream', 'Disable streaming output') .option('--limit ', 'History limit') diff --git a/ccw/src/commands/memory.ts b/ccw/src/commands/memory.ts index a11b3e61..4d484eba 100644 --- a/ccw/src/commands/memory.ts +++ b/ccw/src/commands/memory.ts @@ -79,10 +79,11 @@ function normalizePath(filePath: string): string { } /** - * Get project path from current working directory + * Get project path from hook data or current working directory */ -function getProjectPath(): string { - return process.cwd(); +function getProjectPath(hookCwd?: string): string { + // Prefer hook's cwd (actual project workspace) over process.cwd() + return hookCwd || process.cwd(); } /** @@ -90,6 +91,7 @@ function getProjectPath(): string { */ async function trackAction(options: TrackOptions): Promise { let { type, action, value, session, stdin } = options; + let hookCwd: string | undefined; // If --stdin flag is set, read from stdin (Claude Code hook format) if (stdin) { @@ -98,6 +100,7 @@ async function trackAction(options: TrackOptions): Promise { if (stdinData) { const hookData = JSON.parse(stdinData); session = hookData.session_id || session; + hookCwd = hookData.cwd; // Extract workspace path from hook // Extract value based on hook event if (hookData.tool_input) { @@ -151,7 +154,7 @@ async function trackAction(options: TrackOptions): Promise { } try { - const projectPath = getProjectPath(); + const projectPath = getProjectPath(hookCwd); const store = getMemoryStore(projectPath); const now = new Date().toISOString(); diff --git a/ccw/src/core/dashboard-generator-patch.ts b/ccw/src/core/dashboard-generator-patch.ts index 52e41403..20156378 100644 --- a/ccw/src/core/dashboard-generator-patch.ts +++ b/ccw/src/core/dashboard-generator-patch.ts @@ -27,6 +27,7 @@ const MODULE_FILES = [ 'dashboard-js/components/mcp-manager.js', 'dashboard-js/components/hook-manager.js', 'dashboard-js/components/version-check.js', + 'dashboard-js/components/task-queue-sidebar.js', // Views 'dashboard-js/views/home.js', 'dashboard-js/views/project-overview.js', diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index c31f740d..692418b7 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -917,109 +917,6 @@ export async function startServer(options: ServerOptions = {}): Promise e.type === type); - } - - // Sort by field - if (sort === 'reads') { - hotEntities.sort((a, b) => b.stats.read_count - a.stats.read_count); - } else if (sort === 'writes') { - hotEntities.sort((a, b) => b.stats.write_count - a.stats.write_count); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - items: hotEntities.map(e => ({ - value: e.value, - type: e.type, - read_count: e.stats.read_count, - write_count: e.stats.write_count, - mention_count: e.stats.mention_count, - heat_score: e.stats.heat_score - })) - })); - } catch (error: unknown) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: (error as Error).message })); - } - return; - } - - // API: Memory Module - Get association graph - if (pathname === '/api/memory/graph') { - const projectPath = url.searchParams.get('path') || initialPath; - const center = url.searchParams.get('center'); - const depth = parseInt(url.searchParams.get('depth') || '1', 10); - - if (!center) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'center parameter is required' })); - return; - } - - try { - const memoryStore = getMemoryStore(projectPath); - - // Find the center entity (assume it's a file for now) - const entity = memoryStore.getEntity('file', center); - if (!entity) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Entity not found' })); - return; - } - - // Get associations - const associations = memoryStore.getAssociations(entity.id!, 20); - const stats = memoryStore.getStats(entity.id!); - - // Build graph structure - const nodes = [ - { - id: entity.id!.toString(), - label: entity.value, - type: entity.type, - heat: stats?.heat_score || 0 - } - ]; - - const links = []; - for (const assoc of associations) { - nodes.push({ - id: assoc.target.id!.toString(), - label: assoc.target.value, - type: assoc.target.type, - heat: 0 - }); - - links.push({ - source: entity.id!.toString(), - target: assoc.target.id!.toString(), - weight: assoc.weight - }); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ nodes, links })); - } catch (error: unknown) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: (error as Error).message })); - } - return; - } - // API: Memory Module - Track entity access if (pathname === '/api/memory/track' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { @@ -1245,7 +1142,7 @@ export async function startServer(options: ServerOptions = {}): Promise { const projectPath = body.path || initialPath; - const tool = body.tool || 'gemini'; // gemini, qwen, codex + const tool = body.tool || 'gemini'; // gemini, qwen, codex, claude const prompts = body.prompts || []; const lang = body.lang || 'en'; // Language preference @@ -1345,6 +1242,527 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p return; } + // API: Memory Module - Get hotspot statistics + if (pathname === '/api/memory/stats') { + const projectPath = url.searchParams.get('path') || initialPath; + const filter = url.searchParams.get('filter') || 'all'; // today, week, all + const limit = parseInt(url.searchParams.get('limit') || '10', 10); + + try { + const memoryStore = getMemoryStore(projectPath); + const hotEntities = memoryStore.getHotEntities(limit * 4); + + // Filter by time if needed + let filtered = hotEntities; + if (filter === 'today') { + const today = new Date(); + today.setHours(0, 0, 0, 0); + filtered = hotEntities.filter((e: any) => new Date(e.last_seen_at) >= today); + } else if (filter === 'week') { + const weekAgo = new Date(); + weekAgo.setDate(weekAgo.getDate() - 7); + filtered = hotEntities.filter((e: any) => new Date(e.last_seen_at) >= weekAgo); + } + + // Separate into mostRead and mostEdited + const fileEntities = filtered.filter((e: any) => e.type === 'file'); + + const mostRead = fileEntities + .filter((e: any) => e.stats.read_count > 0) + .sort((a: any, b: any) => b.stats.read_count - a.stats.read_count) + .slice(0, limit) + .map((e: any) => ({ + path: e.value, + file: e.value.split(/[/\\]/).pop(), + heat: e.stats.read_count, + count: e.stats.read_count, + lastSeen: e.last_seen_at + })); + + const mostEdited = fileEntities + .filter((e: any) => e.stats.write_count > 0) + .sort((a: any, b: any) => b.stats.write_count - a.stats.write_count) + .slice(0, limit) + .map((e: any) => ({ + path: e.value, + file: e.value.split(/[/\\]/).pop(), + heat: e.stats.write_count, + count: e.stats.write_count, + lastSeen: e.last_seen_at + })); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ stats: { mostRead, mostEdited } })); + } catch (error: unknown) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ stats: { mostRead: [], mostEdited: [] } })); + } + return; + } + + // API: Memory Module - Get memory graph (file associations with modules and components) + if (pathname === '/api/memory/graph') { + const projectPath = url.searchParams.get('path') || initialPath; + + try { + const memoryStore = getMemoryStore(projectPath); + const hotEntities = memoryStore.getHotEntities(100); + + // Build file nodes from entities + const fileEntities = hotEntities.filter((e: any) => e.type === 'file'); + const fileNodes = fileEntities.map((e: any) => { + const fileName = e.value.split(/[/\\]/).pop() || ''; + // Detect component type based on file name patterns + const isComponent = /\.(tsx|jsx|vue|svelte)$/.test(fileName) || + /^[A-Z][a-zA-Z]+\.(ts|js)$/.test(fileName) || + fileName.includes('.component.') || + fileName.includes('.controller.'); + + return { + id: e.value, + name: fileName, + path: e.value, + type: isComponent ? 'component' : 'file', + heat: Math.min(25, 8 + e.stats.heat_score / 10) + }; + }); + + // Extract unique modules (directories) from file paths + const moduleMap = new Map(); + for (const file of fileEntities) { + const parts = file.value.split(/[/\\]/); + // Get parent directory as module (skip if root level) + if (parts.length > 1) { + const modulePath = parts.slice(0, -1).join('/'); + const moduleName = parts[parts.length - 2] || modulePath; + // Skip common non-module directories + if (['node_modules', '.git', 'dist', 'build', '.next', '.nuxt'].includes(moduleName)) continue; + + if (!moduleMap.has(modulePath)) { + moduleMap.set(modulePath, { heat: 0, files: [] }); + } + const mod = moduleMap.get(modulePath)!; + mod.heat += file.stats.heat_score / 20; + mod.files.push(file.value); + } + } + + // Create module nodes (limit to top modules by heat) + const moduleNodes = Array.from(moduleMap.entries()) + .sort((a, b) => b[1].heat - a[1].heat) + .slice(0, 15) + .map(([modulePath, data]) => ({ + id: modulePath, + name: modulePath.split(/[/\\]/).pop() || modulePath, + path: modulePath, + type: 'module', + heat: Math.min(20, 12 + data.heat / 5), + fileCount: data.files.length + })); + + // Combine all nodes + const nodes = [...fileNodes, ...moduleNodes]; + const nodeIds = new Set(nodes.map(n => n.id)); + + // Build edges from associations + const edges: any[] = []; + const edgeSet = new Set(); // Prevent duplicate edges + + // Add file-to-file associations + for (const entity of hotEntities) { + if (!entity.id || entity.type !== 'file') continue; + const associations = memoryStore.getAssociations(entity.id, 10); + for (const assoc of associations) { + if (assoc.target && nodeIds.has(assoc.target.value)) { + const edgeKey = [entity.value, assoc.target.value].sort().join('|'); + if (!edgeSet.has(edgeKey)) { + edgeSet.add(edgeKey); + edges.push({ + source: entity.value, + target: assoc.target.value, + weight: assoc.weight + }); + } + } + } + } + + // Add file-to-module edges (files belong to their parent modules) + for (const [modulePath, data] of moduleMap.entries()) { + if (!nodeIds.has(modulePath)) continue; + for (const filePath of data.files) { + if (nodeIds.has(filePath)) { + const edgeKey = [modulePath, filePath].sort().join('|'); + if (!edgeSet.has(edgeKey)) { + edgeSet.add(edgeKey); + edges.push({ + source: modulePath, + target: filePath, + weight: 2 // Lower weight for structural relationships + }); + } + } + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ graph: { nodes, edges } })); + } catch (error: unknown) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ graph: { nodes: [], edges: [] } })); + } + return; + } + + // API: Memory Module - Get recent context activities + if (pathname === '/api/memory/recent') { + const projectPath = url.searchParams.get('path') || initialPath; + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + + try { + const memoryStore = getMemoryStore(projectPath); + + // Get recent access logs with entity info - filter to file type only + const db = (memoryStore as any).db; + const recentLogs = db.prepare(` + SELECT a.*, e.type, e.value + FROM access_logs a + JOIN entities e ON a.entity_id = e.id + WHERE e.type = 'file' + ORDER BY a.timestamp DESC + LIMIT ? + `).all(limit * 2) as any[]; // Fetch more to account for filtering + + // Filter out invalid entries (JSON strings, error messages, etc.) + const validLogs = recentLogs.filter((log: any) => { + const value = log.value || ''; + // Skip if value looks like JSON or contains error-like patterns + if (value.includes('"status"') || value.includes('"content"') || + value.includes('"activeForm"') || value.startsWith('{') || + value.startsWith('[') || value.includes('graph 400')) { + return false; + } + // Must have a file extension or look like a valid path + const hasExtension = /\.[a-zA-Z0-9]{1,10}$/.test(value); + const looksLikePath = value.includes('/') || value.includes('\\'); + return hasExtension || looksLikePath; + }).slice(0, limit); + + const recent = validLogs.map((log: any) => ({ + type: log.action, // read, write, mention + timestamp: log.timestamp, + prompt: log.context_summary || '', + files: [log.value], + description: `${log.action}: ${log.value.split(/[/\\]/).pop()}` + })); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ recent })); + } catch (error: unknown) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ recent: [] })); + } + return; + } + + // API: Active Memory - Get status + if (pathname === '/api/memory/active/status') { + const projectPath = url.searchParams.get('path') || initialPath; + + try { + const configPath = path.join(projectPath, '.claude', 'rules', 'active_memory.md'); + const configJsonPath = path.join(projectPath, '.claude', 'rules', 'active_memory_config.json'); + const enabled = fs.existsSync(configPath); + let lastSync: string | null = null; + let fileCount = 0; + let config = { interval: 'manual', tool: 'gemini' }; + + if (enabled) { + const stats = fs.statSync(configPath); + lastSync = stats.mtime.toISOString(); + const content = fs.readFileSync(configPath, 'utf-8'); + // Count file sections + fileCount = (content.match(/^## /gm) || []).length; + } + + // Load config if exists + if (fs.existsSync(configJsonPath)) { + try { + config = JSON.parse(fs.readFileSync(configJsonPath, 'utf-8')); + } catch (e) { /* ignore parse errors */ } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + enabled, + status: enabled ? { lastSync, fileCount } : null, + config + })); + } catch (error: unknown) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ enabled: false, status: null, config: { interval: 'manual', tool: 'gemini' } })); + } + return; + } + + // API: Active Memory - Toggle + if (pathname === '/api/memory/active/toggle' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + req.on('end', async () => { + try { + const { enabled, config } = JSON.parse(body || '{}'); + const projectPath = initialPath; + const rulesDir = path.join(projectPath, '.claude', 'rules'); + const configPath = path.join(rulesDir, 'active_memory.md'); + const configJsonPath = path.join(rulesDir, 'active_memory_config.json'); + + if (enabled) { + // Enable: Create directory and initial file + if (!fs.existsSync(rulesDir)) { + fs.mkdirSync(rulesDir, { recursive: true }); + } + + // Save config + if (config) { + fs.writeFileSync(configJsonPath, JSON.stringify(config, null, 2), 'utf-8'); + } + + // Create initial active_memory.md with header + const initialContent = `# Active Memory + +> Auto-generated understanding of frequently accessed files. +> Last updated: ${new Date().toISOString()} + +--- + +*No files analyzed yet. Click "Sync Now" to analyze hot files.* +`; + fs.writeFileSync(configPath, initialContent, 'utf-8'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ enabled: true, message: 'Active Memory enabled' })); + } else { + // Disable: Remove the files + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } + if (fs.existsSync(configJsonPath)) { + fs.unlinkSync(configJsonPath); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ enabled: false, message: 'Active Memory disabled' })); + } + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + }); + return; + } + + // API: Active Memory - Update Config + if (pathname === '/api/memory/active/config' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + req.on('end', async () => { + try { + const { config } = JSON.parse(body || '{}'); + const projectPath = initialPath; + const rulesDir = path.join(projectPath, '.claude', 'rules'); + const configJsonPath = path.join(rulesDir, 'active_memory_config.json'); + + if (!fs.existsSync(rulesDir)) { + fs.mkdirSync(rulesDir, { recursive: true }); + } + + fs.writeFileSync(configJsonPath, JSON.stringify(config, null, 2), 'utf-8'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, config })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + }); + return; + } + + // API: Active Memory - Sync (analyze hot files using CLI and update active_memory.md) + if (pathname === '/api/memory/active/sync' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + req.on('end', async () => { + try { + const { tool = 'gemini' } = JSON.parse(body || '{}'); + const projectPath = initialPath; + const rulesDir = path.join(projectPath, '.claude', 'rules'); + const configPath = path.join(rulesDir, 'active_memory.md'); + + // Get hot files from memory store + const memoryStore = getMemoryStore(projectPath); + const hotEntities = memoryStore.getHotEntities(20); + const hotFiles = hotEntities + .filter((e: any) => e.type === 'file') + .slice(0, 10); // Limit to top 10 files + + // Build file list for CLI analysis + const filePaths = hotFiles.map((f: any) => { + const filePath = f.value; + return path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath); + }).filter((p: string) => fs.existsSync(p)); + + // Build the active memory content header + let content = `# Active Memory + +> Auto-generated understanding of frequently accessed files using ${tool.toUpperCase()}. +> Last updated: ${new Date().toISOString()} +> Files analyzed: ${hotFiles.length} +> CLI Tool: ${tool} + +--- + +`; + + // Use CLI to analyze files if available + const { spawn } = require('child_process'); + let cliOutput = ''; + + // Build CLI command based on tool + const cliPrompt = `PURPOSE: Analyze the following hot files and provide a concise understanding of each. +TASK: For each file, describe its purpose, key exports, dependencies, and how it relates to other files. +MODE: analysis +CONTEXT: ${filePaths.map((p: string) => '@' + p).join(' ')} +EXPECTED: Markdown format with ## headings for each file, bullet points for key information. +RULES: Be concise. Focus on practical understanding. Include function signatures for key exports.`; + + const cliCmd = tool === 'qwen' ? 'qwen' : 'gemini'; + const cliArgs = ['-p', cliPrompt]; + + // Try to execute CLI + try { + const cliProcess = spawn(cliCmd, cliArgs, { + cwd: projectPath, + shell: true, + timeout: 120000 // 2 minute timeout + }); + + cliOutput = await new Promise((resolve, reject) => { + let output = ''; + let errorOutput = ''; + + cliProcess.stdout?.on('data', (data: Buffer) => { + output += data.toString(); + }); + + cliProcess.stderr?.on('data', (data: Buffer) => { + errorOutput += data.toString(); + }); + + cliProcess.on('close', (code: number) => { + if (code === 0 || output.length > 100) { + resolve(output); + } else { + reject(new Error(errorOutput || 'CLI execution failed')); + } + }); + + cliProcess.on('error', (err: Error) => { + reject(err); + }); + + // Timeout fallback + setTimeout(() => { + if (output.length > 0) { + resolve(output); + } else { + reject(new Error('CLI timeout')); + } + }, 120000); + }); + + // Add CLI output to content + content += cliOutput + '\n\n---\n\n'; + + } catch (cliErr) { + // Fallback to basic analysis if CLI fails + console.warn('[Active Memory] CLI analysis failed, using basic analysis:', (cliErr as Error).message); + + // Basic analysis fallback + for (const file of hotFiles) { + const fileName = file.value.split(/[/\\]/).pop() || file.value; + const filePath = file.value; + const heat = file.stats?.heat_score || 0; + const readCount = file.stats?.read_count || 0; + const writeCount = file.stats?.write_count || 0; + + content += `## ${fileName} + +- **Path**: \`${filePath}\` +- **Heat Score**: ${heat} +- **Access**: ${readCount} reads, ${writeCount} writes +- **Last Seen**: ${file.last_seen_at || 'Unknown'} + +`; + + // Try to read file and generate summary + try { + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath); + + if (fs.existsSync(fullPath)) { + const stat = fs.statSync(fullPath); + const ext = path.extname(fullPath).toLowerCase(); + + content += `- **Size**: ${(stat.size / 1024).toFixed(1)} KB\n`; + content += `- **Type**: ${ext || 'unknown'}\n`; + + const textExts = ['.ts', '.js', '.tsx', '.jsx', '.md', '.json', '.css', '.html', '.vue', '.svelte', '.py', '.go', '.rs']; + if (textExts.includes(ext) && stat.size < 100000) { + const fileContent = fs.readFileSync(fullPath, 'utf-8'); + const lines = fileContent.split('\n').slice(0, 30); + + const exports = lines.filter(l => + l.includes('export ') || l.includes('function ') || + l.includes('class ') || l.includes('interface ') + ).slice(0, 8); + + if (exports.length > 0) { + content += `\n**Key Exports**:\n\`\`\`\n${exports.join('\n')}\n\`\`\`\n`; + } + } + } + } catch (fileErr) { + // Skip file analysis errors + } + + content += '\n---\n\n'; + } + } + + // Ensure directory exists + if (!fs.existsSync(rulesDir)) { + fs.mkdirSync(rulesDir, { recursive: true }); + } + + // Write the file + fs.writeFileSync(configPath, content, 'utf-8'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + filesAnalyzed: hotFiles.length, + path: configPath, + usedCli: cliOutput.length > 0 + })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + }); + return; + } + // API: Memory Module - Get conversations index if (pathname === '/api/memory/conversations') { const projectPath = url.searchParams.get('path') || initialPath; @@ -2985,7 +3403,7 @@ async function getFileContent(filePath) { /** * Trigger update-module-claude tool (async execution) * @param {string} targetPath - Directory path to update - * @param {string} tool - CLI tool to use (gemini, qwen, codex) + * @param {string} tool - CLI tool to use (gemini, qwen, codex, claude) * @param {string} strategy - Update strategy (single-layer, multi-layer) * @returns {Promise} */ diff --git a/ccw/src/templates/dashboard-css/07-managers.css b/ccw/src/templates/dashboard-css/07-managers.css index 2e1db4ab..bdcc1194 100644 --- a/ccw/src/templates/dashboard-css/07-managers.css +++ b/ccw/src/templates/dashboard-css/07-managers.css @@ -1863,3 +1863,238 @@ } } +/* ========================================== + UPDATE TASKS SECTION - In CLI Tab + ========================================== */ + +/* Section Container */ +.update-tasks-section { + border-bottom: 1px solid hsl(var(--border)); + padding-bottom: 12px; + margin-bottom: 12px; +} + +.update-tasks-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + margin-bottom: 8px; +} + +.update-tasks-title { + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.update-tasks-clear-btn { + width: 28px; + height: 28px; + border: none; + background: transparent; + color: hsl(var(--muted-foreground)); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.update-tasks-clear-btn:hover { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); +} + +.update-tasks-list { + padding: 0 12px; +} + +.update-tasks-empty { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px; + text-align: center; + color: hsl(var(--muted-foreground)); +} + +.update-tasks-empty span { + font-size: 0.8125rem; + font-weight: 500; +} + +.update-tasks-empty p { + font-size: 0.75rem; + margin: 4px 0 0; +} + +/* CLI History Section */ +.cli-history-section { + padding-top: 4px; +} + +.cli-history-header { + display: flex; + align-items: center; + padding: 8px 12px; + margin-bottom: 8px; +} + +.cli-history-title { + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.cli-history-list { + padding: 0 12px; +} + +/* Individual Update Task Item */ +.update-task-item { + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 8px; + transition: all 0.15s ease; +} + +.update-task-item:last-child { + margin-bottom: 0; +} + +.update-task-item.status-pending { + border-left: 3px solid hsl(var(--muted-foreground)); +} + +.update-task-item.status-running { + border-left: 3px solid hsl(var(--warning)); + background: hsl(var(--warning) / 0.05); +} + +.update-task-item.status-completed { + border-left: 3px solid hsl(var(--success)); + background: hsl(var(--success) / 0.05); + opacity: 0.8; +} + +.update-task-item.status-failed { + border-left: 3px solid hsl(var(--destructive)); + background: hsl(var(--destructive) / 0.05); +} + +.update-task-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.update-task-status { + font-size: 14px; + flex-shrink: 0; +} + +.update-task-item.status-running .update-task-status { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.update-task-name { + flex: 1; + font-size: 0.8125rem; + font-weight: 500; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.update-task-strategy { + font-size: 14px; + flex-shrink: 0; +} + +.update-task-controls { + display: flex; + align-items: center; + gap: 6px; +} + +.update-task-cli-select { + height: 28px; + padding: 0 8px; + font-size: 0.75rem; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + cursor: pointer; + transition: all 0.15s; +} + +.update-task-cli-select:hover:not(:disabled) { + border-color: hsl(var(--primary)); +} + +.update-task-cli-select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.update-task-btn { + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.update-task-start { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.update-task-start:hover { + background: hsl(var(--primary) / 0.9); + transform: scale(1.05); +} + +.update-task-remove { + background: transparent; + color: hsl(var(--muted-foreground)); +} + +.update-task-remove:hover { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); +} + +.update-task-stop { + background: hsl(var(--warning)); + color: white; +} + +.update-task-stop:hover { + background: hsl(var(--warning) / 0.9); +} + +.update-task-message { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid hsl(var(--border)); +} + diff --git a/ccw/src/templates/dashboard-css/10-cli.css b/ccw/src/templates/dashboard-css/10-cli.css index a1acfc11..0458a304 100644 --- a/ccw/src/templates/dashboard-css/10-cli.css +++ b/ccw/src/templates/dashboard-css/10-cli.css @@ -484,6 +484,11 @@ color: hsl(142 71% 35%); } +.history-tool-tag.tool-claude { + background: hsl(25 90% 50% / 0.12); + color: hsl(25 90% 40%); +} + .history-mode-tag { font-size: 0.625rem; font-weight: 500; @@ -713,6 +718,14 @@ border-color: hsl(142 71% 45% / 0.7); } +.cli-tool-card.tool-claude.available { + border-color: hsl(25 90% 50% / 0.5); +} + +.cli-tool-card.tool-claude.available:hover { + border-color: hsl(25 90% 50% / 0.7); +} + .cli-tool-card.unavailable { border-color: hsl(var(--border)); opacity: 0.6; @@ -1006,6 +1019,11 @@ color: hsl(142 71% 35%); } +.cli-tool-claude { + background: hsl(25 90% 50% / 0.12); + color: hsl(25 90% 40%); +} + .cli-history-time { font-size: 0.6875rem; color: hsl(var(--muted-foreground)); @@ -3187,6 +3205,11 @@ color: hsl(145 60% 35%); } +.cli-queue-tool-tag.cli-tool-claude { + background: hsl(25 90% 50% / 0.15); + color: hsl(25 90% 40%); +} + .cli-queue-status { font-size: 0.75rem; } diff --git a/ccw/src/templates/dashboard-css/11-memory.css b/ccw/src/templates/dashboard-css/11-memory.css index 8390afee..eb565df0 100644 --- a/ccw/src/templates/dashboard-css/11-memory.css +++ b/ccw/src/templates/dashboard-css/11-memory.css @@ -9,6 +9,10 @@ .memory-view { height: 100%; min-height: 600px; + max-height: calc(100vh - 150px); + overflow: hidden; + display: flex; + flex-direction: column; } .memory-view.loading { @@ -18,11 +22,241 @@ justify-content: center; } +/* Memory Header with Active Memory Toggle */ +.memory-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0; + margin-bottom: 1rem; + border-bottom: 1px solid hsl(var(--border)); + flex-shrink: 0; +} + +.memory-header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.memory-header-left h2 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.125rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.memory-header-right { + display: flex; + align-items: center; + gap: 1rem; +} + +/* Active Memory Controls Container */ +.active-memory-controls { + display: flex; + align-items: center; + gap: 1rem; +} + +/* Active Memory Toggle */ +.active-memory-toggle { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Active Memory Config */ +.active-memory-config { + display: flex; + align-items: center; + gap: 0.75rem; + padding-left: 0.75rem; + border-left: 1px solid hsl(var(--border)); +} + +.config-item { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.config-item label { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + white-space: nowrap; +} + +.config-item select { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + color: hsl(var(--foreground)); + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.25rem; + cursor: pointer; + outline: none; + min-width: 80px; +} + +.config-item select:focus { + border-color: hsl(var(--primary)); +} + +.config-item select:hover { + border-color: hsl(var(--primary) / 0.5); +} + +/* Active Memory Actions */ +.active-memory-actions { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.75rem; + border-left: 1px solid hsl(var(--border)); +} + +.last-sync { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + white-space: nowrap; +} + +.toggle-label { + font-size: 0.8125rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: hsl(var(--muted)); + border-radius: 24px; + transition: all 0.3s ease; +} + +.toggle-slider::before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + border-radius: 50%; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle-switch input:checked + .toggle-slider { + background-color: hsl(var(--primary)); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} + +.toggle-switch input:focus + .toggle-slider { + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3); +} + +.toggle-status { + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + background: hsl(var(--muted) / 0.5); +} + +.toggle-status.active { + display: flex; + align-items: center; + gap: 0.25rem; + color: hsl(142 76% 36%); + background: hsl(142 76% 36% / 0.1); +} + +/* Auto-sync indicator */ +.auto-sync-indicator { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.6875rem; + color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.1); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + white-space: nowrap; +} + +.auto-sync-indicator svg { + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Sync Button */ +.btn-sync { + padding: 0.5rem; + border-radius: 0.375rem; + background: hsl(var(--primary) / 0.1); + border: 1px solid hsl(var(--primary) / 0.3); + color: hsl(var(--primary)); + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-sync:hover { + background: hsl(var(--primary) / 0.2); + border-color: hsl(var(--primary)); +} + +.btn-sync.syncing { + opacity: 0.7; + cursor: not-allowed; +} + +.btn-sync.syncing i { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .memory-columns { display: grid; grid-template-columns: 280px 1fr 320px; gap: 1.5rem; - height: 100%; + flex: 1; + min-height: 0; + max-height: calc(100vh - 230px); } .memory-column { @@ -33,6 +267,7 @@ border-radius: 0.75rem; overflow: hidden; min-width: 0; + max-height: 100%; } /* Memory Section inside columns */ @@ -40,6 +275,7 @@ display: flex; flex-direction: column; height: 100%; + overflow: hidden; } .section-header { @@ -110,10 +346,15 @@ flex: 1; overflow-y: auto; padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; } .hotspot-list-container { - margin-bottom: 1rem; + display: flex; + flex-direction: column; } .hotspot-list-container:last-child { @@ -130,6 +371,7 @@ margin: 0 0 0.5rem 0; padding-bottom: 0.375rem; border-bottom: 1px solid hsl(var(--border)); + flex-shrink: 0; } /* Hotspot List Items */ @@ -267,8 +509,8 @@ } .legend-dot.file { background: hsl(var(--primary)); } -.legend-dot.module { background: hsl(var(--muted-foreground)); } -.legend-dot.component { background: hsl(var(--success)); } +.legend-dot.module { background: hsl(var(--muted-foreground)); border: 1px dashed hsl(var(--muted-foreground)); } +.legend-dot.component { background: hsl(142 76% 36%); } /* Graph Container */ .graph-container, @@ -277,9 +519,11 @@ position: relative; background: hsl(var(--background)); min-height: 300px; + max-height: 100%; display: flex; align-items: center; justify-content: center; + overflow: hidden; } .memory-graph-container svg { @@ -425,8 +669,10 @@ flex: 1; overflow-y: auto; padding: 0.75rem; + min-height: 0; } +/* Context Timeline Card Style */ .timeline-item { display: flex; gap: 0.75rem; @@ -436,10 +682,21 @@ border: 1px solid hsl(var(--border)); border-radius: 0.5rem; transition: all 0.15s ease; + cursor: pointer; + min-height: 60px; + max-height: 120px; + overflow: hidden; } .timeline-item:hover { border-color: hsl(var(--primary) / 0.3); + background: hsl(var(--hover)); +} + +.timeline-item.expanded { + max-height: none; + background: hsl(var(--muted) / 0.3); + border-color: hsl(var(--primary) / 0.5); } .timeline-item:last-child { @@ -641,6 +898,12 @@ .memory-columns { grid-template-columns: 1fr; gap: 1rem; + max-height: none; + overflow-y: auto; + } + + .memory-column { + max-height: 500px; } } @@ -654,6 +917,7 @@ border: 1px solid hsl(var(--border)); border-radius: 0.75rem; overflow: hidden; + max-height: 100%; } .hotspots-header { @@ -683,6 +947,7 @@ flex: 1; overflow-y: auto; padding: 0.5rem; + min-height: 0; } .hotspot-item { @@ -793,6 +1058,7 @@ border: 1px solid hsl(var(--border)); border-radius: 0.75rem; overflow: hidden; + max-height: 100%; } .graph-header { @@ -828,7 +1094,9 @@ flex: 1; position: relative; background: hsl(var(--background)); - min-height: 400px; + min-height: 300px; + max-height: 100%; + overflow: hidden; } /* D3 Graph Elements */ @@ -943,6 +1211,111 @@ } } +/* Graph Zoom/Pan Styles */ +.memory-graph-svg { + cursor: grab; +} + +.memory-graph-svg:active { + cursor: grabbing; +} + +.graph-content { + transition: transform 0.1s ease-out; +} + +/* Graph Node Groups */ +.graph-node-group { + cursor: pointer; +} + +.graph-node-group:hover circle { + filter: brightness(1.2); +} + +.graph-node-group.file circle { + fill: hsl(var(--primary)); + stroke: hsl(var(--primary)); + stroke-width: 2; +} + +.graph-node-group.module circle { + fill: hsl(var(--muted)); + stroke: hsl(var(--muted-foreground)); + stroke-width: 2; + stroke-dasharray: 4 2; +} + +.graph-node-group.component circle { + fill: hsl(142 76% 36%); + stroke: hsl(142 76% 36%); + stroke-width: 2; +} + +/* Individual graph-node circles (for legacy support) */ +.graph-node.file { + fill: hsl(var(--primary)); + stroke: hsl(var(--primary)); + stroke-width: 2; +} + +.graph-node.module { + fill: hsl(var(--muted)); + stroke: hsl(var(--muted-foreground)); + stroke-width: 2; + stroke-dasharray: 4 2; +} + +.graph-node.component { + fill: hsl(142 76% 36%); + stroke: hsl(142 76% 36%); + stroke-width: 2; +} + +/* Selected Node */ +.graph-node.selected { + stroke: hsl(var(--foreground)); + stroke-width: 3; + filter: drop-shadow(0 0 8px hsl(var(--primary))); +} + +/* Graph Labels */ +.graph-label { + font-family: var(--font-sans); + font-size: 11px; + fill: hsl(var(--foreground)); + pointer-events: none; + user-select: none; + text-shadow: + 1px 1px 2px hsl(var(--background)), + -1px -1px 2px hsl(var(--background)), + 1px -1px 2px hsl(var(--background)), + -1px 1px 2px hsl(var(--background)); +} + +/* Graph Controls */ +.graph-controls { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.graph-controls .btn-icon { + padding: 0.375rem; + border-radius: 0.375rem; + background: transparent; + border: 1px solid transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.15s ease; +} + +.graph-controls .btn-icon:hover { + background: hsl(var(--muted)); + border-color: hsl(var(--border)); + color: hsl(var(--foreground)); +} + /* ======================================== * Context Timeline (Right Column) * ======================================== */ @@ -953,6 +1326,7 @@ border: 1px solid hsl(var(--border)); border-radius: 0.75rem; overflow: hidden; + max-height: 100%; } .context-header { @@ -982,37 +1356,10 @@ flex: 1; overflow-y: auto; padding: 0.75rem; + min-height: 0; } -.timeline-item { - position: relative; - padding-left: 1.5rem; - padding-bottom: 1rem; - border-left: 2px solid hsl(var(--border)); -} - -.timeline-item:last-child { - border-left-color: transparent; - padding-bottom: 0; -} - -.timeline-item::before { - content: ''; - position: absolute; - left: -6px; - top: 0; - width: 10px; - height: 10px; - border-radius: 50%; - background: hsl(var(--primary)); - border: 2px solid hsl(var(--card)); -} - -.timeline-item.recent::before { - background: hsl(0 84% 60%); - box-shadow: 0 0 8px hsl(0 84% 60% / 0.5); - animation: timelinePulse 2s infinite; -} +/* Timeline item styles moved to Context Timeline Card Style section (line ~445) */ .timeline-timestamp { font-size: 0.6875rem; diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index bde3eebe..4b9fc230 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -2,7 +2,7 @@ // Displays CLI tool availability status and allows setting default tool // ========== CLI State ========== -let cliToolStatus = { gemini: {}, qwen: {}, codex: {} }; +let cliToolStatus = { gemini: {}, qwen: {}, codex: {}, claude: {} }; let codexLensStatus = { ready: false }; let semanticStatus = { available: false }; let defaultCliTool = 'gemini'; @@ -105,16 +105,18 @@ function renderCliStatus() { const toolDescriptions = { gemini: 'Google AI for code analysis', qwen: 'Alibaba AI assistant', - codex: 'OpenAI code generation' + codex: 'OpenAI code generation', + claude: 'Anthropic AI assistant' }; const toolIcons = { gemini: 'sparkle', qwen: 'bot', - codex: 'code-2' + codex: 'code-2', + claude: 'brain' }; - const tools = ['gemini', 'qwen', 'codex']; + const tools = ['gemini', 'qwen', 'codex', 'claude']; const toolsHtml = tools.map(tool => { const status = cliToolStatus[tool] || {}; @@ -270,7 +272,7 @@ function renderCliStatus() { -

Use native tool resume (gemini -r, qwen --resume, codex resume)

+

Use native tool resume (gemini -r, qwen --resume, codex resume, claude --resume)

@@ -89,7 +119,7 @@ function initTaskQueueSidebar() { updateTaskQueueData(); updateCliQueueData(); - renderTaskQueue(); + renderTaskQueueSidebar(); renderCliQueue(); updateTaskQueueBadge(); } @@ -114,7 +144,7 @@ function toggleTaskQueueSidebar() { toggle.classList.add('hidden'); // Refresh data when opened updateTaskQueueData(); - renderTaskQueue(); + renderTaskQueueSidebar(); } else { sidebar.classList.remove('open'); overlay.classList.remove('show'); @@ -182,9 +212,10 @@ function updateTaskQueueData() { } /** - * Render task queue list + * Render task queue list in sidebar + * Note: Named renderTaskQueueSidebar to avoid conflict with explorer.js renderTaskQueue */ -function renderTaskQueue(filter) { +function renderTaskQueueSidebar(filter) { filter = filter || 'all'; var contentEl = document.getElementById('taskQueueContent'); if (!contentEl) { @@ -249,7 +280,7 @@ function filterTaskQueue(filter) { document.querySelectorAll('.task-filter-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.filter === filter); }); - renderTaskQueue(filter); + renderTaskQueueSidebar(filter); } /** @@ -307,7 +338,7 @@ function updateTaskQueueBadge() { function refreshTaskQueue() { updateTaskQueueData(); updateCliQueueData(); - renderTaskQueue(); + renderTaskQueueSidebar(); renderCliQueue(); updateTaskQueueBadge(); } @@ -365,7 +396,7 @@ async function updateCliQueueData() { * Render CLI queue list */ function renderCliQueue() { - const contentEl = document.getElementById('cliQueueContent'); + const contentEl = document.getElementById('cliHistoryList'); if (!contentEl) return; // Filter by category @@ -459,3 +490,227 @@ function getCliTimeAgo(date) { if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; return `${Math.floor(seconds / 86400)}d`; } + +// ========================================== +// UPDATE TASK QUEUE - For CLAUDE.md Updates +// ========================================== + +/** + * Add update task to sidebar queue (called from explorer) + */ +function addUpdateTaskToSidebar(path, tool = 'gemini', strategy = 'single-layer') { + const task = { + id: Date.now(), + path, + tool, + strategy, + status: 'pending', // pending, running, completed, failed + message: '', + addedAt: new Date().toISOString() + }; + + sidebarUpdateTasks.push(task); + renderSidebarUpdateTasks(); + updateCliTabBadge(); + + // Open sidebar and switch to CLI tab if not visible + if (!isTaskQueueSidebarVisible) { + toggleTaskQueueSidebar(); + } + switchQueueTab('cli'); +} + +/** + * Remove update task from queue + */ +function removeUpdateTask(taskId) { + sidebarUpdateTasks = sidebarUpdateTasks.filter(t => t.id !== taskId); + renderSidebarUpdateTasks(); + updateCliTabBadge(); +} + +/** + * Clear completed/failed update tasks + */ +function clearCompletedUpdateTasks() { + sidebarUpdateTasks = sidebarUpdateTasks.filter(t => t.status === 'pending' || t.status === 'running'); + renderSidebarUpdateTasks(); + updateCliTabBadge(); +} + +/** + * Update CLI tool for a specific task + */ +function updateSidebarTaskCliTool(taskId, tool) { + const task = sidebarUpdateTasks.find(t => t.id === taskId); + if (task && task.status === 'pending') { + task.tool = tool; + } +} + +/** + * Execute a single update task + */ +async function executeSidebarUpdateTask(taskId) { + const task = sidebarUpdateTasks.find(t => t.id === taskId); + if (!task || task.status !== 'pending') return; + + const folderName = task.path.split('/').pop() || task.path; + + // Update status to running + task.status = 'running'; + task.message = t('taskQueue.processing'); + isSidebarTaskRunning[taskId] = true; + renderSidebarUpdateTasks(); + + if (typeof addGlobalNotification === 'function') { + addGlobalNotification('info', `Processing: ${folderName}`, `Strategy: ${task.strategy}, Tool: ${task.tool}`, 'Explorer'); + } + + try { + const response = await fetch('/api/update-claude-md', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path: task.path, + tool: task.tool, + strategy: task.strategy + }) + }); + + const result = await response.json(); + + if (result.success) { + task.status = 'completed'; + task.message = t('taskQueue.updated'); + if (typeof addGlobalNotification === 'function') { + addGlobalNotification('success', `Completed: ${folderName}`, result.message, 'Explorer'); + } + } else { + task.status = 'failed'; + task.message = result.error || t('taskQueue.failed'); + if (typeof addGlobalNotification === 'function') { + addGlobalNotification('error', `Failed: ${folderName}`, result.error || 'Unknown error', 'Explorer'); + } + } + } catch (error) { + task.status = 'failed'; + task.message = error.message; + if (typeof addGlobalNotification === 'function') { + addGlobalNotification('error', `Error: ${folderName}`, error.message, 'Explorer'); + } + } finally { + delete isSidebarTaskRunning[taskId]; + renderSidebarUpdateTasks(); + updateCliTabBadge(); + + // Refresh tree to show updated CLAUDE.md files + if (typeof loadExplorerTree === 'function' && typeof explorerCurrentPath !== 'undefined') { + loadExplorerTree(explorerCurrentPath); + } + } +} + +/** + * Stop/cancel a running update task (if possible) + */ +function stopSidebarUpdateTask(taskId) { + // Currently just removes the task - actual cancellation would need AbortController + const task = sidebarUpdateTasks.find(t => t.id === taskId); + if (task && task.status === 'running') { + task.status = 'failed'; + task.message = 'Cancelled'; + delete isSidebarTaskRunning[taskId]; + renderSidebarUpdateTasks(); + updateCliTabBadge(); + } +} + +/** + * Render update task queue list + */ +function renderSidebarUpdateTasks() { + const listEl = document.getElementById('updateTasksList'); + if (!listEl) return; + + if (sidebarUpdateTasks.length === 0) { + listEl.innerHTML = ` +
+ ${t('taskQueue.noTasks')} +

${t('taskQueue.noTasksHint')}

+
+ `; + return; + } + + listEl.innerHTML = sidebarUpdateTasks.map(task => { + const folderName = task.path.split('/').pop() || task.path; + const strategyIcon = task.strategy === 'multi-layer' ? '📂' : '📄'; + const strategyLabel = task.strategy === 'multi-layer' + ? t('taskQueue.withSubdirs') + : t('taskQueue.currentOnly'); + + const statusIcon = { + 'pending': '⏳', + 'running': '🔄', + 'completed': '✅', + 'failed': '❌' + }[task.status]; + + const isPending = task.status === 'pending'; + const isRunning = task.status === 'running'; + + return ` +
+
+ ${statusIcon} + ${escapeHtml(folderName)} + ${strategyIcon} +
+
+ + ${isPending ? ` + + + ` : ''} + ${isRunning ? ` + + ` : ''} +
+ ${task.message ? `
${escapeHtml(task.message)}
` : ''} +
+ `; + }).join(''); +} + +/** + * Update CLI tab badge with pending update tasks count + */ +function updateCliTabBadge() { + const pendingCount = sidebarUpdateTasks.filter(t => t.status === 'pending' || t.status === 'running').length; + const cliTabBadge = document.getElementById('cliTabBadge'); + if (cliTabBadge) { + const totalCount = pendingCount + cliQueueData.length; + cliTabBadge.textContent = totalCount; + cliTabBadge.style.display = totalCount > 0 ? 'inline' : 'none'; + } +} diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 9027e26a..f1b64e35 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -569,6 +569,9 @@ const i18n = { 'memory.memoryGraph': 'Memory Graph', 'memory.nodes': 'nodes', 'memory.resetView': 'Reset View', + 'memory.zoomIn': 'Zoom In', + 'memory.zoomOut': 'Zoom Out', + 'memory.fitView': 'Fit to View', 'memory.file': 'File', 'memory.module': 'Module', 'memory.component': 'Component', @@ -580,6 +583,7 @@ const i18n = { 'memory.noRecentActivity': 'No recent activity', 'memory.reads': 'Reads', 'memory.edits': 'Edits', + 'memory.mentions': 'Mentions', 'memory.prompts': 'Prompts', 'memory.nodeDetails': 'Node Details', 'memory.heat': 'Heat', @@ -590,6 +594,25 @@ const i18n = { 'memory.justNow': 'Just now', 'memory.minutesAgo': 'minutes ago', 'memory.hoursAgo': 'hours ago', + 'memory.title': 'Memory', + 'memory.activeMemory': 'Active Memory', + 'memory.active': 'Active', + 'memory.inactive': 'Inactive', + 'memory.syncNow': 'Sync Now', + 'memory.syncComplete': 'Sync complete', + 'memory.syncError': 'Sync failed', + 'memory.filesAnalyzed': 'files analyzed', + 'memory.activeMemoryEnabled': 'Active Memory enabled', + 'memory.activeMemoryDisabled': 'Active Memory disabled', + 'memory.activeMemoryError': 'Failed to toggle Active Memory', + 'memory.interval': 'Interval', + 'memory.intervalManual': 'Manual', + 'memory.minutes': 'min', + 'memory.cliTool': 'CLI', + 'memory.lastSync': 'Last sync', + 'memory.autoSyncActive': 'Auto-sync', + 'memory.configUpdated': 'Configuration updated', + 'memory.configError': 'Failed to update configuration', // Common 'common.cancel': 'Cancel', @@ -1170,6 +1193,9 @@ const i18n = { 'memory.memoryGraph': '记忆图谱', 'memory.nodes': '节点', 'memory.resetView': '重置视图', + 'memory.zoomIn': '放大', + 'memory.zoomOut': '缩小', + 'memory.fitView': '自适应', 'memory.file': '文件', 'memory.module': '模块', 'memory.component': '组件', @@ -1181,6 +1207,7 @@ const i18n = { 'memory.noRecentActivity': '无最近活动', 'memory.reads': '读取', 'memory.edits': '编辑', + 'memory.mentions': '提及', 'memory.prompts': '提示', 'memory.nodeDetails': '节点详情', 'memory.heat': '热度', @@ -1191,6 +1218,25 @@ const i18n = { 'memory.justNow': '刚刚', 'memory.minutesAgo': '分钟前', 'memory.hoursAgo': '小时前', + 'memory.title': '记忆', + 'memory.activeMemory': '活动记忆', + 'memory.active': '已启用', + 'memory.inactive': '未启用', + 'memory.syncNow': '立即同步', + 'memory.syncComplete': '同步完成', + 'memory.syncError': '同步失败', + 'memory.filesAnalyzed': '个文件已分析', + 'memory.activeMemoryEnabled': '活动记忆已启用', + 'memory.activeMemoryDisabled': '活动记忆已禁用', + 'memory.activeMemoryError': '切换活动记忆失败', + 'memory.interval': '间隔', + 'memory.intervalManual': '手动', + 'memory.minutes': '分钟', + 'memory.cliTool': 'CLI', + 'memory.lastSync': '上次同步', + 'memory.autoSyncActive': '自动同步', + 'memory.configUpdated': '配置已更新', + 'memory.configError': '配置更新失败', // Common 'common.cancel': '取消', diff --git a/ccw/src/templates/dashboard-js/views/explorer.js b/ccw/src/templates/dashboard-js/views/explorer.js index e7f9118f..e9a0fe9c 100644 --- a/ccw/src/templates/dashboard-js/views/explorer.js +++ b/ccw/src/templates/dashboard-js/views/explorer.js @@ -104,6 +104,7 @@ async function renderExplorer() { +
@@ -707,12 +708,17 @@ function addUpdateTask(path, tool = 'gemini', strategy = 'single-layer') { * Add task from folder context (right-click or button) */ function addFolderToQueue(folderPath, strategy = 'single-layer') { - // Use the selected CLI tool from the queue panel - addUpdateTask(folderPath, defaultCliTool, strategy); - - // Show task queue if not visible - if (!isTaskQueueVisible) { - toggleTaskQueue(); + // Use the sidebar queue instead of floating panel + if (typeof addUpdateTaskToSidebar === 'function') { + addUpdateTaskToSidebar(folderPath, defaultCliTool, strategy); + } else { + // Fallback to local queue + addUpdateTask(folderPath, defaultCliTool, strategy); + + // Show task queue if not visible + if (!isTaskQueueVisible) { + toggleTaskQueue(); + } } } diff --git a/ccw/src/templates/dashboard-js/views/memory.js b/ccw/src/templates/dashboard-js/views/memory.js index 485c65b2..887df54f 100644 --- a/ccw/src/templates/dashboard-js/views/memory.js +++ b/ccw/src/templates/dashboard-js/views/memory.js @@ -7,6 +7,13 @@ var memoryGraphData = null; var recentContext = []; var memoryTimeFilter = 'all'; // 'today', 'week', 'all' var selectedNode = null; +var activeMemoryEnabled = false; +var activeMemoryStatus = null; +var activeMemoryConfig = { + interval: 'manual', // manual, 5, 15, 30, 60 (minutes) + tool: 'gemini' // gemini, qwen +}; +var activeMemorySyncTimer = null; // Timer for automatic periodic sync // ========== Main Render Function ========== async function renderMemoryView() { @@ -29,11 +36,20 @@ async function renderMemoryView() { await Promise.all([ loadMemoryStats(), loadMemoryGraph(), - loadRecentContext() + loadRecentContext(), + loadActiveMemoryStatus() ]); - // Render three-column layout + // Render layout with Active Memory header container.innerHTML = '
' + + '
' + + '
' + + '

' + t('memory.title') + '

' + + '
' + + '
' + + renderActiveMemoryControls() + + '
' + + '
' + '
' + '
' + '
' + @@ -50,6 +66,56 @@ async function renderMemoryView() { if (window.lucide) lucide.createIcons(); } +function renderActiveMemoryControls() { + var html = '
' + + '
' + + '' + t('memory.activeMemory') + '' + + '' + + (activeMemoryEnabled ? ' ' + t('memory.active') + '' : '' + t('memory.inactive') + '') + + '
'; + + if (activeMemoryEnabled) { + var isAutoSync = activeMemoryConfig.interval !== 'manual'; + html += '
' + + // Interval selector + '
' + + '' + + '' + + '
' + + // CLI tool selector + '
' + + '' + + '' + + '
' + + // Auto-sync indicator + (isAutoSync ? '
' + t('memory.autoSyncActive') + '
' : '') + + '
' + + // Sync button and status + '
' + + '' + + (activeMemoryStatus && activeMemoryStatus.lastSync ? + '' + t('memory.lastSync') + ': ' + formatTimestamp(activeMemoryStatus.lastSync) + '' : '') + + '
'; + } + + html += '
'; + return html; +} + // ========== Data Loading ========== async function loadMemoryStats() { try { @@ -93,6 +159,168 @@ async function loadRecentContext() { } } +// ========== Active Memory Functions ========== +// Timer management for automatic sync +function startActiveMemorySyncTimer() { + // Clear any existing timer + stopActiveMemorySyncTimer(); + + // Only start timer if interval is not manual + if (activeMemoryConfig.interval === 'manual' || !activeMemoryEnabled) { + return; + } + + var intervalMs = parseInt(activeMemoryConfig.interval, 10) * 60 * 1000; // Convert minutes to ms + console.log('[ActiveMemory] Starting auto-sync timer:', activeMemoryConfig.interval, 'minutes'); + + activeMemorySyncTimer = setInterval(function() { + console.log('[ActiveMemory] Auto-sync triggered'); + syncActiveMemory(); + }, intervalMs); +} + +function stopActiveMemorySyncTimer() { + if (activeMemorySyncTimer) { + console.log('[ActiveMemory] Stopping auto-sync timer'); + clearInterval(activeMemorySyncTimer); + activeMemorySyncTimer = null; + } +} + +async function loadActiveMemoryStatus() { + try { + var response = await fetch('/api/memory/active/status'); + if (!response.ok) throw new Error('Failed to load active memory status'); + var data = await response.json(); + activeMemoryEnabled = data.enabled || false; + activeMemoryStatus = data.status || null; + // Load config if available + if (data.config) { + activeMemoryConfig = Object.assign(activeMemoryConfig, data.config); + } + + // Start timer if active memory is enabled and interval is not manual + if (activeMemoryEnabled && activeMemoryConfig.interval !== 'manual') { + startActiveMemorySyncTimer(); + } + + return data; + } catch (err) { + console.error('Failed to load active memory status:', err); + activeMemoryEnabled = false; + activeMemoryStatus = null; + return { enabled: false }; + } +} + +async function toggleActiveMemory(enabled) { + try { + var response = await fetch('/api/memory/active/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: enabled, + config: activeMemoryConfig + }) + }); + if (!response.ok) throw new Error('Failed to toggle active memory'); + var data = await response.json(); + activeMemoryEnabled = data.enabled; + + // Manage auto-sync timer based on enabled state + if (activeMemoryEnabled) { + startActiveMemorySyncTimer(); + } else { + stopActiveMemorySyncTimer(); + } + + // Show notification + if (window.showToast) { + showToast(enabled ? t('memory.activeMemoryEnabled') : t('memory.activeMemoryDisabled'), 'success'); + } + + // Re-render the view to update UI + renderMemoryView(); + } catch (err) { + console.error('Failed to toggle active memory:', err); + if (window.showToast) { + showToast(t('memory.activeMemoryError'), 'error'); + } + // Revert checkbox state + var checkbox = document.getElementById('activeMemorySwitch'); + if (checkbox) checkbox.checked = !enabled; + } +} + +async function updateActiveMemoryConfig(key, value) { + activeMemoryConfig[key] = value; + + try { + var response = await fetch('/api/memory/active/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: activeMemoryConfig }) + }); + if (!response.ok) throw new Error('Failed to update config'); + + // Restart timer if interval changed and active memory is enabled + if (key === 'interval' && activeMemoryEnabled) { + startActiveMemorySyncTimer(); + } + + if (window.showToast) { + showToast(t('memory.configUpdated'), 'success'); + } + } catch (err) { + console.error('Failed to update active memory config:', err); + if (window.showToast) { + showToast(t('memory.configError'), 'error'); + } + } +} + +async function syncActiveMemory() { + var syncBtn = document.querySelector('.btn-sync'); + if (syncBtn) { + syncBtn.classList.add('syncing'); + syncBtn.disabled = true; + } + + try { + var response = await fetch('/api/memory/active/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tool: activeMemoryConfig.tool + }) + }); + if (!response.ok) throw new Error('Failed to sync active memory'); + var data = await response.json(); + + if (window.showToast) { + showToast(t('memory.syncComplete') + ' (' + (data.filesAnalyzed || 0) + ' ' + t('memory.filesAnalyzed') + ')', 'success'); + } + + // Refresh data and update last sync time + await loadActiveMemoryStatus(); + // Update last sync display without full re-render + var lastSyncEl = document.querySelector('.last-sync'); + if (lastSyncEl && activeMemoryStatus && activeMemoryStatus.lastSync) { + lastSyncEl.textContent = t('memory.lastSync') + ': ' + formatTimestamp(activeMemoryStatus.lastSync); + } + } catch (err) { + console.error('Failed to sync active memory:', err); + if (window.showToast) { + showToast(t('memory.syncError'), 'error'); + } + } finally { + if (syncBtn) { + syncBtn.classList.remove('syncing'); + syncBtn.disabled = false; + } + } +} + // ========== Left Column: Context Hotspots ========== function renderHotspotsColumn() { var container = document.getElementById('memory-hotspots'); @@ -163,6 +391,12 @@ function renderHotspotList(items, type) { } // ========== Center Column: Memory Graph ========== +// Store graph state for zoom/pan +var graphZoom = null; +var graphSvg = null; +var graphGroup = null; +var graphSimulation = null; + function renderGraphColumn() { var container = document.getElementById('memory-graph'); if (!container) return; @@ -173,10 +407,19 @@ function renderGraphColumn() { '

' + t('memory.memoryGraph') + '

' + '' + (memoryGraphData.nodes || []).length + ' ' + t('memory.nodes') + '' + '
' + - '
' + - '' + + '' + + '' + + '' + '
' + '
' + '
' + @@ -223,81 +466,140 @@ function renderMemoryGraph(graphData) { if (!container) return; var width = container.clientWidth || 600; - var height = container.clientHeight || 500; + var height = container.clientHeight || 400; // Clear existing container.innerHTML = ''; - var svg = d3.select('#memoryGraphSvg') + // Filter and clean nodes - remove invalid names (like JSON data) + var cleanNodes = graphData.nodes.filter(function(node) { + var name = node.name || node.id || ''; + // Filter out JSON-like data, error messages, and very long strings + if (name.length > 100) return false; + if (name.includes('"status"') || name.includes('"content"')) return false; + if (name.includes('"todos"') || name.includes('"activeForm"')) return false; + if (name.startsWith('{') || name.startsWith('[')) return false; + // Allow all valid node types: file, module, component + return true; + }).map(function(node) { + // Truncate long names for display + var displayName = node.name || node.id || 'Unknown'; + if (displayName.length > 25) { + displayName = displayName.substring(0, 22) + '...'; + } + return Object.assign({}, node, { displayName: displayName }); + }); + + // Filter edges to only include valid nodes + var nodeIds = new Set(cleanNodes.map(function(n) { return n.id; })); + var cleanEdges = graphData.edges.filter(function(edge) { + var sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source; + var targetId = typeof edge.target === 'object' ? edge.target.id : edge.target; + return nodeIds.has(sourceId) && nodeIds.has(targetId); + }); + + // Create SVG with zoom support + graphSvg = d3.select('#memoryGraphSvg') .append('svg') .attr('width', width) .attr('height', height) - .attr('class', 'memory-graph-svg'); + .attr('class', 'memory-graph-svg') + .attr('viewBox', [0, 0, width, height]); + + // Create a group for zoom/pan transformations + graphGroup = graphSvg.append('g').attr('class', 'graph-content'); + + // Setup zoom behavior + graphZoom = d3.zoom() + .scaleExtent([0.1, 4]) + .on('zoom', function(event) { + graphGroup.attr('transform', event.transform); + }); + + graphSvg.call(graphZoom); // Create force simulation - var simulation = d3.forceSimulation(graphData.nodes) - .force('link', d3.forceLink(graphData.edges).id(function(d) { return d.id; }).distance(100)) - .force('charge', d3.forceManyBody().strength(-300)) + graphSimulation = d3.forceSimulation(cleanNodes) + .force('link', d3.forceLink(cleanEdges).id(function(d) { return d.id; }).distance(80)) + .force('charge', d3.forceManyBody().strength(-200)) .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collision', d3.forceCollide().radius(function(d) { return (d.heat || 10) + 5; })); + .force('collision', d3.forceCollide().radius(function(d) { return Math.max(15, (d.heat || 10) + 10); })) + .force('x', d3.forceX(width / 2).strength(0.05)) + .force('y', d3.forceY(height / 2).strength(0.05)); // Draw edges - var link = svg.append('g') + var link = graphGroup.append('g') + .attr('class', 'graph-links') .selectAll('line') - .data(graphData.edges) + .data(cleanEdges) .enter() .append('line') .attr('class', 'graph-edge') .attr('stroke-width', function(d) { return Math.sqrt(d.weight || 1); }); // Draw nodes - var node = svg.append('g') - .selectAll('circle') - .data(graphData.nodes) + var node = graphGroup.append('g') + .attr('class', 'graph-nodes') + .selectAll('g') + .data(cleanNodes) .enter() - .append('circle') - .attr('class', function(d) { return 'graph-node ' + (d.type || 'file'); }) - .attr('r', function(d) { return (d.heat || 10); }) - .attr('data-id', function(d) { return d.id; }) + .append('g') + .attr('class', function(d) { return 'graph-node-group ' + (d.type || 'file'); }) .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended)) .on('click', function(event, d) { + event.stopPropagation(); selectNode(d); }); - // Node labels - var label = svg.append('g') - .selectAll('text') - .data(graphData.nodes) - .enter() - .append('text') + // Add circles to nodes + node.append('circle') + .attr('class', function(d) { return 'graph-node ' + (d.type || 'file'); }) + .attr('r', function(d) { return Math.max(8, Math.min(20, (d.heat || 10))); }) + .attr('data-id', function(d) { return d.id; }); + + // Add labels to nodes + node.append('text') .attr('class', 'graph-label') - .text(function(d) { return d.name || d.id; }) - .attr('x', 8) - .attr('y', 3); + .text(function(d) { + // Show file count for modules + if (d.type === 'module' && d.fileCount) { + return d.displayName + ' (' + d.fileCount + ')'; + } + return d.displayName; + }) + .attr('x', function(d) { return Math.max(10, (d.heat || 10)) + 4; }) + .attr('y', 4) + .attr('font-size', '11px'); // Update positions on simulation tick - simulation.on('tick', function() { + graphSimulation.on('tick', function() { link .attr('x1', function(d) { return d.source.x; }) .attr('y1', function(d) { return d.source.y; }) .attr('x2', function(d) { return d.target.x; }) .attr('y2', function(d) { return d.target.y; }); - node - .attr('cx', function(d) { return d.x; }) - .attr('cy', function(d) { return d.y; }); - - label - .attr('x', function(d) { return d.x + 8; }) - .attr('y', function(d) { return d.y + 3; }); + node.attr('transform', function(d) { + return 'translate(' + d.x + ',' + d.y + ')'; + }); }); + // Auto-fit after simulation stabilizes + graphSimulation.on('end', function() { + fitGraphToView(); + }); + + // Also fit after initial layout + setTimeout(function() { + fitGraphToView(); + }, 1000); + // Drag functions function dragstarted(event, d) { - if (!event.active) simulation.alphaTarget(0.3).restart(); + if (!event.active) graphSimulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } @@ -308,18 +610,94 @@ function renderMemoryGraph(graphData) { } function dragended(event, d) { - if (!event.active) simulation.alphaTarget(0); + if (!event.active) graphSimulation.alphaTarget(0); d.fx = null; d.fy = null; } } +// ========== Graph Zoom Controls ========== +function zoomGraphIn() { + if (graphSvg && graphZoom) { + graphSvg.transition().duration(300).call(graphZoom.scaleBy, 1.3); + } +} + +function zoomGraphOut() { + if (graphSvg && graphZoom) { + graphSvg.transition().duration(300).call(graphZoom.scaleBy, 0.7); + } +} + +function fitGraphToView() { + if (!graphSvg || !graphGroup || !graphZoom) return; + + var container = document.getElementById('memoryGraphSvg'); + if (!container) return; + + var width = container.clientWidth || 600; + var height = container.clientHeight || 400; + + // Get the bounds of all nodes + var bounds = graphGroup.node().getBBox(); + if (bounds.width === 0 || bounds.height === 0) return; + + // Calculate scale to fit with padding + var padding = 40; + var scale = Math.min( + (width - padding * 2) / bounds.width, + (height - padding * 2) / bounds.height + ); + scale = Math.min(Math.max(scale, 0.2), 2); // Clamp scale between 0.2 and 2 + + // Calculate translation to center + var tx = (width - bounds.width * scale) / 2 - bounds.x * scale; + var ty = (height - bounds.height * scale) / 2 - bounds.y * scale; + + // Apply transform with animation + graphSvg.transition() + .duration(500) + .call(graphZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale)); +} + +function centerGraphOnNode(nodeId) { + if (!graphSvg || !graphGroup || !graphZoom) return; + + var container = document.getElementById('memoryGraphSvg'); + if (!container) return; + + var width = container.clientWidth || 600; + var height = container.clientHeight || 400; + + // Find the node + var nodeData = null; + graphGroup.selectAll('.graph-node-group').each(function(d) { + if (d.id === nodeId) nodeData = d; + }); + + if (!nodeData || nodeData.x === undefined) return; + + // Calculate translation to center on node + var scale = 1.2; + var tx = width / 2 - nodeData.x * scale; + var ty = height / 2 - nodeData.y * scale; + + graphSvg.transition() + .duration(500) + .call(graphZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale)); +} + function selectNode(node) { selectedNode = node; // Highlight in graph - d3.selectAll('.graph-node').classed('selected', false); - d3.selectAll('.graph-node[data-id="' + node.id + '"]').classed('selected', true); + if (graphGroup) { + graphGroup.selectAll('.graph-node').classed('selected', false); + graphGroup.selectAll('.graph-node[data-id="' + node.id + '"]').classed('selected', true); + } + + // Center graph on selected node + centerGraphOnNode(node.id); // Show node details in context column showNodeDetails(node); @@ -329,19 +707,15 @@ function highlightNode(path) { var node = memoryGraphData.nodes.find(function(n) { return n.path === path || n.id === path; }); if (node) { selectNode(node); - // Center graph on node if possible - if (typeof d3 !== 'undefined') { - var container = document.getElementById('memoryGraphSvg'); - if (container) { - container.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } } } function resetGraphView() { selectedNode = null; - d3.selectAll('.graph-node').classed('selected', false); + if (graphGroup) { + graphGroup.selectAll('.graph-node').classed('selected', false); + } + fitGraphToView(); renderContextColumn(); } @@ -382,13 +756,14 @@ function renderContextTimeline(prompts) { } return '
' + - prompts.map(function(item) { + prompts.map(function(item, index) { var timestamp = item.timestamp ? formatTimestamp(item.timestamp) : 'Unknown time'; var type = item.type || 'unknown'; - var typeIcon = type === 'read' ? 'eye' : type === 'edit' ? 'pencil' : 'file-text'; + var typeIcon = type === 'read' ? 'eye' : type === 'write' ? 'pencil' : type === 'edit' ? 'pencil' : 'file-text'; var files = item.files || []; + var description = item.prompt || item.description || 'No description'; - return '
' + + return '
' + '
' + '' + '
' + @@ -397,14 +772,13 @@ function renderContextTimeline(prompts) { '' + escapeHtml(type.charAt(0).toUpperCase() + type.slice(1)) + '' + '' + timestamp + '' + '
' + - '
' + escapeHtml(item.prompt || item.description || 'No description') + '
' + + '
' + escapeHtml(description) + '
' + (files.length > 0 ? '
' + - files.slice(0, 3).map(function(f) { - return '' + + files.map(function(f) { + return '' + ' ' + escapeHtml(f.split('/').pop().split('\\').pop()) + ''; }).join('') + - (files.length > 3 ? '+' + (files.length - 3) + ' more' : '') + '
' : '') + '
' + '
'; @@ -412,10 +786,17 @@ function renderContextTimeline(prompts) { '
'; } +/** + * Toggle timeline item expansion + */ +function toggleTimelineItem(element) { + element.classList.toggle('expanded'); +} + function renderContextStats() { var totalReads = recentContext.filter(function(c) { return c.type === 'read'; }).length; - var totalEdits = recentContext.filter(function(c) { return c.type === 'edit'; }).length; - var totalPrompts = recentContext.filter(function(c) { return c.type === 'prompt'; }).length; + var totalEdits = recentContext.filter(function(c) { return c.type === 'edit' || c.type === 'write'; }).length; + var totalMentions = recentContext.filter(function(c) { return c.type === 'mention'; }).length; return '
' + '
' + @@ -430,8 +811,8 @@ function renderContextStats() { '
' + '
' + '' + - '' + t('memory.prompts') + '' + - '' + totalPrompts + '' + + '' + t('memory.mentions') + '' + + '' + totalMentions + '' + '
' + '
'; } diff --git a/ccw/src/tools/cli-executor.ts b/ccw/src/tools/cli-executor.ts index 44eab8dc..832a9099 100644 --- a/ccw/src/tools/cli-executor.ts +++ b/ccw/src/tools/cli-executor.ts @@ -299,6 +299,35 @@ function buildCommand(params: { } break; + case 'claude': + // Claude Code: claude -p "prompt" for non-interactive mode + args.push('-p'); // Print mode (non-interactive) + // Native resume: claude --resume or --continue + if (nativeResume?.enabled) { + if (nativeResume.isLatest) { + args.push('--continue'); + } else if (nativeResume.sessionId) { + args.push('--resume', nativeResume.sessionId); + } + } + if (model) { + args.push('--model', model); + } + // Permission modes for write/auto + if (mode === 'write' || mode === 'auto') { + args.push('--dangerously-skip-permissions'); + } + // Output format for better parsing + args.push('--output-format', 'text'); + // Add directories + if (include) { + const dirs = include.split(',').map(d => d.trim()).filter(d => d); + for (const addDir of dirs) { + args.push('--add-dir', addDir); + } + } + break; + default: throw new Error(`Unknown CLI tool: ${tool}`); } @@ -1172,7 +1201,7 @@ export async function batchDeleteExecutionsAsync(baseDir: string, ids: string[]) * Get status of all CLI tools */ export async function getCliToolsStatus(): Promise> { - const tools = ['gemini', 'qwen', 'codex']; + const tools = ['gemini', 'qwen', 'codex', 'claude']; const results: Record = {}; await Promise.all(tools.map(async (tool) => { diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts index 534eac8a..8e2bc54a 100644 --- a/ccw/src/tools/cli-history-store.ts +++ b/ccw/src/tools/cli-history-store.ts @@ -187,15 +187,27 @@ export class CliHistoryStore { * Migrate schema for existing databases */ private migrateSchema(): void { - // Check if category column exists - const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>; - const hasCategory = tableInfo.some(col => col.name === 'category'); + try { + // Check if category column exists + const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>; + const hasCategory = tableInfo.some(col => col.name === 'category'); - if (!hasCategory) { - this.db.exec(` - ALTER TABLE conversations ADD COLUMN category TEXT DEFAULT 'user'; - CREATE INDEX IF NOT EXISTS idx_conversations_category ON conversations(category); - `); + if (!hasCategory) { + console.log('[CLI History] Migrating database: adding category column...'); + this.db.exec(` + ALTER TABLE conversations ADD COLUMN category TEXT DEFAULT 'user'; + `); + // Create index separately to handle potential errors + try { + this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_category ON conversations(category);`); + } catch (indexErr) { + console.warn('[CLI History] Category index creation warning:', (indexErr as Error).message); + } + console.log('[CLI History] Migration complete: category column added'); + } + } catch (err) { + console.error('[CLI History] Migration error:', (err as Error).message); + // Don't throw - allow the store to continue working with existing schema } } diff --git a/ccw/src/tools/native-session-discovery.ts b/ccw/src/tools/native-session-discovery.ts index d18622d1..bbc9fe72 100644 --- a/ccw/src/tools/native-session-discovery.ts +++ b/ccw/src/tools/native-session-discovery.ts @@ -432,11 +432,103 @@ class CodexSessionDiscoverer extends SessionDiscoverer { } } +/** + * Claude Code Session Discoverer + * Path: ~/.claude/projects//sessions/*.jsonl + * Claude Code stores sessions with UUID-based session IDs + */ +class ClaudeSessionDiscoverer extends SessionDiscoverer { + tool = 'claude'; + basePath = join(getHomePath(), '.claude', 'projects'); + + getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] { + const { workingDir, limit, afterTimestamp } = options; + const sessions: NativeSession[] = []; + + try { + if (!existsSync(this.basePath)) return []; + + // If workingDir provided, only look in that project's folder + let projectDirs: string[]; + if (workingDir) { + const projectHash = calculateProjectHash(workingDir); + const projectPath = join(this.basePath, projectHash); + projectDirs = existsSync(projectPath) ? [projectHash] : []; + } else { + projectDirs = readdirSync(this.basePath).filter(d => { + const fullPath = join(this.basePath, d); + return statSync(fullPath).isDirectory(); + }); + } + + for (const projectHash of projectDirs) { + const sessionsDir = join(this.basePath, projectHash, 'sessions'); + if (!existsSync(sessionsDir)) continue; + + const sessionFiles = readdirSync(sessionsDir) + .filter(f => f.endsWith('.jsonl') || f.endsWith('.json')) + .map(f => ({ + name: f, + path: join(sessionsDir, f), + stat: statSync(join(sessionsDir, f)) + })) + .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs); + + for (const file of sessionFiles) { + if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue; + + try { + // Extract session ID from filename or content + const uuidMatch = file.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); + if (uuidMatch) { + sessions.push({ + sessionId: uuidMatch[1], + tool: this.tool, + filePath: file.path, + projectHash, + createdAt: file.stat.birthtime, + updatedAt: file.stat.mtime + }); + } else { + // Try reading first line for session metadata + const firstLine = readFileSync(file.path, 'utf8').split('\n')[0]; + const meta = JSON.parse(firstLine); + if (meta.session_id) { + sessions.push({ + sessionId: meta.session_id, + tool: this.tool, + filePath: file.path, + projectHash, + createdAt: new Date(meta.timestamp || file.stat.birthtime), + updatedAt: file.stat.mtime + }); + } + } + } catch { + // Skip invalid files + } + } + } + + sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + return limit ? sessions.slice(0, limit) : sessions; + } catch { + return []; + } + } + + findSessionById(sessionId: string): NativeSession | null { + const sessions = this.getSessions(); + return sessions.find(s => s.sessionId === sessionId) || null; + } +} + // Singleton discoverers const discoverers: Record = { gemini: new GeminiSessionDiscoverer(), qwen: new QwenSessionDiscoverer(), - codex: new CodexSessionDiscoverer() + codex: new CodexSessionDiscoverer(), + claude: new ClaudeSessionDiscoverer() }; /**