diff --git a/.gitignore b/.gitignore index 0285301..2ff70c1 100755 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ temp/ .windsurf/ .serena/ CLAUDE.md +.mcp.json # Database files diff --git a/.playwright-mcp/1-input-with-text.png b/.playwright-mcp/1-input-with-text.png new file mode 100644 index 0000000..19569df Binary files /dev/null and b/.playwright-mcp/1-input-with-text.png differ diff --git a/.playwright-mcp/2-slash-command-menu-open.png b/.playwright-mcp/2-slash-command-menu-open.png new file mode 100644 index 0000000..0828a4f Binary files /dev/null and b/.playwright-mcp/2-slash-command-menu-open.png differ diff --git a/.playwright-mcp/3-after-fix-with-text.png b/.playwright-mcp/3-after-fix-with-text.png new file mode 100644 index 0000000..0433ec2 Binary files /dev/null and b/.playwright-mcp/3-after-fix-with-text.png differ diff --git a/.playwright-mcp/4-clear-button-final-position.png b/.playwright-mcp/4-clear-button-final-position.png new file mode 100644 index 0000000..c8d0244 Binary files /dev/null and b/.playwright-mcp/4-clear-button-final-position.png differ diff --git a/.playwright-mcp/5-slash-menu-no-clear-button.png b/.playwright-mcp/5-slash-menu-no-clear-button.png new file mode 100644 index 0000000..73d21bd Binary files /dev/null and b/.playwright-mcp/5-slash-menu-no-clear-button.png differ diff --git a/.playwright-mcp/6-latest-build-with-text.png b/.playwright-mcp/6-latest-build-with-text.png new file mode 100644 index 0000000..fa3252a Binary files /dev/null and b/.playwright-mcp/6-latest-build-with-text.png differ diff --git a/.playwright-mcp/after-sw-cleanup.png b/.playwright-mcp/after-sw-cleanup.png new file mode 100644 index 0000000..2805738 Binary files /dev/null and b/.playwright-mcp/after-sw-cleanup.png differ diff --git a/.playwright-mcp/input-area-scrolled.png b/.playwright-mcp/input-area-scrolled.png new file mode 100644 index 0000000..d563ebf Binary files /dev/null and b/.playwright-mcp/input-area-scrolled.png differ diff --git a/.playwright-mcp/login-screen-working.png b/.playwright-mcp/login-screen-working.png new file mode 100644 index 0000000..16f96aa Binary files /dev/null and b/.playwright-mcp/login-screen-working.png differ diff --git a/.playwright-mcp/page-after-restart.png b/.playwright-mcp/page-after-restart.png new file mode 100644 index 0000000..2805738 Binary files /dev/null and b/.playwright-mcp/page-after-restart.png differ diff --git a/.playwright-mcp/session-with-commands-button-desktop.png b/.playwright-mcp/session-with-commands-button-desktop.png new file mode 100644 index 0000000..fe482d2 Binary files /dev/null and b/.playwright-mcp/session-with-commands-button-desktop.png differ diff --git a/.playwright-mcp/slash-command-desktop-initial.png b/.playwright-mcp/slash-command-desktop-initial.png new file mode 100644 index 0000000..2805738 Binary files /dev/null and b/.playwright-mcp/slash-command-desktop-initial.png differ diff --git a/.playwright-mcp/slash-command-menu-desktop.png b/.playwright-mcp/slash-command-menu-desktop.png new file mode 100644 index 0000000..c651a49 Binary files /dev/null and b/.playwright-mcp/slash-command-menu-desktop.png differ diff --git a/.playwright-mcp/slash-command-menu-mobile.png b/.playwright-mcp/slash-command-menu-mobile.png new file mode 100644 index 0000000..fdbdde5 Binary files /dev/null and b/.playwright-mcp/slash-command-menu-mobile.png differ diff --git a/package-lock.json b/package-lock.json index 44e046c..c556d12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@codemirror/lang-markdown": "^6.3.3", "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.2", + "@esbuild/darwin-arm64": "^0.25.11", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", "@xterm/addon-clipboard": "^0.1.0", @@ -31,6 +32,8 @@ "cors": "^2.8.5", "cross-spawn": "^7.0.3", "express": "^4.18.2", + "fuse.js": "^6.6.2", + "gray-matter": "^4.0.3", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.515.0", "mime-types": "^3.0.1", @@ -658,15 +661,13 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", - "optional": true, "os": [ "darwin" ], @@ -3176,6 +3177,15 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -4676,6 +4686,23 @@ "@esbuild/win32-x64": "0.25.8" } }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4718,7 +4745,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -4910,6 +4936,18 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -5141,6 +5179,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -5408,6 +5455,21 @@ "devOptional": true, "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -5837,6 +5899,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6048,6 +6119,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6129,6 +6213,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -8963,6 +9056,19 @@ "loose-envify": "^1.1.0" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9574,6 +9680,12 @@ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", "dev": true }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/sqlite": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz", @@ -10125,6 +10237,15 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 2fd96dd..17a2054 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -151,23 +151,29 @@ function extractTokenBudget(resultMessage) { return null; } - // Get the first model's usage data (same as CLI implementation) + // Get the first model's usage data const modelKey = Object.keys(resultMessage.modelUsage)[0]; const modelData = resultMessage.modelUsage[modelKey]; - if (!modelData || !modelData.contextWindow) { + if (!modelData) { return null; } - // Calculate total tokens used (input + output + cache) - const inputTokens = modelData.inputTokens || 0; - const outputTokens = modelData.outputTokens || 0; - const cacheReadTokens = modelData.cacheReadInputTokens || 0; - const cacheCreationTokens = modelData.cacheCreationInputTokens || 0; + // Use cumulative tokens if available (tracks total for the session) + // Otherwise fall back to per-request tokens + const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0; + const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0; + const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0; + const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0; // Total used = input + output + cache tokens const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens; - const contextWindow = modelData.contextWindow; + + // Use configured context window budget from environment (default 160000) + // This is the user's budget limit, not the model's context window + const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000; + + console.log(`📊 Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`); return { used: totalUsed, @@ -364,9 +370,11 @@ async function queryClaudeSDK(command, options = {}, ws) { } // Process streaming messages + console.log('🔄 Starting async generator loop for session:', capturedSessionId || 'NEW'); for await (const message of queryInstance) { // Capture session ID from first message if (message.session_id && !capturedSessionId) { + console.log('📝 Captured session ID:', message.session_id); capturedSessionId = message.session_id; addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); @@ -377,7 +385,11 @@ async function queryClaudeSDK(command, options = {}, ws) { type: 'session-created', sessionId: capturedSessionId })); + } else { + console.log('⚠️ Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent); } + } else { + console.log('⚠️ No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId); } // Transform and send message to WebSocket @@ -409,12 +421,14 @@ async function queryClaudeSDK(command, options = {}, ws) { await cleanupTempFiles(tempImagePaths, tempDir); // Send completion event + console.log('✅ Streaming complete, sending claude-complete event'); ws.send(JSON.stringify({ type: 'claude-complete', sessionId: capturedSessionId, exitCode: 0, isNewSession: !sessionId && !!command })); + console.log('📤 claude-complete event sent'); } catch (error) { console.error('SDK query error:', error); diff --git a/server/index.js b/server/index.js index 176a22f..65e5f53 100755 --- a/server/index.js +++ b/server/index.js @@ -27,7 +27,7 @@ try { console.log('PORT from env:', process.env.PORT); import express from 'express'; -import { WebSocketServer } from 'ws'; +import { WebSocketServer, WebSocket } from 'ws'; import os from 'os'; import http from 'http'; import cors from 'cors'; @@ -46,6 +46,7 @@ import mcpRoutes from './routes/mcp.js'; import cursorRoutes from './routes/cursor.js'; import taskmasterRoutes from './routes/taskmaster.js'; import mcpUtilsRoutes from './routes/mcp-utils.js'; +import commandsRoutes from './routes/commands.js'; import { initializeDatabase } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; @@ -107,7 +108,7 @@ async function setupProjectsWatcher() { }); connectedClients.forEach(client => { - if (client.readyState === client.OPEN) { + if (client.readyState === WebSocket.OPEN) { client.send(updateMessage); } }); @@ -192,8 +193,24 @@ app.use('/api/taskmaster', authenticateToken, taskmasterRoutes); // MCP utilities app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes); +// Commands API Routes (protected) +app.use('/api/commands', authenticateToken, commandsRoutes); + // Static files served after API routes -app.use(express.static(path.join(__dirname, '../dist'))); +// Add cache control: HTML files should not be cached, but assets can be cached +app.use(express.static(path.join(__dirname, '../dist'), { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.html')) { + // Prevent HTML caching to avoid service worker issues after builds + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) { + // Cache static assets for 1 year (they have hashed names) + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + } +})); // API Routes (protected) app.get('/api/config', authenticateToken, (req, res) => { @@ -370,15 +387,24 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) = console.log('📄 File read request:', projectName, filePath); - // Using fsPromises from import - - // Security check - ensure the path is safe and absolute - if (!filePath || !path.isAbsolute(filePath)) { + // Security: ensure the requested path is inside the project root + if (!filePath) { return res.status(400).json({ error: 'Invalid file path' }); } - const content = await fsPromises.readFile(filePath, 'utf8'); - res.json({ content, path: filePath }); + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + const resolved = path.resolve(filePath); + const normalizedRoot = path.resolve(projectRoot) + path.sep; + if (!resolved.startsWith(normalizedRoot)) { + return res.status(403).json({ error: 'Path must be under project root' }); + } + + const content = await fsPromises.readFile(resolved, 'utf8'); + res.json({ content, path: resolved }); } catch (error) { console.error('Error reading file:', error); if (error.code === 'ENOENT') { @@ -399,27 +425,35 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re console.log('🖼️ Binary file serve request:', projectName, filePath); - // Using fs from import - // Using mime from import - - // Security check - ensure the path is safe and absolute - if (!filePath || !path.isAbsolute(filePath)) { + // Security: ensure the requested path is inside the project root + if (!filePath) { return res.status(400).json({ error: 'Invalid file path' }); } + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + const resolved = path.resolve(filePath); + const normalizedRoot = path.resolve(projectRoot) + path.sep; + if (!resolved.startsWith(normalizedRoot)) { + return res.status(403).json({ error: 'Path must be under project root' }); + } + // Check if file exists try { - await fsPromises.access(filePath); + await fsPromises.access(resolved); } catch (error) { return res.status(404).json({ error: 'File not found' }); } // Get file extension and set appropriate content type - const mimeType = mime.lookup(filePath) || 'application/octet-stream'; + const mimeType = mime.lookup(resolved) || 'application/octet-stream'; res.setHeader('Content-Type', mimeType); // Stream the file - const fileStream = fs.createReadStream(filePath); + const fileStream = fs.createReadStream(resolved); fileStream.pipe(res); fileStream.on('error', (error) => { @@ -445,10 +479,8 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) = console.log('💾 File save request:', projectName, filePath); - // Using fsPromises from import - - // Security check - ensure the path is safe and absolute - if (!filePath || !path.isAbsolute(filePath)) { + // Security: ensure the requested path is inside the project root + if (!filePath) { return res.status(400).json({ error: 'Invalid file path' }); } @@ -456,21 +488,32 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) = return res.status(400).json({ error: 'Content is required' }); } + const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + if (!projectRoot) { + return res.status(404).json({ error: 'Project not found' }); + } + + const resolved = path.resolve(filePath); + const normalizedRoot = path.resolve(projectRoot) + path.sep; + if (!resolved.startsWith(normalizedRoot)) { + return res.status(403).json({ error: 'Path must be under project root' }); + } + // Create backup of original file try { - const backupPath = filePath + '.backup.' + Date.now(); - await fsPromises.copyFile(filePath, backupPath); + const backupPath = resolved + '.backup.' + Date.now(); + await fsPromises.copyFile(resolved, backupPath); console.log('📋 Created backup:', backupPath); } catch (backupError) { console.warn('Could not create backup:', backupError.message); } // Write the new content - await fsPromises.writeFile(filePath, content, 'utf8'); + await fsPromises.writeFile(resolved, content, 'utf8'); res.json({ success: true, - path: filePath, + path: resolved, message: 'File saved successfully' }); } catch (error) { @@ -751,7 +794,7 @@ function handleShellConnection(ws) { // Handle data output shellProcess.onData((data) => { - if (ws.readyState === ws.OPEN) { + if (ws.readyState === WebSocket.OPEN) { let outputData = data; // Check for various URL opening patterns @@ -798,7 +841,7 @@ function handleShellConnection(ws) { // Handle process exit shellProcess.onExit((exitCode) => { console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal); - if (ws.readyState === ws.OPEN) { + if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'output', data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n` @@ -835,7 +878,7 @@ function handleShellConnection(ws) { } } catch (error) { console.error('❌ Shell WebSocket error:', error.message); - if (ws.readyState === ws.OPEN) { + if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'output', data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n` @@ -1090,30 +1133,50 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r } }); -// API endpoint to get token usage for a session by reading its JSONL file -app.get('/api/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { +// Get token usage for a specific session +app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { try { - const { sessionId } = req.params; - const { projectPath } = req.query; + const { projectName, sessionId } = req.params; + const homeDir = os.homedir(); - if (!projectPath) { - return res.status(400).json({ error: 'projectPath query parameter is required' }); + // Extract actual project path + let projectPath; + try { + projectPath = await extractProjectDirectory(projectName); + } catch (error) { + console.error('Error extracting project directory:', error); + return res.status(500).json({ error: 'Failed to determine project path' }); } // Construct the JSONL file path // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl // The encoding replaces /, spaces, ~, and _ with - - const homeDir = os.homedir(); - const encodedPath = projectPath.replace(/[\/\s~_]/g, '-'); - const jsonlPath = path.join(homeDir, '.claude', 'projects', encodedPath, `${sessionId}.jsonl`); + const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-'); + const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); - // Check if file exists - if (!fs.existsSync(jsonlPath)) { - return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); + // Allow only safe characters in sessionId + const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safeSessionId) { + return res.status(400).json({ error: 'Invalid sessionId' }); + } + const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); + + // Constrain to projectDir + const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + return res.status(400).json({ error: 'Invalid path' }); } // Read and parse the JSONL file - const fileContent = fs.readFileSync(jsonlPath, 'utf8'); + let fileContent; + try { + fileContent = await fsPromises.readFile(jsonlPath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); + } + throw error; // Re-throw other errors to be caught by outer try-catch + } const lines = fileContent.trim().split('\n'); const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); @@ -1164,9 +1227,18 @@ app.get('/api/sessions/:sessionId/token-usage', authenticateToken, async (req, r // Serve React app for all other routes (excluding static files) app.get('*', (req, res) => { + // Skip requests for static assets (files with extensions) + if (path.extname(req.path)) { + return res.status(404).send('Not found'); + } + // Only serve index.html for HTML routes, not for static assets // Static assets should already be handled by express.static middleware above if (process.env.NODE_ENV === 'production') { + // Set no-cache headers for HTML to prevent service worker issues + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); res.sendFile(path.join(__dirname, '../dist/index.html')); } else { // In development, redirect to Vite dev server diff --git a/server/projects.js b/server/projects.js index 4f0aae3..2369c0f 100755 --- a/server/projects.js +++ b/server/projects.js @@ -627,8 +627,9 @@ async function getSessions(projectName, limit = 5, offset = 0) { return session; }); const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray] + .filter(session => !session.summary.startsWith('{ "')) .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); - + const total = visibleSessions.length; const paginatedSessions = visibleSessions.slice(offset, offset + limit); const hasMore = offset + limit < total; @@ -649,20 +650,26 @@ async function getSessions(projectName, limit = 5, offset = 0) { async function parseJsonlSessions(filePath) { const sessions = new Map(); const entries = []; - + const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId + try { const fileStream = fsSync.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - + for await (const line of rl) { if (line.trim()) { try { const entry = JSON.parse(line); entries.push(entry); - + + // Handle summary entries that don't have sessionId yet + if (entry.type === 'summary' && entry.summary && !entry.sessionId && entry.leafUuid) { + pendingSummaries.set(entry.leafUuid, entry.summary); + } + if (entry.sessionId) { if (!sessions.has(entry.sessionId)) { sessions.set(entry.sessionId, { @@ -670,24 +677,84 @@ async function parseJsonlSessions(filePath) { summary: 'New Session', messageCount: 0, lastActivity: new Date(), - cwd: entry.cwd || '' + cwd: entry.cwd || '', + lastUserMessage: null, + lastAssistantMessage: null }); } - + const session = sessions.get(entry.sessionId); - - // Update summary from summary entries or first user message + + // Apply pending summary if this entry has a parentUuid that matches a pending summary + if (session.summary === 'New Session' && entry.parentUuid && pendingSummaries.has(entry.parentUuid)) { + session.summary = pendingSummaries.get(entry.parentUuid); + } + + // Update summary from summary entries with sessionId if (entry.type === 'summary' && entry.summary) { session.summary = entry.summary; - } else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') { + } + + // Track last user and assistant messages (skip system messages) + if (entry.message?.role === 'user' && entry.message?.content) { const content = entry.message.content; - if (typeof content === 'string' && content.length > 0 && !content.startsWith('')) { - session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content; + + // Extract text from array format if needed + let textContent = content; + if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') { + textContent = content[0].text; + } + + const isSystemMessage = typeof textContent === 'string' && ( + textContent.startsWith('') || + textContent.startsWith('') || + textContent.startsWith('') || + textContent.startsWith('') || + textContent.startsWith('') || + textContent.startsWith('Caveat:') || + textContent.startsWith('This session is being continued from a previous') || + textContent.startsWith('Invalid API key') || + textContent.includes('{"subtasks":') || // Filter Task Master prompts + textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts + textContent === 'Warmup' // Explicitly filter out "Warmup" + ); + + if (typeof textContent === 'string' && textContent.length > 0 && !isSystemMessage) { + session.lastUserMessage = textContent; + } + } else if (entry.message?.role === 'assistant' && entry.message?.content) { + // Skip API error messages using the isApiErrorMessage flag + if (entry.isApiErrorMessage === true) { + // Skip this message entirely + } else { + // Track last assistant text message + let assistantText = null; + + if (Array.isArray(entry.message.content)) { + for (const part of entry.message.content) { + if (part.type === 'text' && part.text) { + assistantText = part.text; + } + } + } else if (typeof entry.message.content === 'string') { + assistantText = entry.message.content; + } + + // Additional filter for assistant messages with system content + const isSystemAssistantMessage = typeof assistantText === 'string' && ( + assistantText.startsWith('Invalid API key') || + assistantText.includes('{"subtasks":') || + assistantText.includes('CRITICAL: You MUST respond with ONLY a JSON') + ); + + if (assistantText && !isSystemAssistantMessage) { + session.lastAssistantMessage = assistantText; + } } } - + session.messageCount++; - + if (entry.timestamp) { session.lastActivity = new Date(entry.timestamp); } @@ -697,12 +764,36 @@ async function parseJsonlSessions(filePath) { } } } - + + // After processing all entries, set final summary based on last message if no summary exists + for (const session of sessions.values()) { + if (session.summary === 'New Session') { + // Prefer last user message, fall back to last assistant message + const lastMessage = session.lastUserMessage || session.lastAssistantMessage; + if (lastMessage) { + session.summary = lastMessage.length > 50 ? lastMessage.substring(0, 50) + '...' : lastMessage; + } + } + } + + // Filter out sessions that contain JSON responses (Task Master errors) + const allSessions = Array.from(sessions.values()); + const filteredSessions = allSessions.filter(session => { + const shouldFilter = session.summary.startsWith('{ "'); + if (shouldFilter) { + } + // Log a sample of summaries to debug + if (Math.random() < 0.01) { // Log 1% of sessions + } + return !shouldFilter; + }); + + return { - sessions: Array.from(sessions.values()), + sessions: filteredSessions, entries: entries }; - + } catch (error) { console.error('Error reading JSONL file:', error); return { sessions: [], entries: [] }; @@ -1060,4 +1151,4 @@ export { saveProjectConfig, extractProjectDirectory, clearProjectDirectoryCache -}; \ No newline at end of file +}; diff --git a/server/routes/commands.js b/server/routes/commands.js new file mode 100644 index 0000000..18a5c93 --- /dev/null +++ b/server/routes/commands.js @@ -0,0 +1,572 @@ +import express from 'express'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import os from 'os'; +import matter from 'gray-matter'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const router = express.Router(); + +/** + * Recursively scan directory for command files (.md) + * @param {string} dir - Directory to scan + * @param {string} baseDir - Base directory for relative paths + * @param {string} namespace - Namespace for commands (e.g., 'project', 'user') + * @returns {Promise} Array of command objects + */ +async function scanCommandsDirectory(dir, baseDir, namespace) { + const commands = []; + + try { + // Check if directory exists + await fs.access(dir); + + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Recursively scan subdirectories + const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace); + commands.push(...subCommands); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + // Parse markdown file for metadata + try { + const content = await fs.readFile(fullPath, 'utf8'); + const { data: frontmatter, content: commandContent } = matter(content); + + // Calculate relative path from baseDir for command name + const relativePath = path.relative(baseDir, fullPath); + // Remove .md extension and convert to command name + const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/'); + + // Extract description from frontmatter or first line of content + let description = frontmatter.description || ''; + if (!description) { + const firstLine = commandContent.trim().split('\n')[0]; + description = firstLine.replace(/^#+\s*/, '').trim(); + } + + commands.push({ + name: commandName, + path: fullPath, + relativePath, + description, + namespace, + metadata: frontmatter + }); + } catch (err) { + console.error(`Error parsing command file ${fullPath}:`, err.message); + } + } + } + } catch (err) { + // Directory doesn't exist or can't be accessed - this is okay + if (err.code !== 'ENOENT' && err.code !== 'EACCES') { + console.error(`Error scanning directory ${dir}:`, err.message); + } + } + + return commands; +} + +/** + * Built-in commands that are always available + */ +const builtInCommands = [ + { + name: '/help', + description: 'Show help documentation for Claude Code', + namespace: 'builtin', + metadata: { type: 'builtin' } + }, + { + name: '/clear', + description: 'Clear the conversation history', + namespace: 'builtin', + metadata: { type: 'builtin' } + }, + { + name: '/model', + description: 'Switch or view the current AI model', + namespace: 'builtin', + metadata: { type: 'builtin' } + }, + { + name: '/cost', + description: 'Display token usage and cost information', + namespace: 'builtin', + metadata: { type: 'builtin' } + }, + { + name: '/memory', + description: 'Open CLAUDE.md memory file for editing', + namespace: 'builtin', + metadata: { type: 'builtin' } + }, + { + name: '/config', + description: 'Open settings and configuration', + namespace: 'builtin', + metadata: { type: 'builtin' } + }, + { + name: '/status', + description: 'Show system status and version information', + namespace: 'builtin', + metadata: { type: 'builtin' } + }, + { + name: '/rewind', + description: 'Rewind the conversation to a previous state', + namespace: 'builtin', + metadata: { type: 'builtin' } + } +]; + +/** + * Built-in command handlers + * Each handler returns { type: 'builtin', action: string, data: any } + */ +const builtInHandlers = { + '/help': async (args, context) => { + const helpText = `# Claude Code Commands + +## Built-in Commands + +${builtInCommands.map(cmd => `### ${cmd.name} +${cmd.description} +`).join('\n')} + +## Custom Commands + +Custom commands can be created in: +- Project: \`.claude/commands/\` (project-specific) +- User: \`~/.claude/commands/\` (available in all projects) + +### Command Syntax + +- **Arguments**: Use \`$ARGUMENTS\` for all args or \`$1\`, \`$2\`, etc. for positional +- **File Includes**: Use \`@filename\` to include file contents +- **Bash Commands**: Use \`!command\` to execute bash commands + +### Examples + +\`\`\`markdown +/mycommand arg1 arg2 +\`\`\` +`; + + return { + type: 'builtin', + action: 'help', + data: { + content: helpText, + format: 'markdown' + } + }; + }, + + '/clear': async (args, context) => { + return { + type: 'builtin', + action: 'clear', + data: { + message: 'Conversation history cleared' + } + }; + }, + + '/model': async (args, context) => { + // Read available models from config or defaults + const availableModels = { + claude: [ + 'claude-sonnet-4.5', + 'claude-sonnet-4', + 'claude-opus-4', + 'claude-sonnet-3.5' + ], + cursor: [ + 'gpt-5', + 'sonnet-4', + 'opus-4.1' + ] + }; + + const currentProvider = context?.provider || 'claude'; + const currentModel = context?.model || 'claude-sonnet-4.5'; + + return { + type: 'builtin', + action: 'model', + data: { + current: { + provider: currentProvider, + model: currentModel + }, + available: availableModels, + message: args.length > 0 + ? `Switching to model: ${args[0]}` + : `Current model: ${currentModel}` + } + }; + }, + + '/cost': async (args, context) => { + // Calculate token usage and cost + const sessionId = context?.sessionId; + const tokenUsage = context?.tokenUsage || { used: 0, total: 200000 }; + + const costPerMillion = { + 'claude-sonnet-4.5': { input: 3, output: 15 }, + 'claude-sonnet-4': { input: 3, output: 15 }, + 'claude-opus-4': { input: 15, output: 75 }, + 'gpt-5': { input: 5, output: 15 } + }; + + const model = context?.model || 'claude-sonnet-4.5'; + const rates = costPerMillion[model] || costPerMillion['claude-sonnet-4.5']; + + // Estimate 70% input, 30% output + const estimatedInputTokens = Math.floor(tokenUsage.used * 0.7); + const estimatedOutputTokens = Math.floor(tokenUsage.used * 0.3); + + const inputCost = (estimatedInputTokens / 1000000) * rates.input; + const outputCost = (estimatedOutputTokens / 1000000) * rates.output; + const totalCost = inputCost + outputCost; + + return { + type: 'builtin', + action: 'cost', + data: { + tokenUsage: { + used: tokenUsage.used, + total: tokenUsage.total, + percentage: ((tokenUsage.used / tokenUsage.total) * 100).toFixed(1) + }, + cost: { + input: inputCost.toFixed(4), + output: outputCost.toFixed(4), + total: totalCost.toFixed(4), + currency: 'USD' + }, + model, + rates + } + }; + }, + + '/status': async (args, context) => { + // Read version from package.json + const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json'); + let version = 'unknown'; + let packageName = 'claude-code-ui'; + + try { + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + version = packageJson.version; + packageName = packageJson.name; + } catch (err) { + console.error('Error reading package.json:', err); + } + + const uptime = process.uptime(); + const uptimeMinutes = Math.floor(uptime / 60); + const uptimeHours = Math.floor(uptimeMinutes / 60); + const uptimeFormatted = uptimeHours > 0 + ? `${uptimeHours}h ${uptimeMinutes % 60}m` + : `${uptimeMinutes}m`; + + return { + type: 'builtin', + action: 'status', + data: { + version, + packageName, + uptime: uptimeFormatted, + uptimeSeconds: Math.floor(uptime), + model: context?.model || 'claude-sonnet-4.5', + provider: context?.provider || 'claude', + nodeVersion: process.version, + platform: process.platform + } + }; + }, + + '/memory': async (args, context) => { + const projectPath = context?.projectPath; + + if (!projectPath) { + return { + type: 'builtin', + action: 'memory', + data: { + error: 'No project selected', + message: 'Please select a project to access its CLAUDE.md file' + } + }; + } + + const claudeMdPath = path.join(projectPath, 'CLAUDE.md'); + + // Check if CLAUDE.md exists + let exists = false; + try { + await fs.access(claudeMdPath); + exists = true; + } catch (err) { + // File doesn't exist + } + + return { + type: 'builtin', + action: 'memory', + data: { + path: claudeMdPath, + exists, + message: exists + ? `Opening CLAUDE.md at ${claudeMdPath}` + : `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.` + } + }; + }, + + '/config': async (args, context) => { + return { + type: 'builtin', + action: 'config', + data: { + message: 'Opening settings...' + } + }; + }, + + '/rewind': async (args, context) => { + const steps = args[0] ? parseInt(args[0]) : 1; + + if (isNaN(steps) || steps < 1) { + return { + type: 'builtin', + action: 'rewind', + data: { + error: 'Invalid steps parameter', + message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)' + } + }; + } + + return { + type: 'builtin', + action: 'rewind', + data: { + steps, + message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...` + } + }; + } +}; + +/** + * POST /api/commands/list + * List all available commands from project and user directories + */ +router.post('/list', async (req, res) => { + try { + const { projectPath } = req.body; + const allCommands = [...builtInCommands]; + + // Scan project-level commands (.claude/commands/) + if (projectPath) { + const projectCommandsDir = path.join(projectPath, '.claude', 'commands'); + const projectCommands = await scanCommandsDirectory( + projectCommandsDir, + projectCommandsDir, + 'project' + ); + allCommands.push(...projectCommands); + } + + // Scan user-level commands (~/.claude/commands/) + const homeDir = os.homedir(); + const userCommandsDir = path.join(homeDir, '.claude', 'commands'); + const userCommands = await scanCommandsDirectory( + userCommandsDir, + userCommandsDir, + 'user' + ); + allCommands.push(...userCommands); + + // Separate built-in and custom commands + const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin'); + + // Sort commands alphabetically by name + customCommands.sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ + builtIn: builtInCommands, + custom: customCommands, + count: allCommands.length + }); + } catch (error) { + console.error('Error listing commands:', error); + res.status(500).json({ + error: 'Failed to list commands', + message: error.message + }); + } +}); + +/** + * POST /api/commands/load + * Load a specific command file and return its content and metadata + */ +router.post('/load', async (req, res) => { + try { + const { commandPath } = req.body; + + if (!commandPath) { + return res.status(400).json({ + error: 'Command path is required' + }); + } + + // Security: Prevent path traversal + const resolvedPath = path.resolve(commandPath); + if (!resolvedPath.startsWith(path.resolve(os.homedir())) && + !resolvedPath.includes('.claude/commands')) { + return res.status(403).json({ + error: 'Access denied', + message: 'Command must be in .claude/commands directory' + }); + } + + // Read and parse the command file + const content = await fs.readFile(commandPath, 'utf8'); + const { data: metadata, content: commandContent } = matter(content); + + res.json({ + path: commandPath, + metadata, + content: commandContent + }); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ + error: 'Command not found', + message: `Command file not found: ${req.body.commandPath}` + }); + } + + console.error('Error loading command:', error); + res.status(500).json({ + error: 'Failed to load command', + message: error.message + }); + } +}); + +/** + * POST /api/commands/execute + * Execute a command with argument replacement + * This endpoint prepares the command content but doesn't execute bash commands yet + * (that will be handled in the command parser utility) + */ +router.post('/execute', async (req, res) => { + try { + const { commandName, commandPath, args = [], context = {} } = req.body; + + if (!commandName) { + return res.status(400).json({ + error: 'Command name is required' + }); + } + + // Handle built-in commands + const handler = builtInHandlers[commandName]; + if (handler) { + try { + const result = await handler(args, context); + return res.json({ + ...result, + command: commandName + }); + } catch (error) { + console.error(`Error executing built-in command ${commandName}:`, error); + return res.status(500).json({ + error: 'Command execution failed', + message: error.message, + command: commandName + }); + } + } + + // Handle custom commands + if (!commandPath) { + return res.status(400).json({ + error: 'Command path is required for custom commands' + }); + } + + // Load command content + // Security: validate commandPath is within allowed directories + { + const resolvedPath = path.resolve(commandPath); + const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands')); + const projectBase = context?.projectPath + ? path.resolve(path.join(context.projectPath, '.claude', 'commands')) + : null; + const isUnder = (base) => { + const rel = path.relative(base, resolvedPath); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); + }; + if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) { + return res.status(403).json({ + error: 'Access denied', + message: 'Command must be in .claude/commands directory' + }); + } + } + const content = await fs.readFile(commandPath, 'utf8'); + const { data: metadata, content: commandContent } = matter(content); + // Basic argument replacement (will be enhanced in command parser utility) + let processedContent = commandContent; + + // Replace $ARGUMENTS with all arguments joined + const argsString = args.join(' '); + processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString); + + // Replace $1, $2, etc. with positional arguments + args.forEach((arg, index) => { + const placeholder = `$${index + 1}`; + processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg); + }); + + res.json({ + type: 'custom', + command: commandName, + content: processedContent, + metadata, + hasFileIncludes: processedContent.includes('@'), + hasBashCommands: processedContent.includes('!') + }); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ + error: 'Command not found', + message: `Command file not found: ${req.body.commandPath}` + }); + } + + console.error('Error executing command:', error); + res.status(500).json({ + error: 'Failed to execute command', + message: error.message + }); + } +}); + +export default router; diff --git a/server/utils/commandParser.js b/server/utils/commandParser.js new file mode 100644 index 0000000..11af5c7 --- /dev/null +++ b/server/utils/commandParser.js @@ -0,0 +1,303 @@ +import matter from 'gray-matter'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { parse as parseShellCommand } from 'shell-quote'; + +const execFileAsync = promisify(execFile); + +// Configuration +const MAX_INCLUDE_DEPTH = 3; +const BASH_TIMEOUT = 30000; // 30 seconds +const BASH_COMMAND_ALLOWLIST = [ + 'echo', + 'ls', + 'pwd', + 'date', + 'whoami', + 'git', + 'npm', + 'node', + 'cat', + 'grep', + 'find', + 'task-master' +]; + +/** + * Parse a markdown command file and extract frontmatter and content + * @param {string} content - Raw markdown content + * @returns {object} Parsed command with data (frontmatter) and content + */ +export function parseCommand(content) { + try { + const parsed = matter(content); + return { + data: parsed.data || {}, + content: parsed.content || '', + raw: content + }; + } catch (error) { + throw new Error(`Failed to parse command: ${error.message}`); + } +} + +/** + * Replace argument placeholders in content + * @param {string} content - Content with placeholders + * @param {string|array} args - Arguments to replace (string or array) + * @returns {string} Content with replaced arguments + */ +export function replaceArguments(content, args) { + if (!content) return content; + + let result = content; + + // Convert args to array if it's a string + const argsArray = Array.isArray(args) ? args : (args ? [args] : []); + + // Replace $ARGUMENTS with all arguments joined by space + const allArgs = argsArray.join(' '); + result = result.replace(/\$ARGUMENTS/g, allArgs); + + // Replace positional arguments $1-$9 + for (let i = 1; i <= 9; i++) { + const regex = new RegExp(`\\$${i}`, 'g'); + const value = argsArray[i - 1] || ''; + result = result.replace(regex, value); + } + + return result; +} + +/** + * Validate file path to prevent directory traversal + * @param {string} filePath - Path to validate + * @param {string} basePath - Base directory path + * @returns {boolean} True if path is safe + */ +export function isPathSafe(filePath, basePath) { + const resolvedPath = path.resolve(basePath, filePath); + const resolvedBase = path.resolve(basePath); + const relative = path.relative(resolvedBase, resolvedPath); + return ( + relative !== '' && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ); +} + +/** + * Process file includes in content (@filename syntax) + * @param {string} content - Content with @filename includes + * @param {string} basePath - Base directory for resolving file paths + * @param {number} depth - Current recursion depth + * @returns {Promise} Content with includes resolved + */ +export async function processFileIncludes(content, basePath, depth = 0) { + if (!content) return content; + + // Prevent infinite recursion + if (depth >= MAX_INCLUDE_DEPTH) { + throw new Error(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`); + } + + // Match @filename patterns (at start of line or after whitespace) + const includePattern = /(?:^|\s)@([^\s]+)/gm; + const matches = [...content.matchAll(includePattern)]; + + if (matches.length === 0) { + return content; + } + + let result = content; + + for (const match of matches) { + const fullMatch = match[0]; + const filename = match[1]; + + // Security: prevent directory traversal + if (!isPathSafe(filename, basePath)) { + throw new Error(`Invalid file path (directory traversal detected): ${filename}`); + } + + try { + const filePath = path.resolve(basePath, filename); + const fileContent = await fs.readFile(filePath, 'utf-8'); + + // Recursively process includes in the included file + const processedContent = await processFileIncludes(fileContent, basePath, depth + 1); + + // Replace the @filename with the file content + result = result.replace(fullMatch, fullMatch.startsWith(' ') ? ' ' + processedContent : processedContent); + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error(`File not found: ${filename}`); + } + throw error; + } + } + + return result; +} + +/** + * Validate that a command and its arguments are safe + * @param {string} commandString - Command string to validate + * @returns {{ allowed: boolean, command: string, args: string[], error?: string }} Validation result + */ +export function validateCommand(commandString) { + const trimmedCommand = commandString.trim(); + if (!trimmedCommand) { + return { allowed: false, command: '', args: [], error: 'Empty command' }; + } + + // Parse the command using shell-quote to handle quotes properly + const parsed = parseShellCommand(trimmedCommand); + + // Check for shell operators or control structures + const hasOperators = parsed.some(token => + typeof token === 'object' && token.op + ); + + if (hasOperators) { + return { + allowed: false, + command: '', + args: [], + error: 'Shell operators (&&, ||, |, ;, etc.) are not allowed' + }; + } + + // Extract command and args (all should be strings after validation) + const tokens = parsed.filter(token => typeof token === 'string'); + + if (tokens.length === 0) { + return { allowed: false, command: '', args: [], error: 'No valid command found' }; + } + + const [command, ...args] = tokens; + + // Extract just the command name (remove path if present) + const commandName = path.basename(command); + + // Check if command exactly matches allowlist (no prefix matching) + const isAllowed = BASH_COMMAND_ALLOWLIST.includes(commandName); + + if (!isAllowed) { + return { + allowed: false, + command: commandName, + args, + error: `Command '${commandName}' is not in the allowlist` + }; + } + + // Validate arguments don't contain dangerous metacharacters + const dangerousPattern = /[;&|`$()<>{}[\]\\]/; + for (const arg of args) { + if (dangerousPattern.test(arg)) { + return { + allowed: false, + command: commandName, + args, + error: `Argument contains dangerous characters: ${arg}` + }; + } + } + + return { allowed: true, command: commandName, args }; +} + +/** + * Backward compatibility: Check if command is allowed (deprecated) + * @deprecated Use validateCommand() instead for better security + * @param {string} command - Command to validate + * @returns {boolean} True if command is allowed + */ +export function isBashCommandAllowed(command) { + const result = validateCommand(command); + return result.allowed; +} + +/** + * Sanitize bash command output + * @param {string} output - Raw command output + * @returns {string} Sanitized output + */ +export function sanitizeOutput(output) { + if (!output) return ''; + + // Remove control characters except \t, \n, \r + return [...output] + .filter(ch => { + const code = ch.charCodeAt(0); + return code === 9 // \t + || code === 10 // \n + || code === 13 // \r + || (code >= 32 && code !== 127); + }) + .join(''); +} + +/** + * Process bash commands in content (!command syntax) + * @param {string} content - Content with !command syntax + * @param {object} options - Options for bash execution + * @returns {Promise} Content with bash commands executed and replaced + */ +export async function processBashCommands(content, options = {}) { + if (!content) return content; + + const { cwd = process.cwd(), timeout = BASH_TIMEOUT } = options; + + // Match !command patterns (at start of line or after whitespace) + const commandPattern = /(?:^|\n)!(.+?)(?=\n|$)/g; + const matches = [...content.matchAll(commandPattern)]; + + if (matches.length === 0) { + return content; + } + + let result = content; + + for (const match of matches) { + const fullMatch = match[0]; + const commandString = match[1].trim(); + + // Security: validate command and parse args + const validation = validateCommand(commandString); + + if (!validation.allowed) { + throw new Error(`Command not allowed: ${commandString} - ${validation.error}`); + } + + try { + // Execute without shell using execFile with parsed args + const { stdout, stderr } = await execFileAsync( + validation.command, + validation.args, + { + cwd, + timeout, + maxBuffer: 1024 * 1024, // 1MB max output + shell: false, // IMPORTANT: No shell interpretation + env: { ...process.env, PATH: process.env.PATH } // Inherit PATH for finding commands + } + ); + + const output = sanitizeOutput(stdout || stderr || ''); + + // Replace the !command with the output + result = result.replace(fullMatch, fullMatch.startsWith('\n') ? '\n' + output : output); + } catch (error) { + if (error.killed) { + throw new Error(`Command timeout: ${commandString}`); + } + throw new Error(`Command failed: ${commandString} - ${error.message}`); + } + } + + return result; +} diff --git a/slash-command-fix-progress.md b/slash-command-fix-progress.md new file mode 100644 index 0000000..61bb310 --- /dev/null +++ b/slash-command-fix-progress.md @@ -0,0 +1,151 @@ +# Slash Command Execution Fix - Progress Report + +## Issue +Slash commands weren't executing when selected from the command menu. After typing a command like `/tm:list` and selecting it from the menu, nothing would happen - the page would stay on "Choose Your AI Assistant" screen. + +## Root Cause +The `handleCustomCommand` function was trying to call `handleSubmit` via a ref, but the ref wasn't being set properly. Originally attempted to set the ref inside `handleSubmit` itself, which meant it was only set AFTER the first submit - too late for command execution. + +## Solution Implemented +1. Converted `handleSubmit` to use `useCallback` with proper dependencies +2. Added a `useEffect` hook that runs after `handleSubmit` is defined to store it in `handleSubmitRef` +3. Now `handleCustomCommand` can access `handleSubmit` via the ref and call it with a fake event + +## Code Changes + +### File: src/components/ChatInterface.jsx + +**Added ref declaration (around line 1534):** +```javascript +// Ref to store handleSubmit so we can call it from handleCustomCommand +const handleSubmitRef = useRef(null); +``` + +**Modified handleCustomCommand (around line 1555):** +```javascript +// Set the input to the command content +setInput(content); + +// Wait for state to update, then directly call handleSubmit +setTimeout(() => { + if (handleSubmitRef.current) { + // Create a fake event to pass to handleSubmit + const fakeEvent = { preventDefault: () => {} }; + handleSubmitRef.current(fakeEvent); + } +}, 50); +``` + +**Converted handleSubmit to useCallback (line 3292):** +```javascript +const handleSubmit = useCallback(async (e) => { + e.preventDefault(); + if (!input.trim() || isLoading || !selectedProject) return; + // ... rest of function +}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); +``` + +**Added useEffect to store ref (line 3437):** +```javascript +// Store handleSubmit in ref so handleCustomCommand can access it +useEffect(() => { + handleSubmitRef.current = handleSubmit; +}, [handleSubmit]); +``` + +## Fixed Issues + +### 1. Commands Button Visibility ✅ FIXED +- **Problem**: Button was not showing in active chat sessions with provider selected +- **Root Cause**: Button was positioned at `right-14 sm:right-16` which overlapped with the clear button at `sm:right-28` +- **Solution**: Changed button position to `right-14 sm:right-36` to place it left of the clear button +- **File**: src/components/ChatInterface.jsx:4255 +- **Status**: Fixed in build dist/assets/index-CWRjcZ7A.js + +### 2. Slash Command Menu Positioning ✅ FIXED +- **Problem**: Mobile positioning was inconsistent - used wrong ref for bottom calculation +- **Root Cause**: Position calculation used `inputContainerRef` (permission mode selector) instead of `textareaRef` (actual input) +- **Solution**: + - Changed bottom calculation to use `textareaRef` instead of `inputContainerRef` + - Updated formula: `window.innerHeight - textareaRef.getBoundingClientRect().top + 8` + - Removed extra `+ 8` in CommandMenu.jsx since spacing is already in the calculation + - Added explicit `maxHeight: '300px'` to desktop positioning for consistency + - Mobile maxHeight now uses `min(50vh, 300px)` for better consistency +- **Files Modified**: + - src/components/ChatInterface.jsx:4132-4134 - Fixed bottom position calculation + - src/components/CommandMenu.jsx:30-46 - Improved positioning logic and max heights + +## Related Issues Found (Not Fixed Yet) + +### 3. Service Worker Caching Issue +- After building, the service worker caches old build files +- Requires manual unregistration of service worker on first load after build +- Causes 404 errors for old asset filenames (e.g., index-n_2V3_vw.js when new build has index-Wp3pq386.js) +- Need to implement proper cache busting or service worker update strategy + +### 4. Chat Screen Jumping +- Screen jumps/scrolls when Task Master widget appears/disappears +- Likely due to layout shifts from the task widget + +## Testing Status +- ✅ Slash command execution fix implemented and built +- ✅ Commands button visibility fix implemented and built +- ⏳ Not yet tested end-to-end due to service worker caching issues requiring manual cache clearing +- Need to test: + 1. Verify commands button is now visible to the left of clear button + 2. Click commands button to open menu + 3. Type `/tm:list` in chat input + 4. Select command from menu + 5. Verify command content loads and sends to Claude + 6. Verify session is created if none exists + +## Next Steps +1. Test the slash command button visibility fix +2. Test the slash command execution fix end-to-end +3. Fix service worker caching to enable easier testing +4. Fix chat screen jumping issue + +## Build Info +- Latest build: dist/assets/index-C5zDTo8x.js (657.55 kB) +- Commands button positioned at `right-14 sm:right-36` (mobile/desktop) +- Menu positioning uses `textareaRef` for accurate placement +- Mobile menu: `bottom` calculated from textarea top + 8px spacing +- Desktop menu: `top` calculated with 316px offset, max 300px height +- Server running on port 3001 +- Using Claude Agents SDK for Claude integration + +## Implementation Details + +### Mobile Positioning +```javascript +// ChatInterface.jsx - Position calculation +bottom: textareaRef.current + ? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8 + : 90 + +// CommandMenu.jsx - Mobile layout +{ + position: 'fixed', + bottom: `${inputBottom}px`, + left: '16px', + right: '16px', + maxHeight: 'min(50vh, 300px)' +} +``` + +### Desktop Positioning +```javascript +// ChatInterface.jsx - Position calculation +top: textareaRef.current + ? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316) + : 0 + +// CommandMenu.jsx - Desktop layout +{ + position: 'fixed', + top: `${calculatedTop}px`, + left: `${position.left}px`, + width: 'min(400px, calc(100vw - 32px))', + maxHeight: '300px' +} +``` diff --git a/src/App.jsx b/src/App.jsx index 0d3e413..fe01254 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -70,6 +70,10 @@ function AppContent() { // This allows us to restore the "Thinking..." banner when switching back to a processing session const [processingSessions, setProcessingSessions] = useState(new Set()); + // External Message Update Trigger: Incremented when external CLI modifies current session's JSONL + // Triggers ChatInterface to reload messages without switching sessions + const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); + const { ws, sendMessage, messages } = useWebSocketContext(); // Detect if running as PWA @@ -164,7 +168,32 @@ function AppContent() { const latestMessage = messages[messages.length - 1]; if (latestMessage.type === 'projects_updated') { - + + // External Session Update Detection: Check if the changed file is the current session's JSONL + // If so, and the session is not active, trigger a message reload in ChatInterface + if (latestMessage.changedFile && selectedSession && selectedProject) { + // Extract session ID from changedFile (format: "project-name/session-id.jsonl") + const changedFileParts = latestMessage.changedFile.split('/'); + if (changedFileParts.length >= 2) { + const filename = changedFileParts[changedFileParts.length - 1]; + const changedSessionId = filename.replace('.jsonl', ''); + + // Check if this is the currently-selected session + if (changedSessionId === selectedSession.id) { + const isSessionActive = activeSessions.has(selectedSession.id); + + if (!isSessionActive) { + // Session is not active - safe to reload messages + console.log('🔄 External CLI update detected for current session:', changedSessionId); + setExternalMessageUpdate(prev => prev + 1); + } else { + // Session is active - skip reload to avoid interrupting user + console.log('⏸️ External update paused - session is active:', changedSessionId); + } + } + } + } + // Session Protection Logic: Allow additions but prevent changes during active conversations // This allows new sessions/projects to appear in sidebar while protecting active chat messages // We check for two types of active sessions: @@ -332,7 +361,7 @@ function AppContent() { if (activeTab !== 'git' && activeTab !== 'preview') { 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'; @@ -340,9 +369,17 @@ function AppContent() { // Cursor sessions have persistent IDs sessionStorage.setItem('cursorSessionId', session.id); } - + + // Only close sidebar on mobile if switching to a different project if (isMobile) { - setSidebarOpen(false); + const sessionProjectName = session.__projectName; + const currentProjectName = selectedProject?.name; + + // Close sidebar if clicking a session from a different project + // Keep it open if clicking a session from the same project + if (sessionProjectName !== currentProjectName) { + setSidebarOpen(false); + } } navigate(`/session/${session.id}`); }; @@ -694,6 +731,7 @@ function AppContent() { showThinking={showThinking} autoScrollToBottom={autoScrollToBottom} sendByCtrlEnter={sendByCtrlEnter} + externalMessageUpdate={externalMessageUpdate} /> diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 3a3ee2c..d81a014 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -29,6 +29,8 @@ import ClaudeStatus from './ClaudeStatus'; import TokenUsagePie from './TokenUsagePie'; import { MicButton } from './MicButton.jsx'; import { api, authenticatedFetch } from '../utils/api'; +import Fuse from 'fuse.js'; +import CommandMenu from './CommandMenu'; // Helper function to decode HTML entities in text @@ -1078,48 +1080,83 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile )} - {message.type === 'assistant' ? ( -
- { - return inline ? ( - - {children} - - ) : ( -
- - {children} - -
- ); - }, - blockquote: ({children}) => ( -
- {children} -
- ), - a: ({href, children}) => ( - - {children} - - ), - p: ({children}) => ( -
- {children} + {(() => { + const content = formatUsageLimitText(String(message.content || '')); + + // Detect if content is pure JSON (starts with { or [) + const trimmedContent = content.trim(); + if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) && + (trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) { + try { + const parsed = JSON.parse(trimmedContent); + const formatted = JSON.stringify(parsed, null, 2); + + return ( +
+
+ + + + JSON Response
- ) - }} - > - {formatUsageLimitText(String(message.content || ''))} - -
- ) : ( -
- {formatUsageLimitText(String(message.content || ''))} -
- )} +
+
+                              
+                                {formatted}
+                              
+                            
+
+
+ ); + } catch (e) { + // Not valid JSON, fall through to normal rendering + } + } + + // Normal rendering for non-JSON content + return message.type === 'assistant' ? ( +
+ { + return inline ? ( + + {children} + + ) : ( +
+ + {children} + +
+ ); + }, + blockquote: ({children}) => ( +
+ {children} +
+ ), + a: ({href, children}) => ( + + {children} + + ), + p: ({children}) => ( +
+ {children} +
+ ) + }} + > + {content} +
+
+ ) : ( +
+ {content} +
+ ); + })()}
)} @@ -1178,7 +1215,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => { // - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID // // This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations. -function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, onTaskClick, onShowAllTasks }) { +function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) { const { tasksEnabled } = useTasksSettings(); const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { @@ -1210,10 +1247,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [imageErrors, setImageErrors] = useState(new Map()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); + const inputContainerRef = useRef(null); const scrollContainerRef = useRef(null); + const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls // Streaming throttle buffers const streamBufferRef = useRef(''); const streamTimerRef = useRef(null); + const commandQueryTimerRef = useRef(null); const [debouncedInput, setDebouncedInput] = useState(''); const [showFileDropdown, setShowFileDropdown] = useState(false); const [fileList, setFileList] = useState([]); @@ -1227,6 +1267,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [showCommandMenu, setShowCommandMenu] = useState(false); const [slashCommands, setSlashCommands] = useState([]); const [filteredCommands, setFilteredCommands] = useState([]); + const [commandQuery, setCommandQuery] = useState(''); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [tokenBudget, setTokenBudget] = useState(null); const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1); @@ -1239,6 +1280,18 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [cursorModel, setCursorModel] = useState(() => { return localStorage.getItem('cursor-model') || 'gpt-5'; }); + // Load permission mode for the current session + useEffect(() => { + if (selectedSession?.id) { + const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`); + if (savedMode) { + setPermissionMode(savedMode); + } else { + setPermissionMode('default'); + } + } + }, [selectedSession?.id]); + // When selecting a session from Sidebar, auto-switch provider to match session's origin useEffect(() => { if (selectedSession && selectedSession.__provider && selectedSession.__provider !== provider) { @@ -1276,6 +1329,344 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [provider]); + // Fetch slash commands on mount and when project changes + useEffect(() => { + const fetchCommands = async () => { + if (!selectedProject) return; + + try { + const response = await authenticatedFetch('/api/commands/list', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + projectPath: selectedProject.path + }) + }); + + if (!response.ok) { + throw new Error('Failed to fetch commands'); + } + + const data = await response.json(); + + // Combine built-in and custom commands + const allCommands = [ + ...(data.builtIn || []).map(cmd => ({ ...cmd, type: 'built-in' })), + ...(data.custom || []).map(cmd => ({ ...cmd, type: 'custom' })) + ]; + + setSlashCommands(allCommands); + + // Load command history from localStorage + const historyKey = `command_history_${selectedProject.name}`; + const history = safeLocalStorage.getItem(historyKey); + if (history) { + try { + const parsedHistory = JSON.parse(history); + // Sort commands by usage frequency + const sortedCommands = allCommands.sort((a, b) => { + const aCount = parsedHistory[a.name] || 0; + const bCount = parsedHistory[b.name] || 0; + return bCount - aCount; + }); + setSlashCommands(sortedCommands); + } catch (e) { + console.error('Error parsing command history:', e); + } + } + } catch (error) { + console.error('Error fetching slash commands:', error); + setSlashCommands([]); + } + }; + + fetchCommands(); + }, [selectedProject]); + + // Create Fuse instance for fuzzy search + const fuse = useMemo(() => { + if (!slashCommands.length) return null; + + return new Fuse(slashCommands, { + keys: [ + { name: 'name', weight: 2 }, + { name: 'description', weight: 1 } + ], + threshold: 0.4, + includeScore: true, + minMatchCharLength: 1 + }); + }, [slashCommands]); + + // Filter commands based on query + useEffect(() => { + if (!commandQuery) { + setFilteredCommands(slashCommands); + return; + } + + if (!fuse) { + setFilteredCommands([]); + return; + } + + const results = fuse.search(commandQuery); + setFilteredCommands(results.map(result => result.item)); + }, [commandQuery, slashCommands, fuse]); + + // Calculate frequently used commands + const frequentCommands = useMemo(() => { + if (!selectedProject || slashCommands.length === 0) return []; + + const historyKey = `command_history_${selectedProject.name}`; + const history = safeLocalStorage.getItem(historyKey); + + if (!history) return []; + + try { + const parsedHistory = JSON.parse(history); + + // Sort commands by usage count + const commandsWithUsage = slashCommands + .map(cmd => ({ + ...cmd, + usageCount: parsedHistory[cmd.name] || 0 + })) + .filter(cmd => cmd.usageCount > 0) + .sort((a, b) => b.usageCount - a.usageCount) + .slice(0, 5); // Top 5 most used + + return commandsWithUsage; + } catch (e) { + console.error('Error parsing command history:', e); + return []; + } + }, [selectedProject, slashCommands]); + + // Command selection callback with history tracking + const handleCommandSelect = useCallback((command, index, isHover) => { + if (!command || !selectedProject) return; + + // If hovering, just update the selected index + if (isHover) { + setSelectedCommandIndex(index); + return; + } + + // Update command history + const historyKey = `command_history_${selectedProject.name}`; + const history = safeLocalStorage.getItem(historyKey); + let parsedHistory = {}; + + try { + parsedHistory = history ? JSON.parse(history) : {}; + } catch (e) { + console.error('Error parsing command history:', e); + } + + parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1; + safeLocalStorage.setItem(historyKey, JSON.stringify(parsedHistory)); + + // Execute the command + executeCommand(command); + }, [selectedProject]); + + // Execute a command + const handleBuiltInCommand = useCallback((result) => { + const { action, data } = result; + + switch (action) { + case 'clear': + // Clear conversation history + setChatMessages([]); + setSessionMessages([]); + break; + + case 'help': + // Show help content + setChatMessages(prev => [...prev, { + role: 'assistant', + content: data.content, + timestamp: Date.now() + }]); + break; + + case 'model': + // Show model information + setChatMessages(prev => [...prev, { + role: 'assistant', + content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`, + timestamp: Date.now() + }]); + break; + + case 'cost': { + const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`; + setChatMessages(prev => [...prev, { role: 'assistant', content: costMessage, timestamp: Date.now() }]); + break; + } + + case 'status': { + const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; + setChatMessages(prev => [...prev, { role: 'assistant', content: statusMessage, timestamp: Date.now() }]); + break; + } + case 'memory': + // Show memory file info + if (data.error) { + setChatMessages(prev => [...prev, { + role: 'assistant', + content: `⚠️ ${data.message}`, + timestamp: Date.now() + }]); + } else { + setChatMessages(prev => [...prev, { + role: 'assistant', + content: `📝 ${data.message}\n\nPath: \`${data.path}\``, + timestamp: Date.now() + }]); + // Optionally open file in editor + if (data.exists && onFileOpen) { + onFileOpen(data.path); + } + } + break; + + case 'config': + // Open settings + if (onShowSettings) { + onShowSettings(); + } + break; + + case 'rewind': + // Rewind conversation + if (data.error) { + setChatMessages(prev => [...prev, { + role: 'assistant', + content: `⚠️ ${data.message}`, + timestamp: Date.now() + }]); + } else { + // Remove last N messages + setChatMessages(prev => prev.slice(0, -data.steps * 2)); // Remove user + assistant pairs + setChatMessages(prev => [...prev, { + role: 'assistant', + content: `⏪ ${data.message}`, + timestamp: Date.now() + }]); + } + break; + + default: + console.warn('Unknown built-in command action:', action); + } + }, [onFileOpen, onShowSettings]); + + // Ref to store handleSubmit so we can call it from handleCustomCommand + const handleSubmitRef = useRef(null); + + // Handle custom command execution + const handleCustomCommand = useCallback(async (result, args) => { + const { content, hasBashCommands, hasFileIncludes } = result; + + // Show confirmation for bash commands + if (hasBashCommands) { + const confirmed = window.confirm( + 'This command contains bash commands that will be executed. Do you want to proceed?' + ); + if (!confirmed) { + setChatMessages(prev => [...prev, { + role: 'assistant', + content: '❌ Command execution cancelled', + timestamp: Date.now() + }]); + return; + } + } + + // Set the input to the command content + setInput(content); + + // Wait for state to update, then directly call handleSubmit + setTimeout(() => { + if (handleSubmitRef.current) { + // Create a fake event to pass to handleSubmit + const fakeEvent = { preventDefault: () => {} }; + handleSubmitRef.current(fakeEvent); + } + }, 50); + }, []); + const executeCommand = useCallback(async (command) => { + if (!command || !selectedProject) return; + + try { + // Parse command and arguments from current input + const commandMatch = input.match(new RegExp(`${command.name}\\s*(.*)`)); + const args = commandMatch && commandMatch[1] + ? commandMatch[1].trim().split(/\s+/) + : []; + + // Prepare context for command execution + const context = { + projectPath: selectedProject.path, + projectName: selectedProject.name, + sessionId: currentSessionId, + provider, + model: provider === 'cursor' ? cursorModel : 'claude-sonnet-4.5', + tokenUsage: tokenBudget + }; + + // Call the execute endpoint + const response = await authenticatedFetch('/api/commands/execute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + commandName: command.name, + commandPath: command.path, + args, + context + }) + }); + + if (!response.ok) { + throw new Error('Failed to execute command'); + } + + const result = await response.json(); + + // Handle built-in commands + if (result.type === 'builtin') { + handleBuiltInCommand(result); + } else if (result.type === 'custom') { + // Handle custom commands - inject as system message + await handleCustomCommand(result, args); + } + + // Clear the input after successful execution + setInput(''); + setShowCommandMenu(false); + setSlashPosition(-1); + setCommandQuery(''); + setSelectedCommandIndex(-1); + + } catch (error) { + console.error('Error executing command:', error); + // Show error message to user + setChatMessages(prev => [...prev, { + role: 'assistant', + content: `Error executing command: ${error.message}`, + timestamp: Date.now() + }]); + } + }, [input, selectedProject, currentSessionId, provider, cursorModel, tokenBudget]); + + // Handle built-in command actions + // Memoized diff calculation to prevent recalculating on every render const createDiff = useMemo(() => { @@ -1733,8 +2124,18 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess content = decodeHtmlEntities(String(msg.message.content)); } - // Skip command messages and empty content - if (content && !content.startsWith('') && !content.startsWith('[Request interrupted')) { + // Skip command messages, system messages, and empty content + const shouldSkip = !content || + content.startsWith('') || + content.startsWith('') || + content.startsWith('') || + content.startsWith('') || + content.startsWith('') || + content.startsWith('Caveat:') || + content.startsWith('This session is being continued from a previous') || + content.startsWith('[Request interrupted'); + + if (!shouldSkip) { // Unescape double-escaped newlines and other escape sequences content = content.replace(/\\n/g, '\n') .replace(/\\t/g, '\t') @@ -1865,6 +2266,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess if (selectedSession && selectedProject) { const provider = localStorage.getItem('selected-provider') || 'claude'; + // Mark that we're loading a session to prevent multiple scroll triggers + isLoadingSessionRef.current = true; + // Only reset state if the session ID actually changed (not initial load) const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; @@ -1931,10 +2335,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false); 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); - } + // Scroll will be handled by the main scroll useEffect after messages are rendered } else { // Reset the flag after handling system session change setIsSystemSessionChange(false); @@ -1953,11 +2354,55 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setHasMoreMessages(false); setTotalMessages(0); } + + // Mark loading as complete after messages are set + // Use setTimeout to ensure state updates and DOM rendering are complete + setTimeout(() => { + isLoadingSessionRef.current = false; + }, 250); }; - + loadMessages(); }, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]); + // External Message Update Handler: Reload messages when external CLI modifies current session + // This triggers when App.jsx detects a JSONL file change for the currently-viewed session + // Only reloads if the session is NOT active (respecting Session Protection System) + useEffect(() => { + if (externalMessageUpdate > 0 && selectedSession && selectedProject) { + console.log('🔄 Reloading messages due to external CLI update'); + + const reloadExternalMessages = async () => { + try { + const provider = localStorage.getItem('selected-provider') || 'claude'; + + if (provider === 'cursor') { + // Reload Cursor messages from SQLite + const projectPath = selectedProject.fullPath || selectedProject.path; + const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); + setSessionMessages([]); + setChatMessages(converted); + } else { + // Reload Claude messages from API/JSONL + const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false); + setSessionMessages(messages); + // convertedMessages will be automatically updated via useMemo + + // Smart scroll behavior: only auto-scroll if user is near bottom + if (isNearBottom && autoScrollToBottom) { + setTimeout(() => scrollToBottom(), 200); + } + // If user scrolled up, preserve their position (they're reading history) + } + } catch (error) { + console.error('Error reloading messages from external update:', error); + } + }; + + reloadExternalMessages(); + } + }, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]); + // Update chatMessages when convertedMessages changes useEffect(() => { if (sessionMessages.length > 0) { @@ -2022,7 +2467,20 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Handle WebSocket messages if (messages.length > 0) { const latestMessage = messages[messages.length - 1]; - + console.log('🔵 WebSocket message received:', latestMessage.type, latestMessage); + + // Filter messages by session ID to prevent cross-session interference + // Skip filtering for global messages that apply to all sessions + const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete']; + const isGlobalMessage = globalMessageTypes.includes(latestMessage.type); + + // For new sessions (currentSessionId is null), allow messages through + if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) { + // Message is for a different session, ignore it + console.log('⏭️ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId); + return; + } + switch (latestMessage.type) { case 'session-created': // New session created by Claude CLI - we receive the real session ID here @@ -2040,8 +2498,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess break; case 'token-budget': - // Token budget is now fetched from API endpoint, ignore WebSocket data - console.log('📊 Ignoring WebSocket token budget (using API instead)'); + // Token budget now fetched via API after message completion instead of WebSocket + // This case is kept for compatibility but does nothing break; case 'claude-response': @@ -2429,11 +2887,36 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Get session ID from message or fall back to current session const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - // Only update UI state if this is the current session - if (completedSessionId === currentSessionId) { + console.log('🎯 claude-complete received:', { + completedSessionId, + currentSessionId, + match: completedSessionId === currentSessionId, + isNew: !currentSessionId + }); + + // Update UI state if this is the current session OR if we don't have a session ID yet (new session) + if (completedSessionId === currentSessionId || !currentSessionId) { + console.log('✅ Stopping loading state'); setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); + + // Fetch updated token usage after message completes + if (selectedProject && selectedSession?.id) { + const fetchUpdatedTokenUsage = async () => { + try { + const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; + const response = await authenticatedFetch(url); + if (response.ok) { + const data = await response.json(); + setTokenBudget(data); + } + } catch (error) { + console.error('Failed to fetch updated token usage:', error); + } + }; + fetchUpdatedTokenUsage(); + } } // Always mark the completed session as inactive and not processing @@ -2451,11 +2934,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { setCurrentSessionId(pendingSessionId); sessionStorage.removeItem('pendingSessionId'); - - // Trigger a project refresh to update the sidebar with the new session - if (window.refreshProjects) { - setTimeout(() => window.refreshProjects(), 500); - } + + // No need to manually refresh - projects_updated WebSocket message will handle it + console.log('✅ New session complete, ID set to:', pendingSessionId); } // Clear persisted chat messages after successful completion @@ -2464,7 +2945,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } break; - case 'session-aborted': + case 'session-aborted': { // Get session ID from message or fall back to current session const abortedSessionId = latestMessage.sessionId || currentSessionId; @@ -2491,6 +2972,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess timestamp: new Date() }]); break; + } + + case 'session-status': { + const statusSessionId = latestMessage.sessionId; + const isCurrentSession = statusSessionId === currentSessionId || + (selectedSession && statusSessionId === selectedSession.id); + if (isCurrentSession && latestMessage.isProcessing) { + // Session is currently processing, restore UI state + setIsLoading(true); + setCanAbortSession(true); + if (onSessionProcessing) { + onSessionProcessing(statusSessionId); + } + } + break; + } case 'session-status': // Response to check-session-status request @@ -2669,15 +3166,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]); - // Scroll to bottom when component mounts with existing messages or when messages first load + // Scroll to bottom when messages first load after session switch useEffect(() => { - if (scrollContainerRef.current && chatMessages.length > 0) { - // Always scroll to bottom when messages first load (user expects to see latest) + if (scrollContainerRef.current && chatMessages.length > 0 && !isLoadingSessionRef.current) { + // Only scroll if we're not in the middle of loading a session + // This prevents the "double scroll" effect during session switching // Also reset scroll state setIsUserScrolledUp(false); - setTimeout(() => scrollToBottom(), 200); // Longer delay to ensure full rendering + setTimeout(() => scrollToBottom(), 200); // Delay to ensure full rendering } - }, [chatMessages.length > 0, scrollToBottom]); // Trigger when messages first appear + }, [selectedSession?.id, selectedProject?.name]); // Only trigger when session/project changes // Add scroll event listener to detect user scrolling useEffect(() => { @@ -2688,7 +3186,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [handleScroll]); - // Initial textarea setup + // Initial textarea setup - set to 2 rows height useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; @@ -2709,120 +3207,56 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [input]); - // Poll token usage from JSONL file + // Load token usage when session changes (but don't poll to avoid conflicts with WebSocket) useEffect(() => { - console.log('🔍 Token usage polling effect triggered', { - sessionId: selectedSession?.id, - projectPath: selectedProject?.path - }); - - if (!selectedProject) { - console.log('⚠️ Skipping token usage fetch - missing project'); + if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { + // Reset for new/empty sessions + setTokenBudget(null); return; } - // No session selected - reset to zero (new session state) - if (!selectedSession) { - console.log('🆕 No session selected, resetting token budget to zero'); - setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 }); - return; - } - - // For new sessions without an ID yet, reset to zero - if (!selectedSession.id || selectedSession.id.startsWith('new-session-')) { - console.log('🆕 New session detected, resetting token budget to zero'); - setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 }); - return; - } - - // Create AbortController to cancel in-flight requests when session/project changes - let abortController = new AbortController(); - - const fetchTokenUsage = async () => { - // Abort previous request if still in flight - if (abortController.signal.aborted) { - abortController = new AbortController(); - } - - // Capture current session/project to verify before updating state - const currentSessionId = selectedSession.id; - const currentProjectPath = selectedProject.path; - + // Fetch token usage once when session loads + const fetchInitialTokenUsage = async () => { try { - const url = `/api/sessions/${currentSessionId}/token-usage?projectPath=${encodeURIComponent(currentProjectPath)}`; - console.log('📊 Fetching token usage from:', url); + const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; + console.log('📊 Fetching initial token usage from:', url); - const response = await authenticatedFetch(url, { - signal: abortController.signal - }); - - // Only update state if session/project hasn't changed - if (currentSessionId !== selectedSession?.id || currentProjectPath !== selectedProject?.path) { - console.log('⚠️ Session/project changed during fetch, discarding stale data'); - return; - } + const response = await authenticatedFetch(url); if (response.ok) { const data = await response.json(); - console.log('✅ Token usage data received:', data); + console.log('✅ Initial token usage loaded:', data); setTokenBudget(data); } else { - console.error('❌ Token usage fetch failed:', response.status, await response.text()); - // Reset to zero if fetch fails (likely new session with no JSONL yet) - setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 }); + console.log('⚠️ No token usage data available for this session yet'); + setTokenBudget(null); } } catch (error) { - // Don't log error if request was aborted (expected behavior) - if (error.name === 'AbortError') { - console.log('🚫 Token usage fetch aborted (session/project changed)'); - return; - } - console.error('Failed to fetch token usage:', error); - // Reset to zero on error - setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 }); + console.error('Failed to fetch initial token usage:', error); } }; - // Fetch immediately on mount/session change - fetchTokenUsage(); - - // Then poll every 5 seconds - const interval = setInterval(fetchTokenUsage, 5000); - - // Also fetch when page becomes visible (tab focus/refresh) - const handleVisibilityChange = () => { - if (!document.hidden) { - fetchTokenUsage(); - } - }; - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - // Abort any in-flight requests when effect cleans up - abortController.abort(); - clearInterval(interval); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; + fetchInitialTokenUsage(); }, [selectedSession?.id, selectedProject?.path]); const handleTranscript = useCallback((text) => { if (text.trim()) { setInput(prevInput => { const newInput = prevInput.trim() ? `${prevInput} ${text}` : text; - + // Update textarea height after setting new content setTimeout(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; - + // Check if expanded after transcript const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2; setIsTextareaExpanded(isExpanded); } }, 0); - + return newInput; }); } @@ -2905,7 +3339,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess noKeyboard: true }); - const handleSubmit = async (e) => { + const handleSubmit = useCallback(async (e) => { e.preventDefault(); if (!input.trim() || isLoading || !selectedProject) return; @@ -3038,21 +3472,91 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setUploadingImages(new Map()); setImageErrors(new Map()); setIsTextareaExpanded(false); - + // Reset textarea height - - if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } - + // Clear the saved draft since message was sent if (selectedProject) { safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); } + }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); + + // Store handleSubmit in ref so handleCustomCommand can access it + useEffect(() => { + handleSubmitRef.current = handleSubmit; + }, [handleSubmit]); + + const selectCommand = (command) => { + if (!command) return; + + // Prepare the input with command name and any arguments that were already typed + const textBeforeSlash = input.slice(0, slashPosition); + const textAfterSlash = input.slice(slashPosition); + const spaceIndex = textAfterSlash.indexOf(' '); + const textAfterQuery = spaceIndex !==-1 ? textAfterSlash.slice(spaceIndex) : ''; + + const newInput = textBeforeSlash + command.name + ' ' + textAfterQuery; + + // Update input temporarily so executeCommand can parse arguments + setInput(newInput); + + // Hide command menu + setShowCommandMenu(false); + setSlashPosition(-1); + setCommandQuery(''); + setSelectedCommandIndex(-1); + + // Clear debounce timer + if (commandQueryTimerRef.current) { + clearTimeout(commandQueryTimerRef.current); + } + + // Execute the command (which will load its content and send to Claude) + executeCommand(command); }; const handleKeyDown = (e) => { + // Handle command menu navigation + if (showCommandMenu && filteredCommands.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedCommandIndex(prev => + prev < filteredCommands.length - 1 ? prev + 1 : 0 + ); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedCommandIndex(prev => + prev > 0 ? prev - 1 : filteredCommands.length - 1 + ); + return; + } + if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault(); + if (selectedCommandIndex >= 0) { + selectCommand(filteredCommands[selectedCommandIndex]); + } else if (filteredCommands.length > 0) { + selectCommand(filteredCommands[0]); + } + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setShowCommandMenu(false); + setSlashPosition(-1); + setCommandQuery(''); + setSelectedCommandIndex(-1); + if (commandQueryTimerRef.current) { + clearTimeout(commandQueryTimerRef.current); + } + return; + } + } + // Handle file dropdown navigation if (showFileDropdown && filteredFiles.length > 0) { if (e.key === 'ArrowDown') { @@ -3085,13 +3589,19 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } } - // Handle Tab key for mode switching (only when file dropdown is not showing) - if (e.key === 'Tab' && !showFileDropdown) { + // Handle Tab key for mode switching (only when dropdowns are not showing) + if (e.key === 'Tab' && !showFileDropdown && !showCommandMenu) { e.preventDefault(); const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; const currentIndex = modes.indexOf(permissionMode); const nextIndex = (currentIndex + 1) % modes.length; - setPermissionMode(modes[nextIndex]); + const newMode = modes[nextIndex]; + setPermissionMode(newMode); + + // Save mode for this session + if (selectedSession?.id) { + localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode); + } return; } @@ -3156,13 +3666,74 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const handleInputChange = (e) => { const newValue = e.target.value; + const cursorPos = e.target.selectionStart; + + // Auto-select Claude provider if no session exists and user starts typing + if (!currentSessionId && newValue.trim() && provider === 'claude') { + // Provider is already set to 'claude' by default, so no need to change it + // The session will be created automatically when they submit + } + setInput(newValue); - setCursorPosition(e.target.selectionStart); - + setCursorPosition(cursorPos); + // Handle height reset when input becomes empty if (!newValue.trim()) { e.target.style.height = 'auto'; setIsTextareaExpanded(false); + setShowCommandMenu(false); + setSlashPosition(-1); + setCommandQuery(''); + return; + } + + // Detect slash command at cursor position + // Look backwards from cursor to find a slash that starts a command + const textBeforeCursor = newValue.slice(0, cursorPos); + + // Check if we're in a code block (simple heuristic: between triple backticks) + const backticksBefore = (textBeforeCursor.match(/```/g) || []).length; + const inCodeBlock = backticksBefore % 2 === 1; + + if (inCodeBlock) { + // Don't show command menu in code blocks + setShowCommandMenu(false); + setSlashPosition(-1); + setCommandQuery(''); + return; + } + + // Find the last slash before cursor that could start a command + // Slash is valid if it's at the start or preceded by whitespace + const slashPattern = /(^|\s)\/(\S*)$/; + const match = textBeforeCursor.match(slashPattern); + + if (match) { + const slashPos = match.index + match[1].length; // Position of the slash + const query = match[2]; // Text after the slash + + // Update states with debouncing for query + setSlashPosition(slashPos); + setShowCommandMenu(true); + setSelectedCommandIndex(-1); + + // Debounce the command query update + if (commandQueryTimerRef.current) { + clearTimeout(commandQueryTimerRef.current); + } + + commandQueryTimerRef.current = setTimeout(() => { + setCommandQuery(query); + }, 150); // 150ms debounce + } else { + // No slash command detected + setShowCommandMenu(false); + setSlashPosition(-1); + setCommandQuery(''); + + if (commandQueryTimerRef.current) { + clearTimeout(commandQueryTimerRef.current); + } } }; @@ -3193,7 +3764,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; const currentIndex = modes.indexOf(permissionMode); const nextIndex = (currentIndex + 1) % modes.length; - setPermissionMode(modes[nextIndex]); + const newMode = modes[nextIndex]; + setPermissionMode(newMode); + + // Save mode for this session + if (selectedSession?.id) { + localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode); + } }; // Don't render if no project is selected @@ -3458,15 +4035,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess }`}>
-
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */} -
+
+ )} + {/* Scroll to bottom button - positioned next to mode indicator */} {isUserScrolledUp && chatMessages.length > 0 && (