diff --git a/.env.example b/.env.example index d1f2fa9..6b34f69 100755 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ VITE_PORT=5173 # Uncomment the following line if you have a custom claude cli path other than the default "claude" # CLAUDE_CLI_PATH=claude + +# Claude Code context window size (maximum tokens per session) +# Note: VITE_ prefix makes it available to frontend +VITE_CONTEXT_WINDOW=160000 +CONTEXT_WINDOW=160000 diff --git a/.gitignore b/.gitignore index 84b56c6..0285301 100755 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ temp/ .taskmaster/ .cline/ .windsurf/ +.serena/ CLAUDE.md @@ -126,5 +127,5 @@ dev-debug.log # OS specific # Task files -# tasks.json -# tasks/ +tasks.json +tasks/ diff --git a/package-lock.json b/package-lock.json index eb3b591..44e046c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.8.12", "license": "MIT", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.13", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-javascript": "^6.2.4", @@ -16,11 +17,12 @@ "@codemirror/lang-markdown": "^6.3.3", "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.2", - "@siteboon/claude-code-ui": "^1.8.4", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", "@xterm/addon-clipboard": "^0.1.0", + "@xterm/addon-fit": "^0.10.0", "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.2.0", "chokidar": "^4.0.3", @@ -43,9 +45,7 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", - "ws": "^8.14.2", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" + "ws": "^8.14.2" }, "bin": { "claude-code-ui": "server/index.js" @@ -91,6 +91,26 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.13.tgz", + "integrity": "sha512-9/+/iVdVQx2o3INUxwNWuZQOhff3ISXgSc/G7jfD85qtEN/7ZK/uOnAtCH1PChMNBN5CGXgVKNMce++52tfZ5A==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1018,6 +1038,114 @@ "license": "MIT", "optional": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-ppc64": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", @@ -1052,6 +1180,22 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", @@ -1086,6 +1230,50 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, "node_modules/@img/sharp-linux-ppc64": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", @@ -1132,6 +1320,28 @@ "@img/sharp-libvips-linux-s390x": "1.2.0" } }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, "node_modules/@img/sharp-linuxmusl-arm64": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", @@ -1238,6 +1448,25 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", @@ -2506,50 +2735,6 @@ "win32" ] }, - "node_modules/@siteboon/claude-code-ui": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/@siteboon/claude-code-ui/-/claude-code-ui-1.8.4.tgz", - "integrity": "sha512-9moBlMDNF/6IfIcqShavxdq0TI9aNuY3+33YZcnvYagWsZMdJ/7d5tgDwAZEp3Uup/nHU+bdrkiXmFfLcRQLCQ==", - "license": "MIT", - "dependencies": { - "@codemirror/lang-css": "^6.3.1", - "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-javascript": "^6.2.4", - "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-markdown": "^6.3.3", - "@codemirror/lang-python": "^6.2.1", - "@codemirror/theme-one-dark": "^6.1.2", - "@tailwindcss/typography": "^0.5.16", - "@uiw/react-codemirror": "^4.23.13", - "@xterm/addon-clipboard": "^0.1.0", - "@xterm/addon-webgl": "^0.18.0", - "bcrypt": "^6.0.0", - "better-sqlite3": "^12.2.0", - "chokidar": "^4.0.3", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cors": "^2.8.5", - "cross-spawn": "^7.0.3", - "express": "^4.18.2", - "jsonwebtoken": "^9.0.2", - "lucide-react": "^0.515.0", - "mime-types": "^3.0.1", - "multer": "^2.0.1", - "node-fetch": "^2.7.0", - "node-pty": "^1.1.0-beta34", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", - "react-markdown": "^10.1.0", - "react-router-dom": "^6.8.1", - "sqlite": "^5.1.1", - "sqlite3": "^5.1.7", - "tailwind-merge": "^3.3.1", - "ws": "^8.14.2", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" - } - }, "node_modules/@tailwindcss/typography": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", @@ -2806,6 +2991,15 @@ "@xterm/xterm": "^5.4.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, "node_modules/@xterm/addon-webgl": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", @@ -2819,8 +3013,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/abbrev": { "version": "2.0.0", @@ -10674,18 +10867,18 @@ } }, "node_modules/vite": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz", - "integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==", + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.8.tgz", + "integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.6", - "picomatch": "^4.0.2", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rollup": "^4.40.0", - "tinyglobby": "^0.2.14" + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -10749,11 +10942,14 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -11038,23 +11234,6 @@ "node": ">=0.4" } }, - "node_modules/xterm": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", - "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", - "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", - "license": "MIT" - }, - "node_modules/xterm-addon-fit": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", - "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", - "deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.", - "license": "MIT", - "peerDependencies": { - "xterm": "^5.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -11184,6 +11363,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 91dec5d..468ee1e 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,8 @@ "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", "ws": "^8.14.2", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/public/clear-cache.html b/public/clear-cache.html new file mode 100644 index 0000000..47da67f --- /dev/null +++ b/public/clear-cache.html @@ -0,0 +1,85 @@ + + + + Clear Cache - Claude Code UI + + + +

Clear Cache & Service Worker

+

If you're seeing a blank page or old content, click the button below to clear all cached data.

