diff --git a/.env.example b/.env.example index d1f2fa95..6b34f69e 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 84b56c6f..2ff70c19 100755 --- a/.gitignore +++ b/.gitignore @@ -105,7 +105,9 @@ temp/ .taskmaster/ .cline/ .windsurf/ +.serena/ CLAUDE.md +.mcp.json # Database files @@ -126,5 +128,5 @@ dev-debug.log # OS specific # Task files -# tasks.json -# tasks/ +tasks.json +tasks/ diff --git a/.playwright-mcp/1-input-with-text.png b/.playwright-mcp/1-input-with-text.png new file mode 100644 index 00000000..19569dfd 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 00000000..0828a4f1 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 00000000..0433ec22 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 00000000..c8d02442 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 00000000..73d21bd0 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 00000000..fa3252a2 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 00000000..28057388 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 00000000..d563ebfe 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 00000000..16f96aa7 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 00000000..28057388 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 00000000..fe482d2a 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 00000000..28057388 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 00000000..c651a490 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 00000000..fdbdde5c 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 eb3b5911..c556d127 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,13 @@ "@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", + "@esbuild/darwin-arm64": "^0.25.11", "@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", @@ -29,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", @@ -43,9 +48,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 +94,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", @@ -638,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" ], @@ -1018,6 +1039,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 +1181,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 +1231,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 +1321,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 +1449,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 +2736,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 +2992,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 +3014,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", @@ -2983,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", @@ -4483,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", @@ -4525,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", @@ -4717,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", @@ -4948,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", @@ -5215,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", @@ -5644,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", @@ -5855,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", @@ -5936,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", @@ -8770,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", @@ -9381,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", @@ -9932,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", @@ -10674,18 +10988,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 +11063,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 +11355,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 +11484,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 91dec5d5..468ee1e8 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 00000000..47da67fb --- /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 2e685d76..00000000 --- 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 00000000..204c0a89 --- /dev/null +++ b/server/claude-sdk.js @@ -0,0 +1,522 @@ +/** + * 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 + const modelKey = Object.keys(resultMessage.modelUsage)[0]; + const modelData = resultMessage.modelUsage[modelKey]; + + if (!modelData) { + return null; + } + + // 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; + + // 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, + 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 + 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); + + // Send session-created event only once for new sessions + if (!sessionId && !sessionCreatedSent) { + console.log('📤 Sending session-created event'); + sessionCreatedSent = true; + ws.send(JSON.stringify({ + 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 + const transformedMessage = transformMessage(message); + ws.send(JSON.stringify({ + type: 'claude-response', + data: transformedMessage + })); + + // Log all message types for debugging + console.log('🔵 SDK message type:', message.type); + + // Extract and send token budget updates from result messages + if (message.type === 'result') { + console.log('✅ Result message received, extracting token budget...'); + const tokenBudget = extractTokenBudget(message); + if (tokenBudget) { + console.log('📊 Token budget from modelUsage:', tokenBudget); + ws.send(JSON.stringify({ + type: 'token-budget', + data: tokenBudget + })); + console.log('📤 Token budget sent to WebSocket'); + } else { + console.log('⚠️ extractTokenBudget returned null'); + } + } + } + console.log('🏁 Async generator loop completed'); + + // Clean up session on completion + if (capturedSessionId) { + removeSession(capturedSessionId); + } + + // Clean up temporary image files + 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); + + // 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 be471f93..2b0cd701 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 2b7b2605..65e5f53c 100755 --- a/server/index.js +++ b/server/index.js @@ -27,25 +27,26 @@ 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'; 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'; 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) { @@ -550,7 +593,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 +613,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 +637,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); @@ -714,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 @@ -761,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` @@ -798,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` @@ -1053,13 +1133,116 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r } }); -// Serve React app for all other routes +// Get token usage for a specific session +app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { + try { + const { projectName, sessionId } = req.params; + const homeDir = os.homedir(); + + // 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 encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-'); + const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); + + // 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 + 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); + 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) => { + // 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 - res.redirect(`http://localhost:${process.env.VITE_PORT || 3001}`); + res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`); } }); @@ -1153,11 +1336,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/server/projects.js b/server/projects.js index 4f0aae3a..dc72fd8e 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,39 @@ 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) { + console.log('🔇 Filtering out JSON session:', session.id, session.summary.substring(0, 80) + '...'); + } + // Log a sample of summaries to debug + if (Math.random() < 0.01) { // Log 1% of sessions + console.log(`📝 Sample summary for ${session.id}:`, session.summary.substring(0, 60)); + } + return !shouldFilter; + }); + + console.log(`📊 Session filtering: ${allSessions.length} total, ${filteredSessions.length} after filtering`); + return { - sessions: Array.from(sessions.values()), + sessions: filteredSessions, entries: entries }; - + } catch (error) { console.error('Error reading JSONL file:', error); return { sessions: [], entries: [] }; diff --git a/server/routes/commands.js b/server/routes/commands.js new file mode 100644 index 00000000..18a5c93f --- /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 00000000..11af5c75 --- /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 00000000..61bb310b --- /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 5024680c..fe01254b 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,15 @@ 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()); + + // 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 @@ -159,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: @@ -186,13 +220,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); @@ -324,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'; @@ -332,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}`); }; @@ -454,6 +499,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,13 +720,18 @@ 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} + externalMessageUpdate={externalMessageUpdate} /> @@ -682,6 +752,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 1be74815..2ba9aad7 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -26,10 +26,24 @@ 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'; +import Fuse from 'fuse.js'; +import CommandMenu from './CommandMenu'; +// 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 +170,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 +1067,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile ) : (
{/* Thinking accordion for reasoning */} - {message.reasoning && ( + {showThinking && message.reasoning && (
💭 Thinking... @@ -1066,48 +1080,83 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)} - {message.type === 'assistant' ? ( -
- { - return inline ? ( - - {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 +
+
+
+                              
+                                {formatted}
                               
-                            
- ); - }, - blockquote: ({children}) => ( -
- {children} -
- ), - a: ({href, children}) => ( - - {children} - - ), - p: ({children}) => ( -
- {children} +
- ) - }} - > - {formatUsageLimitText(String(message.content || ''))} - -
- ) : ( -
- {formatUsageLimitText(String(message.content || ''))} -
- )} +
+ ); + } 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} +
+ ); + })()}
)} @@ -1166,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, 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, externalMessageUpdate, onTaskClick, onShowAllTasks }) { const { tasksEnabled } = useTasksSettings(); const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { @@ -1198,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([]); @@ -1215,7 +1267,9 @@ 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); const [slashPosition, setSlashPosition] = useState(-1); const [visibleMessageCount, setVisibleMessageCount] = useState(100); @@ -1226,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) { @@ -1263,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(() => { @@ -1409,10 +1813,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 +2112,34 @@ 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')) { + // 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') + .replace(/\\r/g, '\r'); converted.push({ type: messageType, content: content, @@ -1735,9 +2153,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 +2183,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 +2205,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 +2265,48 @@ 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); + + // 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; + + 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 @@ -1865,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); @@ -1887,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) { @@ -1933,12 +2444,43 @@ 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 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 @@ -1954,15 +2496,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } } break; - + + case 'token-budget': + // 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': 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 +2638,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 +2651,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 +2787,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; + // Get session ID from message or fall back to current session + const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; + + // 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); } - 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); + + // 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; + } + 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); + } } - - // 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 +2884,49 @@ 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); + 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 + if (completedSessionId) { + if (onSessionInactive) { + onSessionInactive(completedSessionId); + } + if (onSessionNotProcessing) { + onSessionNotProcessing(completedSessionId); + } } // If we have a pending session ID and the conversation completed successfully, use it @@ -2338,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 @@ -2351,23 +2945,49 @@ 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); + case 'session-aborted': { + // 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.', 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 'claude-status': // Handle Claude working status messages @@ -2531,15 +3151,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(() => { @@ -2550,7 +3171,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'; @@ -2571,24 +3192,56 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [input]); + // Load token usage when session changes (but don't poll to avoid conflicts with WebSocket) + useEffect(() => { + if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { + // Reset for new/empty sessions + setTokenBudget(null); + return; + } + + // Fetch token usage once when session loads + const fetchInitialTokenUsage = async () => { + try { + const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; + console.log('📊 Fetching initial token usage from:', url); + + const response = await authenticatedFetch(url); + + if (response.ok) { + const data = await response.json(); + console.log('✅ Initial token usage loaded:', data); + setTokenBudget(data); + } else { + console.log('⚠️ No token usage data available for this session yet'); + setTokenBudget(null); + } + } catch (error) { + console.error('Failed to fetch initial token usage:', error); + } + }; + + 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; }); } @@ -2671,7 +3324,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; @@ -2804,21 +3457,91 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setUploadingImages(new Map()); setImageErrors(new Map()); setIsTextareaExpanded(false); - - // Reset textarea height - + // 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') { @@ -2851,13 +3574,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; } @@ -2922,13 +3651,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); + } } }; @@ -2959,7 +3749,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 @@ -3181,6 +3977,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess onShowSettings={onShowSettings} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} + showThinking={showThinking} /> ); })} @@ -3223,15 +4020,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess }`}>
-
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */} -
+
- + {/* Token usage pie chart - positioned next to mode indicator */} + + + {/* Clear input button - positioned to the right of token pie, only shows when there's input */} + {input.trim() && ( + + )} + {/* Scroll to bottom button - positioned next to mode indicator */} {isUserScrolledUp && chatMessages.length > 0 && (