+ + + +
+ + + + diff --git a/server/claude-cli.js b/server/claude-cli.js deleted file mode 100755 index 2e685d7..0000000 --- a/server/claude-cli.js +++ /dev/null @@ -1,397 +0,0 @@ -import { spawn } from 'child_process'; -import crossSpawn from 'cross-spawn'; -import { promises as fs } from 'fs'; -import path from 'path'; -import os from 'os'; - -// Use cross-spawn on Windows for better command execution -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; - -let activeClaudeProcesses = new Map(); // Track active processes by session ID - -async function spawnClaude(command, options = {}, ws) { - return new Promise(async (resolve, reject) => { - const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options; - let capturedSessionId = sessionId; // Track session ID throughout the process - let sessionCreatedSent = false; // Track if we've already sent session-created event - - // Use tools settings passed from frontend, or defaults - const settings = toolsSettings || { - allowedTools: [], - disallowedTools: [], - skipPermissions: false - }; - - // Build Claude CLI command - start with print/resume flags first - const args = []; - - // Use cwd (actual project directory) instead of projectPath (Claude's metadata directory) - const workingDir = cwd || process.cwd(); - - // Handle images by saving them to temporary files and passing paths to Claude - const tempImagePaths = []; - let tempDir = null; - if (images && images.length > 0) { - try { - // Create temp directory in the project directory so Claude can access it - tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString()); - await fs.mkdir(tempDir, { recursive: true }); - - // Save each image to a temp file - for (const [index, image] of images.entries()) { - // Extract base64 data and mime type - const matches = image.data.match(/^data:([^;]+);base64,(.+)$/); - if (!matches) { - console.error('Invalid image data format'); - continue; - } - - const [, mimeType, base64Data] = matches; - const extension = mimeType.split('/')[1] || 'png'; - const filename = `image_${index}.${extension}`; - const filepath = path.join(tempDir, filename); - - // Write base64 data to file - await fs.writeFile(filepath, Buffer.from(base64Data, 'base64')); - tempImagePaths.push(filepath); - } - - // Include the full image paths in the prompt for Claude to reference - // Only modify the command if we actually have images and a command - if (tempImagePaths.length > 0 && command && command.trim()) { - const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`; - const modifiedCommand = command + imageNote; - - // Update the command in args - now that --print and command are separate - const printIndex = args.indexOf('--print'); - if (printIndex !== -1 && printIndex + 1 < args.length && args[printIndex + 1] === command) { - args[printIndex + 1] = modifiedCommand; - } - } - - - } catch (error) { - console.error('Error processing images for Claude:', error); - } - } - - // Add resume flag if resuming - if (resume && sessionId) { - args.push('--resume', sessionId); - } - - // Add basic flags - args.push('--output-format', 'stream-json', '--verbose'); - - // Add MCP config flag only if MCP servers are configured - try { - console.log('🔍 Starting MCP config check...'); - // Use already imported modules (fs.promises is imported as fs, path, os) - const fsSync = await import('fs'); // Import synchronous fs methods - console.log('✅ Successfully imported fs sync methods'); - - // Check for MCP config in ~/.claude.json - const claudeConfigPath = path.join(os.homedir(), '.claude.json'); - - console.log(`🔍 Checking for MCP configs in: ${claudeConfigPath}`); - console.log(` Claude config exists: ${fsSync.existsSync(claudeConfigPath)}`); - - let hasMcpServers = false; - - // Check Claude config for MCP servers - if (fsSync.existsSync(claudeConfigPath)) { - try { - const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8')); - - // Check global MCP servers - if (claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0) { - console.log(`✅ Found ${Object.keys(claudeConfig.mcpServers).length} global MCP servers`); - hasMcpServers = true; - } - - // Check project-specific MCP servers - if (!hasMcpServers && claudeConfig.claudeProjects) { - const currentProjectPath = process.cwd(); - const projectConfig = claudeConfig.claudeProjects[currentProjectPath]; - if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) { - console.log(`✅ Found ${Object.keys(projectConfig.mcpServers).length} project MCP servers`); - hasMcpServers = true; - } - } - } catch (e) { - console.log(`❌ Failed to parse Claude config:`, e.message); - } - } - - console.log(`🔍 hasMcpServers result: ${hasMcpServers}`); - - if (hasMcpServers) { - // Use Claude config file if it has MCP servers - let configPath = null; - - if (fsSync.existsSync(claudeConfigPath)) { - try { - const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8')); - - // Check if we have any MCP servers (global or project-specific) - const hasGlobalServers = claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0; - const currentProjectPath = process.cwd(); - const projectConfig = claudeConfig.claudeProjects && claudeConfig.claudeProjects[currentProjectPath]; - const hasProjectServers = projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0; - - if (hasGlobalServers || hasProjectServers) { - configPath = claudeConfigPath; - } - } catch (e) { - // No valid config found - } - } - - if (configPath) { - console.log(`📡 Adding MCP config: ${configPath}`); - args.push('--mcp-config', configPath); - } else { - console.log('⚠️ MCP servers detected but no valid config file found'); - } - } - } catch (error) { - // If there's any error checking for MCP configs, don't add the flag - console.log('❌ MCP config check failed:', error.message); - console.log('📍 Error stack:', error.stack); - console.log('Note: MCP config check failed, proceeding without MCP support'); - } - - // Add model for new sessions - if (!resume) { - args.push('--model', 'sonnet'); - } - - // Add permission mode if specified (works for both new and resumed sessions) - if (permissionMode && permissionMode !== 'default') { - args.push('--permission-mode', permissionMode); - console.log('🔒 Using permission mode:', permissionMode); - } - - // Add tools settings flags - // Don't use --dangerously-skip-permissions when in plan mode - if (settings.skipPermissions && permissionMode !== 'plan') { - args.push('--dangerously-skip-permissions'); - console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)'); - } else { - // Only add allowed/disallowed tools if not skipping permissions - - // Collect all allowed tools, including plan mode defaults - let allowedTools = [...(settings.allowedTools || [])]; - - // Add plan mode specific tools - if (permissionMode === 'plan') { - const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite']; - // Add plan mode tools that aren't already in the allowed list - for (const tool of planModeTools) { - if (!allowedTools.includes(tool)) { - allowedTools.push(tool); - } - } - console.log('📝 Plan mode: Added default allowed tools:', planModeTools); - } - - // Add allowed tools - if (allowedTools.length > 0) { - for (const tool of allowedTools) { - args.push('--allowedTools', tool); - console.log('✅ Allowing tool:', tool); - } - } - - // Add disallowed tools - if (settings.disallowedTools && settings.disallowedTools.length > 0) { - for (const tool of settings.disallowedTools) { - args.push('--disallowedTools', tool); - console.log('❌ Disallowing tool:', tool); - } - } - - // Log when skip permissions is disabled due to plan mode - if (settings.skipPermissions && permissionMode === 'plan') { - console.log('📝 Skip permissions disabled due to plan mode'); - } - } - - // Add print flag with command if we have a command - if (command && command.trim()) { - - // Separate arguments for better cross-platform compatibility - // This prevents issues with spaces and quotes on Windows - args.push('--print'); - // Use `--` so user input is always treated as text, not options - args.push('--'); - args.push(command); - } - - console.log('Spawning Claude CLI:', 'claude', args.map(arg => { - const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r'); - return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg; - }).join(' ')); - console.log('Working directory:', workingDir); - console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); - console.log('🔍 Full command args:', JSON.stringify(args, null, 2)); - console.log('🔍 Final Claude command will be: claude ' + args.join(' ')); - - // Use Claude CLI from environment variable or default to 'claude' - const claudePath = process.env.CLAUDE_CLI_PATH || 'claude'; - console.log('🔍 Using Claude CLI path:', claudePath); - - const claudeProcess = spawnFunction(claudePath, args, { - cwd: workingDir, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env } // Inherit all environment variables - }); - - // Attach temp file info to process for cleanup later - claudeProcess.tempImagePaths = tempImagePaths; - claudeProcess.tempDir = tempDir; - - // Store process reference for potential abort - const processKey = capturedSessionId || sessionId || Date.now().toString(); - activeClaudeProcesses.set(processKey, claudeProcess); - - // Handle stdout (streaming JSON responses) - claudeProcess.stdout.on('data', (data) => { - const rawOutput = data.toString(); - console.log('📤 Claude CLI stdout:', rawOutput); - - const lines = rawOutput.split('\n').filter(line => line.trim()); - - for (const line of lines) { - try { - const response = JSON.parse(line); - console.log('📄 Parsed JSON response:', response); - - // Capture session ID if it's in the response - if (response.session_id && !capturedSessionId) { - capturedSessionId = response.session_id; - console.log('📝 Captured session ID:', capturedSessionId); - - // Update process key with captured session ID - if (processKey !== capturedSessionId) { - activeClaudeProcesses.delete(processKey); - activeClaudeProcesses.set(capturedSessionId, claudeProcess); - } - - // Send session-created event only once for new sessions - if (!sessionId && !sessionCreatedSent) { - sessionCreatedSent = true; - ws.send(JSON.stringify({ - type: 'session-created', - sessionId: capturedSessionId - })); - } - } - - // Send parsed response to WebSocket - ws.send(JSON.stringify({ - type: 'claude-response', - data: response - })); - } catch (parseError) { - console.log('📄 Non-JSON response:', line); - // If not JSON, send as raw text - ws.send(JSON.stringify({ - type: 'claude-output', - data: line - })); - } - } - }); - - // Handle stderr - claudeProcess.stderr.on('data', (data) => { - console.error('Claude CLI stderr:', data.toString()); - ws.send(JSON.stringify({ - type: 'claude-error', - error: data.toString() - })); - }); - - // Handle process completion - claudeProcess.on('close', async (code) => { - console.log(`Claude CLI process exited with code ${code}`); - - // Clean up process reference - const finalSessionId = capturedSessionId || sessionId || processKey; - activeClaudeProcesses.delete(finalSessionId); - - ws.send(JSON.stringify({ - type: 'claude-complete', - exitCode: code, - isNewSession: !sessionId && !!command // Flag to indicate this was a new session - })); - - // Clean up temporary image files if any - if (claudeProcess.tempImagePaths && claudeProcess.tempImagePaths.length > 0) { - for (const imagePath of claudeProcess.tempImagePaths) { - await fs.unlink(imagePath).catch(err => - console.error(`Failed to delete temp image ${imagePath}:`, err) - ); - } - if (claudeProcess.tempDir) { - await fs.rm(claudeProcess.tempDir, { recursive: true, force: true }).catch(err => - console.error(`Failed to delete temp directory ${claudeProcess.tempDir}:`, err) - ); - } - } - - if (code === 0) { - resolve(); - } else { - reject(new Error(`Claude CLI exited with code ${code}`)); - } - }); - - // Handle process errors - claudeProcess.on('error', (error) => { - console.error('Claude CLI process error:', error); - - // Clean up process reference on error - const finalSessionId = capturedSessionId || sessionId || processKey; - activeClaudeProcesses.delete(finalSessionId); - - ws.send(JSON.stringify({ - type: 'claude-error', - error: error.message - })); - - reject(error); - }); - - // Handle stdin for interactive mode - if (command) { - // For --print mode with arguments, we don't need to write to stdin - claudeProcess.stdin.end(); - } else { - // For interactive mode, we need to write the command to stdin if provided later - // Keep stdin open for interactive session - if (command !== undefined) { - claudeProcess.stdin.write(command + '\n'); - claudeProcess.stdin.end(); - } - // If no command provided, stdin stays open for interactive use - } - }); -} - -function abortClaudeSession(sessionId) { - const process = activeClaudeProcesses.get(sessionId); - if (process) { - console.log(`🛑 Aborting Claude session: ${sessionId}`); - process.kill('SIGTERM'); - activeClaudeProcesses.delete(sessionId); - return true; - } - return false; -} - -export { - spawnClaude, - abortClaudeSession -}; diff --git a/server/claude-sdk.js b/server/claude-sdk.js new file mode 100644 index 0000000..2fd96dd --- /dev/null +++ b/server/claude-sdk.js @@ -0,0 +1,499 @@ +/** + * Claude SDK Integration + * + * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk. + * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance + * and maintainability. + * + * Key features: + * - Direct SDK integration without child processes + * - Session management with abort capability + * - Options mapping between CLI and SDK formats + * - WebSocket message streaming + */ + +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +// Session tracking: Map of session IDs to active query instances +const activeSessions = new Map(); + +/** + * Maps CLI options to SDK-compatible options format + * @param {Object} options - CLI options + * @returns {Object} SDK-compatible options + */ +function mapCliOptionsToSDK(options = {}) { + const { sessionId, cwd, toolsSettings, permissionMode, images } = options; + + const sdkOptions = {}; + + // Map working directory + if (cwd) { + sdkOptions.cwd = cwd; + } + + // Map permission mode + if (permissionMode && permissionMode !== 'default') { + sdkOptions.permissionMode = permissionMode; + } + + // Map tool settings + const settings = toolsSettings || { + allowedTools: [], + disallowedTools: [], + skipPermissions: false + }; + + // Handle tool permissions + if (settings.skipPermissions && permissionMode !== 'plan') { + // When skipping permissions, use bypassPermissions mode + sdkOptions.permissionMode = 'bypassPermissions'; + } else { + // Map allowed tools + let allowedTools = [...(settings.allowedTools || [])]; + + // Add plan mode default tools + if (permissionMode === 'plan') { + const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite']; + for (const tool of planModeTools) { + if (!allowedTools.includes(tool)) { + allowedTools.push(tool); + } + } + } + + if (allowedTools.length > 0) { + sdkOptions.allowedTools = allowedTools; + } + + // Map disallowed tools + if (settings.disallowedTools && settings.disallowedTools.length > 0) { + sdkOptions.disallowedTools = settings.disallowedTools; + } + } + + // Map model (default to sonnet) + // Map model (default to sonnet) + sdkOptions.model = options.model || 'sonnet'; + + // Map resume session + if (sessionId) { + sdkOptions.resume = sessionId; + } + + return sdkOptions; +} + +/** + * Adds a session to the active sessions map + * @param {string} sessionId - Session identifier + * @param {Object} queryInstance - SDK query instance + * @param {Array} tempImagePaths - Temp image file paths for cleanup + * @param {string} tempDir - Temp directory for cleanup + */ +function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) { + activeSessions.set(sessionId, { + instance: queryInstance, + startTime: Date.now(), + status: 'active', + tempImagePaths, + tempDir + }); +} + +/** + * Removes a session from the active sessions map + * @param {string} sessionId - Session identifier + */ +function removeSession(sessionId) { + activeSessions.delete(sessionId); +} + +/** + * Gets a session from the active sessions map + * @param {string} sessionId - Session identifier + * @returns {Object|undefined} Session data or undefined + */ +function getSession(sessionId) { + return activeSessions.get(sessionId); +} + +/** + * Gets all active session IDs + * @returns {Array} Array of active session IDs + */ +function getAllSessions() { + return Array.from(activeSessions.keys()); +} + +/** + * Transforms SDK messages to WebSocket format expected by frontend + * @param {Object} sdkMessage - SDK message object + * @returns {Object} Transformed message ready for WebSocket + */ +function transformMessage(sdkMessage) { + // SDK messages are already in a format compatible with the frontend + // The CLI sends them wrapped in {type: 'claude-response', data: message} + // We'll do the same here to maintain compatibility + return sdkMessage; +} + +/** + * Extracts token usage from SDK result messages + * @param {Object} resultMessage - SDK result message + * @returns {Object|null} Token budget object or null + */ +function extractTokenBudget(resultMessage) { + if (resultMessage.type !== 'result' || !resultMessage.modelUsage) { + return null; + } + + // Get the first model's usage data (same as CLI implementation) + const modelKey = Object.keys(resultMessage.modelUsage)[0]; + const modelData = resultMessage.modelUsage[modelKey]; + + if (!modelData || !modelData.contextWindow) { + 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; + + // Total used = input + output + cache tokens + const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens; + const contextWindow = modelData.contextWindow; + + return { + used: totalUsed, + total: contextWindow + }; +} + +/** + * Handles image processing for SDK queries + * Saves base64 images to temporary files and returns modified prompt with file paths + * @param {string} command - Original user prompt + * @param {Array} images - Array of image objects with base64 data + * @param {string} cwd - Working directory for temp file creation + * @returns {Promise} {modifiedCommand, tempImagePaths, tempDir} + */ +async function handleImages(command, images, cwd) { + const tempImagePaths = []; + let tempDir = null; + + if (!images || images.length === 0) { + return { modifiedCommand: command, tempImagePaths, tempDir }; + } + + try { + // Create temp directory in the project directory + const workingDir = cwd || process.cwd(); + tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString()); + await fs.mkdir(tempDir, { recursive: true }); + + // Save each image to a temp file + for (const [index, image] of images.entries()) { + // Extract base64 data and mime type + const matches = image.data.match(/^data:([^;]+);base64,(.+)$/); + if (!matches) { + console.error('Invalid image data format'); + continue; + } + + const [, mimeType, base64Data] = matches; + const extension = mimeType.split('/')[1] || 'png'; + const filename = `image_${index}.${extension}`; + const filepath = path.join(tempDir, filename); + + // Write base64 data to file + await fs.writeFile(filepath, Buffer.from(base64Data, 'base64')); + tempImagePaths.push(filepath); + } + + // Include the full image paths in the prompt + let modifiedCommand = command; + if (tempImagePaths.length > 0 && command && command.trim()) { + const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`; + modifiedCommand = command + imageNote; + } + + console.log(`📸 Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`); + return { modifiedCommand, tempImagePaths, tempDir }; + } catch (error) { + console.error('Error processing images for SDK:', error); + return { modifiedCommand: command, tempImagePaths, tempDir }; + } +} + +/** + * Cleans up temporary image files + * @param {Array} tempImagePaths - Array of temp file paths to delete + * @param {string} tempDir - Temp directory to remove + */ +async function cleanupTempFiles(tempImagePaths, tempDir) { + if (!tempImagePaths || tempImagePaths.length === 0) { + return; + } + + try { + // Delete individual temp files + for (const imagePath of tempImagePaths) { + await fs.unlink(imagePath).catch(err => + console.error(`Failed to delete temp image ${imagePath}:`, err) + ); + } + + // Delete temp directory + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }).catch(err => + console.error(`Failed to delete temp directory ${tempDir}:`, err) + ); + } + + console.log(`🧹 Cleaned up ${tempImagePaths.length} temp image files`); + } catch (error) { + console.error('Error during temp file cleanup:', error); + } +} + +/** + * Loads MCP server configurations from ~/.claude.json + * @param {string} cwd - Current working directory for project-specific configs + * @returns {Object|null} MCP servers object or null if none found + */ +async function loadMcpConfig(cwd) { + try { + const claudeConfigPath = path.join(os.homedir(), '.claude.json'); + + // Check if config file exists + try { + await fs.access(claudeConfigPath); + } catch (error) { + // File doesn't exist, return null + console.log('📡 No ~/.claude.json found, proceeding without MCP servers'); + return null; + } + + // Read and parse config file + let claudeConfig; + try { + const configContent = await fs.readFile(claudeConfigPath, 'utf8'); + claudeConfig = JSON.parse(configContent); + } catch (error) { + console.error('❌ Failed to parse ~/.claude.json:', error.message); + return null; + } + + // Extract MCP servers (merge global and project-specific) + let mcpServers = {}; + + // Add global MCP servers + if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') { + mcpServers = { ...claudeConfig.mcpServers }; + console.log(`📡 Loaded ${Object.keys(mcpServers).length} global MCP servers`); + } + + // Add/override with project-specific MCP servers + if (claudeConfig.claudeProjects && cwd) { + const projectConfig = claudeConfig.claudeProjects[cwd]; + if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') { + mcpServers = { ...mcpServers, ...projectConfig.mcpServers }; + console.log(`📡 Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`); + } + } + + // Return null if no servers found + if (Object.keys(mcpServers).length === 0) { + console.log('📡 No MCP servers configured'); + return null; + } + + console.log(`✅ Total MCP servers loaded: ${Object.keys(mcpServers).length}`); + return mcpServers; + } catch (error) { + console.error('❌ Error loading MCP config:', error.message); + return null; + } +} + +/** + * Executes a Claude query using the SDK + * @param {string} command - User prompt/command + * @param {Object} options - Query options + * @param {Object} ws - WebSocket connection + * @returns {Promise} + */ +async function queryClaudeSDK(command, options = {}, ws) { + const { sessionId } = options; + let capturedSessionId = sessionId; + let sessionCreatedSent = false; + let tempImagePaths = []; + let tempDir = null; + + try { + // Map CLI options to SDK format + const sdkOptions = mapCliOptionsToSDK(options); + + // Load MCP configuration + const mcpServers = await loadMcpConfig(options.cwd); + if (mcpServers) { + sdkOptions.mcpServers = mcpServers; + } + + // Handle images - save to temp files and modify prompt + const imageResult = await handleImages(command, options.images, options.cwd); + const finalCommand = imageResult.modifiedCommand; + tempImagePaths = imageResult.tempImagePaths; + tempDir = imageResult.tempDir; + + // Create SDK query instance + const queryInstance = query({ + prompt: finalCommand, + options: sdkOptions + }); + + // Track the query instance for abort capability + if (capturedSessionId) { + addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); + } + + // Process streaming messages + for await (const message of queryInstance) { + // Capture session ID from first message + if (message.session_id && !capturedSessionId) { + capturedSessionId = message.session_id; + addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); + + // Send session-created event only once for new sessions + if (!sessionId && !sessionCreatedSent) { + sessionCreatedSent = true; + ws.send(JSON.stringify({ + type: 'session-created', + sessionId: capturedSessionId + })); + } + } + + // Transform and send message to WebSocket + const transformedMessage = transformMessage(message); + ws.send(JSON.stringify({ + type: 'claude-response', + data: transformedMessage + })); + + // Extract and send token budget updates from result messages + if (message.type === 'result') { + const tokenBudget = extractTokenBudget(message); + if (tokenBudget) { + console.log('📊 Token budget from modelUsage:', tokenBudget); + ws.send(JSON.stringify({ + type: 'token-budget', + data: tokenBudget + })); + } + } + } + + // Clean up session on completion + if (capturedSessionId) { + removeSession(capturedSessionId); + } + + // Clean up temporary image files + await cleanupTempFiles(tempImagePaths, tempDir); + + // Send completion event + ws.send(JSON.stringify({ + type: 'claude-complete', + sessionId: capturedSessionId, + exitCode: 0, + isNewSession: !sessionId && !!command + })); + + } catch (error) { + console.error('SDK query error:', error); + + // Clean up session on error + if (capturedSessionId) { + removeSession(capturedSessionId); + } + + // Clean up temporary image files on error + await cleanupTempFiles(tempImagePaths, tempDir); + + // Send error to WebSocket + ws.send(JSON.stringify({ + type: 'claude-error', + error: error.message + })); + + throw error; + } +} + +/** + * Aborts an active SDK session + * @param {string} sessionId - Session identifier + * @returns {boolean} True if session was aborted, false if not found + */ +async function abortClaudeSDKSession(sessionId) { + const session = getSession(sessionId); + + if (!session) { + console.log(`Session ${sessionId} not found`); + return false; + } + + try { + console.log(`🛑 Aborting SDK session: ${sessionId}`); + + // Call interrupt() on the query instance + await session.instance.interrupt(); + + // Update session status + session.status = 'aborted'; + + // Clean up temporary image files + await cleanupTempFiles(session.tempImagePaths, session.tempDir); + + // Clean up session + removeSession(sessionId); + + return true; + } catch (error) { + console.error(`Error aborting session ${sessionId}:`, error); + return false; + } +} + +/** + * Checks if an SDK session is currently active + * @param {string} sessionId - Session identifier + * @returns {boolean} True if session is active + */ +function isClaudeSDKSessionActive(sessionId) { + const session = getSession(sessionId); + return session && session.status === 'active'; +} + +/** + * Gets all active SDK session IDs + * @returns {Array} Array of active session IDs + */ +function getActiveClaudeSDKSessions() { + return getAllSessions(); +} + +// Export public API +export { + queryClaudeSDK, + abortClaudeSDKSession, + isClaudeSDKSessionActive, + getActiveClaudeSDKSessions +}; diff --git a/server/cursor-cli.js b/server/cursor-cli.js index be471f9..2b0cd70 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -159,6 +159,7 @@ async function spawnCursor(command, options = {}, ws) { // Send completion event ws.send(JSON.stringify({ type: 'cursor-result', + sessionId: capturedSessionId || sessionId, data: response, success: response.subtype === 'success' })); @@ -198,9 +199,10 @@ async function spawnCursor(command, options = {}, ws) { // Clean up process reference const finalSessionId = capturedSessionId || sessionId || processKey; activeCursorProcesses.delete(finalSessionId); - + ws.send(JSON.stringify({ type: 'claude-complete', + sessionId: finalSessionId, exitCode: code, isNewSession: !sessionId && !!command // Flag to indicate this was a new session })); @@ -244,7 +246,17 @@ function abortCursorSession(sessionId) { return false; } +function isCursorSessionActive(sessionId) { + return activeCursorProcesses.has(sessionId); +} + +function getActiveCursorSessions() { + return Array.from(activeCursorProcesses.keys()); +} + export { spawnCursor, - abortCursorSession + abortCursorSession, + isCursorSessionActive, + getActiveCursorSessions }; \ No newline at end of file diff --git a/server/index.js b/server/index.js index 2b7b260..176a22f 100755 --- a/server/index.js +++ b/server/index.js @@ -28,18 +28,18 @@ console.log('PORT from env:', process.env.PORT); import express from 'express'; import { WebSocketServer } from 'ws'; +import os from 'os'; import http from 'http'; import cors from 'cors'; import { promises as fsPromises } from 'fs'; import { spawn } from 'child_process'; -import os from 'os'; import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; -import { spawnClaude, abortClaudeSession } from './claude-cli.js'; -import { spawnCursor, abortCursorSession } from './cursor-cli.js'; +import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js'; +import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import mcpRoutes from './routes/mcp.js'; @@ -550,7 +550,9 @@ function handleChatConnection(ws) { console.log('💬 User message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.projectPath || 'Unknown'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); - await spawnClaude(data.command, data.options, ws); + + // Use Claude Agents SDK + await queryClaudeSDK(data.command, data.options, ws); } else if (data.type === 'cursor-command') { console.log('🖱️ Cursor message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.cwd || 'Unknown'); @@ -568,9 +570,15 @@ function handleChatConnection(ws) { } else if (data.type === 'abort-session') { console.log('🛑 Abort session request:', data.sessionId); const provider = data.provider || 'claude'; - const success = provider === 'cursor' - ? abortCursorSession(data.sessionId) - : abortClaudeSession(data.sessionId); + let success; + + if (provider === 'cursor') { + success = abortCursorSession(data.sessionId); + } else { + // Use Claude Agents SDK + success = await abortClaudeSDKSession(data.sessionId); + } + ws.send(JSON.stringify({ type: 'session-aborted', sessionId: data.sessionId, @@ -586,6 +594,35 @@ function handleChatConnection(ws) { provider: 'cursor', success })); + } else if (data.type === 'check-session-status') { + // Check if a specific session is currently processing + const provider = data.provider || 'claude'; + const sessionId = data.sessionId; + let isActive; + + if (provider === 'cursor') { + isActive = isCursorSessionActive(sessionId); + } else { + // Use Claude Agents SDK + isActive = isClaudeSDKSessionActive(sessionId); + } + + ws.send(JSON.stringify({ + type: 'session-status', + sessionId, + provider, + isProcessing: isActive + })); + } else if (data.type === 'get-active-sessions') { + // Get all currently active sessions + const activeSessions = { + claude: getActiveClaudeSDKSessions(), + cursor: getActiveCursorSessions() + }; + ws.send(JSON.stringify({ + type: 'active-sessions', + sessions: activeSessions + })); } } catch (error) { console.error('❌ Chat WebSocket error:', error.message); @@ -1053,13 +1090,87 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r } }); -// Serve React app for all other routes +// 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) => { + try { + const { sessionId } = req.params; + const { projectPath } = req.query; + + if (!projectPath) { + return res.status(400).json({ error: 'projectPath query parameter is required' }); + } + + // 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`); + + // Check if file exists + if (!fs.existsSync(jsonlPath)) { + return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); + } + + // Read and parse the JSONL file + const fileContent = fs.readFileSync(jsonlPath, 'utf8'); + const lines = fileContent.trim().split('\n'); + + const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); + const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000; + let inputTokens = 0; + let cacheCreationTokens = 0; + let cacheReadTokens = 0; + + // Find the latest assistant message with usage data (scan from end) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + + // Only count assistant messages which have usage data + if (entry.type === 'assistant' && entry.message?.usage) { + const usage = entry.message.usage; + + // Use token counts from latest assistant message only + inputTokens = usage.input_tokens || 0; + cacheCreationTokens = usage.cache_creation_input_tokens || 0; + cacheReadTokens = usage.cache_read_input_tokens || 0; + + break; // Stop after finding the latest assistant message + } + } catch (parseError) { + // Skip lines that can't be parsed + continue; + } + } + + // Calculate total context usage (excluding output_tokens, as per ccusage) + const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens; + + res.json({ + used: totalUsed, + total: contextWindow, + breakdown: { + input: inputTokens, + cacheCreation: cacheCreationTokens, + cacheRead: cacheReadTokens + } + }); + } catch (error) { + console.error('Error reading session token usage:', error); + res.status(500).json({ error: 'Failed to read session token usage' }); + } +}); + +// Serve React app for all other routes (excluding static files) app.get('*', (req, res) => { + // 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') { res.sendFile(path.join(__dirname, '../dist/index.html')); } else { // In development, redirect to Vite dev server - res.redirect(`http://localhost:${process.env.VITE_PORT || 3001}`); + res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`); } }); @@ -1153,11 +1264,14 @@ async function startServer() { await initializeDatabase(); console.log('✅ Database initialization skipped (testing)'); + // Log Claude implementation mode + console.log('🚀 Using Claude Agents SDK for Claude integration'); + server.listen(PORT, '0.0.0.0', async () => { console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`); // Start watching the projects folder for changes - await setupProjectsWatcher(); + await setupProjectsWatcher(); }); } catch (error) { console.error('❌ Failed to start server:', error); diff --git a/src/App.jsx b/src/App.jsx index 5024680..0d3e413 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -57,6 +57,7 @@ function AppContent() { const [showQuickSettings, setShowQuickSettings] = useState(false); const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false); const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false); + const [showThinking, setShowThinking] = useLocalStorage('showThinking', true); const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true); const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false); // Session Protection System: Track sessions with active conversations to prevent @@ -64,7 +65,11 @@ function AppContent() { // a message, the session is marked as "active" and project updates are paused // until the conversation completes or is aborted. const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations - + + // Processing Sessions: Track which sessions are currently thinking/processing + // This allows us to restore the "Thinking..." banner when switching back to a processing session + const [processingSessions, setProcessingSessions] = useState(new Set()); + const { ws, sendMessage, messages } = useWebSocketContext(); // Detect if running as PWA @@ -186,13 +191,16 @@ function AppContent() { // Update projects state with the new data from WebSocket const updatedProjects = latestMessage.projects; setProjects(updatedProjects); - + // Update selected project if it exists in the updated projects if (selectedProject) { const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name); if (updatedSelectedProject) { - setSelectedProject(updatedSelectedProject); - + // Only update selected project if it actually changed - prevents flickering + if (JSON.stringify(updatedSelectedProject) !== JSON.stringify(selectedProject)) { + setSelectedProject(updatedSelectedProject); + } + // Update selected session only if it was deleted - avoid unnecessary reloads if (selectedSession) { const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id); @@ -454,6 +462,26 @@ function AppContent() { } }; + // Processing Session Functions: Track which sessions are currently thinking/processing + + // markSessionAsProcessing: Called when Claude starts thinking/processing + const markSessionAsProcessing = (sessionId) => { + if (sessionId) { + setProcessingSessions(prev => new Set([...prev, sessionId])); + } + }; + + // markSessionAsNotProcessing: Called when Claude finishes thinking/processing + const markSessionAsNotProcessing = (sessionId) => { + if (sessionId) { + setProcessingSessions(prev => { + const newSet = new Set(prev); + newSet.delete(sessionId); + return newSet; + }); + } + }; + // replaceTemporarySession: Called when WebSocket provides real session ID for new sessions // Removes temporary "new-session-*" identifiers and adds the real session ID // This maintains protection continuity during the transition from temporary to real session @@ -655,11 +683,15 @@ function AppContent() { onInputFocusChange={setIsInputFocused} onSessionActive={markSessionAsActive} onSessionInactive={markSessionAsInactive} + onSessionProcessing={markSessionAsProcessing} + onSessionNotProcessing={markSessionAsNotProcessing} + processingSessions={processingSessions} onReplaceTemporarySession={replaceTemporarySession} onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)} onShowSettings={() => setShowSettings(true)} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} + showThinking={showThinking} autoScrollToBottom={autoScrollToBottom} sendByCtrlEnter={sendByCtrlEnter} /> @@ -682,6 +714,8 @@ function AppContent() { onAutoExpandChange={setAutoExpandTools} showRawParameters={showRawParameters} onShowRawParametersChange={setShowRawParameters} + showThinking={showThinking} + onShowThinkingChange={setShowThinking} autoScrollToBottom={autoScrollToBottom} onAutoScrollChange={setAutoScrollToBottom} sendByCtrlEnter={sendByCtrlEnter} diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 1be7481..3a3ee2c 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -26,10 +26,22 @@ import NextTaskBanner from './NextTaskBanner.jsx'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; import ClaudeStatus from './ClaudeStatus'; +import TokenUsagePie from './TokenUsagePie'; import { MicButton } from './MicButton.jsx'; import { api, authenticatedFetch } from '../utils/api'; +// Helper function to decode HTML entities in text +function decodeHtmlEntities(text) { + if (!text) return text; + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + // Format "Claude AI usage limit reached|" into a local time string function formatUsageLimitText(text) { try { @@ -156,7 +168,7 @@ const safeLocalStorage = { }; // Memoized message component to prevent unnecessary re-renders -const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => { +const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking }) => { const isGrouped = prevMessage && prevMessage.type === message.type && ((prevMessage.type === 'assistant') || (prevMessage.type === 'user') || @@ -1053,7 +1065,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile ) : (
{/* Thinking accordion for reasoning */} - {message.reasoning && ( + {showThinking && message.reasoning && (
💭 Thinking... @@ -1166,7 +1178,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, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, 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, onTaskClick, onShowAllTasks }) { const { tasksEnabled } = useTasksSettings(); const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { @@ -1216,6 +1228,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [slashCommands, setSlashCommands] = useState([]); const [filteredCommands, setFilteredCommands] = useState([]); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); + const [tokenBudget, setTokenBudget] = useState(null); const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1); const [slashPosition, setSlashPosition] = useState(-1); const [visibleMessageCount, setVisibleMessageCount] = useState(100); @@ -1409,10 +1422,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess for (const part of content.content) { if (part?.type === 'text' && part?.text) { - textParts.push(part.text); + textParts.push(decodeHtmlEntities(part.text)); } else if (part?.type === 'reasoning' && part?.text) { // Handle reasoning type - will be displayed in a collapsible section - reasoningText = part.text; + reasoningText = decodeHtmlEntities(part.text); } else if (part?.type === 'tool-call') { // First, add any text/reasoning we've collected so far as a message if (textParts.length > 0 || reasoningText) { @@ -1708,20 +1721,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess for (const part of msg.message.content) { if (part.type === 'text') { - textParts.push(part.text); + textParts.push(decodeHtmlEntities(part.text)); } // Skip tool_result parts - they're handled in the first pass } content = textParts.join('\n'); } else if (typeof msg.message.content === 'string') { - content = msg.message.content; + content = decodeHtmlEntities(msg.message.content); } else { - content = String(msg.message.content); + content = decodeHtmlEntities(String(msg.message.content)); } // Skip command messages and empty content if (content && !content.startsWith('') && !content.startsWith('[Request interrupted')) { + // Unescape double-escaped newlines and other escape sequences + content = content.replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\r/g, '\r'); converted.push({ type: messageType, content: content, @@ -1735,9 +1752,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess if (Array.isArray(msg.message.content)) { for (const part of msg.message.content) { if (part.type === 'text') { + // Unescape double-escaped newlines and other escape sequences + let text = part.text; + if (typeof text === 'string') { + text = text.replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\r/g, '\r'); + } converted.push({ type: 'assistant', - content: part.text, + content: text, timestamp: msg.timestamp || new Date().toISOString() }); } else if (part.type === 'tool_use') { @@ -1758,9 +1782,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } } } else if (typeof msg.message.content === 'string') { + // Unescape double-escaped newlines and other escape sequences + let text = msg.message.content; + text = text.replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\r/g, '\r'); converted.push({ type: 'assistant', - content: msg.message.content, + content: text, timestamp: msg.timestamp || new Date().toISOString() }); } @@ -1775,6 +1804,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess return convertSessionMessages(sessionMessages); }, [sessionMessages]); + // Note: Token budgets are not saved to JSONL files, only sent via WebSocket + // So we don't try to extract them from loaded sessionMessages + // Define scroll functions early to avoid hoisting issues in useEffect dependencies const scrollToBottom = useCallback(() => { if (scrollContainerRef.current) { @@ -1832,11 +1864,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const loadMessages = async () => { if (selectedSession && selectedProject) { const provider = localStorage.getItem('selected-provider') || 'claude'; - - // Reset pagination state when switching sessions - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); + + // Only reset state if the session ID actually changed (not initial load) + const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; + + if (sessionChanged) { + // Reset pagination state when switching sessions + setMessagesOffset(0); + setHasMoreMessages(false); + setTotalMessages(0); + // Reset token budget when switching sessions + // It will update when user sends a message and receives new budget from WebSocket + setTokenBudget(null); + // Reset loading state when switching sessions (unless the new session is processing) + // The restore effect will set it back to true if needed + setIsLoading(false); + + // Check if the session is currently processing on the backend + if (ws && sendMessage) { + sendMessage({ + type: 'check-session-status', + sessionId: selectedSession.id, + provider + }); + } + } else if (currentSessionId === null) { + // Initial load - reset pagination but not token budget + setMessagesOffset(0); + setHasMoreMessages(false); + setTotalMessages(0); + + // Check if the session is currently processing on the backend + if (ws && sendMessage) { + sendMessage({ + type: 'check-session-status', + sessionId: selectedSession.id, + provider + }); + } + } if (provider === 'cursor') { // For Cursor, set the session ID for resuming @@ -1933,6 +1999,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [selectedProject?.name]); + // Track processing state: notify parent when isLoading becomes true + // Note: onSessionNotProcessing is called directly in completion message handlers + useEffect(() => { + if (currentSessionId && isLoading && onSessionProcessing) { + onSessionProcessing(currentSessionId); + } + }, [isLoading, currentSessionId, onSessionProcessing]); + + // Restore processing state when switching to a processing session + useEffect(() => { + if (currentSessionId && processingSessions) { + const shouldBeProcessing = processingSessions.has(currentSessionId); + if (shouldBeProcessing && !isLoading) { + setIsLoading(true); + setCanAbortSession(true); // Assume processing sessions can be aborted + } + } + }, [currentSessionId, processingSessions]); useEffect(() => { // Handle WebSocket messages @@ -1954,15 +2038,21 @@ 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)'); + break; + case 'claude-response': const messageData = latestMessage.data.message || latestMessage.data; // Handle Cursor streaming format (content_block_delta / content_block_stop) if (messageData && typeof messageData === 'object' && messageData.type) { if (messageData.type === 'content_block_delta' && messageData.delta?.text) { - // Buffer deltas and flush periodically to reduce rerenders - streamBufferRef.current += messageData.delta.text; + // Decode HTML entities and buffer deltas + const decodedText = decodeHtmlEntities(messageData.delta.text); + streamBufferRef.current += decodedText; if (!streamTimerRef.current) { streamTimerRef.current = setTimeout(() => { const chunk = streamBufferRef.current; @@ -2090,9 +2180,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess toolResult: null // Will be updated when result comes in }]); } else if (part.type === 'text' && part.text?.trim()) { - // Normalize usage limit message to local time - let content = formatUsageLimitText(part.text); - + // Decode HTML entities and normalize usage limit message to local time + let content = decodeHtmlEntities(part.text); + content = formatUsageLimitText(content); + // Add regular text message setChatMessages(prev => [...prev, { type: 'assistant', @@ -2102,9 +2193,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } } } else if (typeof messageData.content === 'string' && messageData.content.trim()) { - // Normalize usage limit message to local time - let content = formatUsageLimitText(messageData.content); - + // Decode HTML entities and normalize usage limit message to local time + let content = decodeHtmlEntities(messageData.content); + content = formatUsageLimitText(content); + // Add regular text message setChatMessages(prev => [...prev, { type: 'assistant', @@ -2237,50 +2329,64 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess break; case 'cursor-result': - // Handle Cursor completion and final result text - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - try { - const r = latestMessage.data || {}; - const textResult = typeof r.result === 'string' ? r.result : ''; - // Flush buffered deltas before finalizing - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const pendingChunk = streamBufferRef.current; - streamBufferRef.current = ''; + // Get session ID from message or fall back to current session + const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; - setChatMessages(prev => { - const updated = [...prev]; - // Try to consolidate into the last streaming assistant message - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - // Replace streaming content with the final content so deltas don't remain - const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || ''); - last.content = finalContent; - last.isStreaming = false; - } else if (textResult && textResult.trim()) { - updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false }); + // Only update UI state if this is the current session + if (cursorCompletedSessionId === currentSessionId) { + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); + } + + // Always mark the completed session as inactive and not processing + if (cursorCompletedSessionId) { + if (onSessionInactive) { + onSessionInactive(cursorCompletedSessionId); + } + if (onSessionNotProcessing) { + onSessionNotProcessing(cursorCompletedSessionId); + } + } + + // Only process result for current session + if (cursorCompletedSessionId === currentSessionId) { + try { + const r = latestMessage.data || {}; + const textResult = typeof r.result === 'string' ? r.result : ''; + // Flush buffered deltas before finalizing + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current); + streamTimerRef.current = null; } - return updated; - }); - } catch (e) { - console.warn('Error handling cursor-result message:', e); + const pendingChunk = streamBufferRef.current; + streamBufferRef.current = ''; + + setChatMessages(prev => { + const updated = [...prev]; + // Try to consolidate into the last streaming assistant message + const last = updated[updated.length - 1]; + if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { + // Replace streaming content with the final content so deltas don't remain + const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || ''); + last.content = finalContent; + last.isStreaming = false; + } else if (textResult && textResult.trim()) { + updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false }); + } + return updated; + }); + } catch (e) { + console.warn('Error handling cursor-result message:', e); + } } - - // Mark session as inactive - const cursorSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId'); - if (cursorSessionId && onSessionInactive) { - onSessionInactive(cursorSessionId); - } - - // Store session ID for future use and trigger refresh - if (cursorSessionId && !currentSessionId) { - setCurrentSessionId(cursorSessionId); + + // Store session ID for future use and trigger refresh (for new sessions) + const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); + if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) { + setCurrentSessionId(cursorCompletedSessionId); sessionStorage.removeItem('pendingSessionId'); - + // Trigger a project refresh to update the sidebar with the new session if (window.refreshProjects) { setTimeout(() => window.refreshProjects(), 500); @@ -2320,17 +2426,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess break; case 'claude-complete': - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); + // Get session ID from message or fall back to current session + const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - - // Session Protection: Mark session as inactive to re-enable automatic project updates - // Conversation is complete, safe to allow project updates again - // Use real session ID if available, otherwise use pending session ID - const activeSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId'); - if (activeSessionId && onSessionInactive) { - onSessionInactive(activeSessionId); + // Only update UI state if this is the current session + if (completedSessionId === currentSessionId) { + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); + } + + // Always mark the completed session as inactive and not processing + if (completedSessionId) { + if (onSessionInactive) { + onSessionInactive(completedSessionId); + } + if (onSessionNotProcessing) { + onSessionNotProcessing(completedSessionId); + } } // If we have a pending session ID and the conversation completed successfully, use it @@ -2352,16 +2465,26 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess break; case 'session-aborted': - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - - // Session Protection: Mark session as inactive when aborted - // User or system aborted the conversation, re-enable project updates - if (currentSessionId && onSessionInactive) { - onSessionInactive(currentSessionId); + // Get session ID from message or fall back to current session + const abortedSessionId = latestMessage.sessionId || currentSessionId; + + // Only update UI state if this is the current session + if (abortedSessionId === currentSessionId) { + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); } - + + // Always mark the aborted session as inactive and not processing + if (abortedSessionId) { + if (onSessionInactive) { + onSessionInactive(abortedSessionId); + } + if (onSessionNotProcessing) { + onSessionNotProcessing(abortedSessionId); + } + } + setChatMessages(prev => [...prev, { type: 'assistant', content: 'Session interrupted by user.', @@ -2369,6 +2492,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess }]); break; + case 'session-status': + // Response to check-session-status request + 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 'claude-status': // Handle Claude working status messages const statusData = latestMessage.data; @@ -2571,6 +2709,102 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [input]); + // Poll token usage from JSONL file + useEffect(() => { + console.log('🔍 Token usage polling effect triggered', { + sessionId: selectedSession?.id, + projectPath: selectedProject?.path + }); + + if (!selectedProject) { + console.log('⚠️ Skipping token usage fetch - missing project'); + 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; + + try { + const url = `/api/sessions/${currentSessionId}/token-usage?projectPath=${encodeURIComponent(currentProjectPath)}`; + console.log('📊 Fetching 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; + } + + if (response.ok) { + const data = await response.json(); + console.log('✅ Token usage data received:', 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 }); + } + } 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 }); + } + }; + + // 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); + }; + }, [selectedSession?.id, selectedProject?.path]); + const handleTranscript = useCallback((text) => { if (text.trim()) { setInput(prevInput => { @@ -3181,6 +3415,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess onShowSettings={onShowSettings} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} + showThinking={showThinking} /> ); })} @@ -3265,7 +3500,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
- + {/* Token usage pie chart - positioned next to mode indicator */} + + {/* Scroll to bottom button - positioned next to mode indicator */} {isUserScrolledUp && chatMessages.length > 0 && (