feat: Multiple features, improvements, and bug fixes (#208)

* feat: Add token budget tracking and multiple improvements

## Features
- **Token Budget Visualization**: Added real-time token usage tracking with pie chart display showing percentage used (blue < 50%, orange < 75%, red ≥ 75%)
- **Show Thinking Toggle**: Added quick settings option to show/hide reasoning sections in messages
- **Cache Clearing Utility**: Added `/clear-cache.html` page for clearing service workers, caches, and storage

## Improvements
- **Package Upgrades**: Migrated from deprecated `xterm` to `@xterm/*` scoped packages
- **Testing Setup**: Added Playwright for end-to-end testing
- **Build Optimization**: Implemented code splitting for React, CodeMirror, and XTerm vendors to improve initial load time
- **Deployment Scripts**: Added `scripts/start.sh` and `scripts/stop.sh` for cleaner server management with automatic port conflict resolution
- **Vite Update**: Upgraded Vite from 7.0.5 to 7.1.8

## Bug Fixes
- Fixed static file serving to properly handle routes vs assets
- Fixed session state reset to preserve token budget on initial load
- Updated default Vite dev server port to 5173 (Vite's standard)

## Technical Details
- Token budget is parsed from Claude CLI `modelUsage` field in result messages
- Budget updates are sent via WebSocket as `token-budget` events
- Calculation includes input, output, cache read, and cache creation tokens
- Token budget state persists during active sessions but resets on session switch

* feat: Add session processing state persistence

Fixes issue where "Thinking..." banner and stop button disappear when
switching between sessions. Users can now navigate freely while Claude
is processing without losing the ability to monitor or stop the session.

Features:
- Processing state tracked in processingSessions Set (App.jsx)
- Backend session status queries via check-session-status WebSocket message
- UI state (banner + stop button) restored when returning to processing sessions
- Works after page reload by querying backend's authoritative process maps
- Proper cleanup when sessions complete in background

Backend Changes:
- Added sessionId to claude-complete, cursor-result, session-aborted messages
- Exported isClaudeSessionActive, isCursorSessionActive helper functions
- Exported getActiveClaudeSessions, getActiveCursorSessions for status queries
- Added check-session-status and get-active-sessions WebSocket handlers

Frontend Changes:
- processingSessions state tracking in App.jsx
- onSessionProcessing/onSessionNotProcessing callbacks
- Session status check on session load and switch
- Completion handlers only update UI if message is for current session
- Always clean up processing state regardless of which session is active

* feat: Make context window size configurable via environment variables

Removes hardcoded 160k token limit and makes it configurable through
environment variables. This allows easier adjustment for different
Claude models or use cases.

Changes:
- Added CONTEXT_WINDOW env var for backend (default: 160000)
- Added VITE_CONTEXT_WINDOW env var for frontend (default: 160000)
- Updated .env.example with documentation
- Replaced hardcoded values in token usage calculations
- Replaced hardcoded values in pie chart display

Why 160k? Claude Code reserves ~40k tokens for auto-compact feature,
leaving 160k available for actual usage from the 200k context window.

* fix: Decode HTML entities in chat message display

HTML entities like &lt; and &gt; were showing as-is instead of being
decoded to < and > characters. Added decodeHtmlEntities helper function
to properly display angle brackets and other special characters.

Applied to:
- Regular message content
- Streaming content deltas
- Session history loading
- Both string and array content types

* refactor: Align package.json with main branch standards

- Revert to main branch's package.json scripts structure
- Remove custom scripts/start.sh and scripts/stop.sh
- Update xterm dependencies to scoped @xterm packages (required for code compatibility)
  - Replace xterm with @xterm/xterm
  - Replace xterm-addon-fit with @xterm/addon-fit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: Replace CLI implementation with Claude Agents SDK

This commit completes the migration to the Claude Agents SDK, removing the legacy CLI-based implementation and making the SDK the exclusive integration method.

Changes:
- Remove claude-cli.js legacy implementation
- Add claude-sdk.js with full SDK integration
- Remove CLAUDE_USE_SDK feature flag (SDK is now always used)
- Update server/index.js to use SDK functions directly
- Add .serena/ to .gitignore for AI assistant cache

Benefits:
- Better performance (no child process overhead)
- Native session management with interrupt support
- Cleaner codebase without CLI/SDK branching
- Full feature parity with previous CLI implementation
- Maintains compatibility with Cursor integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update server/claude-sdk.js

Whoops. This is correct.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update server/index.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update src/components/ChatInterface.jsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update src/components/ChatInterface.jsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update src/components/ChatInterface.jsx

Left my test code in, but that's fixed.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Prevent stale token-usage data from updating state on session switch

- Add AbortController to cancel in-flight token-usage requests when session/project changes
- Capture session/project IDs before fetch and verify they match before updating state
- Handle AbortError gracefully without logging as error
- Prevents race condition where old session data overwrites current session's token budget

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update src/components/TokenUsagePie.jsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: viper151 <simosmik@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Josh Wilhelmi
2025-10-30 08:01:55 -05:00
committed by GitHub
parent a100648ccb
commit 6dd303a321
16 changed files with 1466 additions and 589 deletions

View File

@@ -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

5
.gitignore vendored
View File

@@ -105,6 +105,7 @@ temp/
.taskmaster/
.cline/
.windsurf/
.serena/
CLAUDE.md
@@ -126,5 +127,5 @@ dev-debug.log
# OS specific
# Task files
# tasks.json
# tasks/
tasks.json
tasks/

343
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.8.12",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.13",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -16,11 +17,12 @@
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@siteboon/claude-code-ui": "^1.8.4",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.0",
"chokidar": "^4.0.3",
@@ -43,9 +45,7 @@
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"ws": "^8.14.2",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
"ws": "^8.14.2"
},
"bin": {
"claude-code-ui": "server/index.js"
@@ -91,6 +91,26 @@
"node": ">=6.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.13.tgz",
"integrity": "sha512-9/+/iVdVQx2o3INUxwNWuZQOhff3ISXgSc/G7jfD85qtEN/7ZK/uOnAtCH1PChMNBN5CGXgVKNMce++52tfZ5A==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "^0.33.5",
"@img/sharp-darwin-x64": "^0.33.5",
"@img/sharp-linux-arm": "^0.33.5",
"@img/sharp-linux-arm64": "^0.33.5",
"@img/sharp-linux-x64": "^0.33.5",
"@img/sharp-win32-x64": "^0.33.5"
},
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -1018,6 +1038,114 @@
"license": "MIT",
"optional": true
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz",
@@ -1052,6 +1180,22 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz",
@@ -1086,6 +1230,50 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz",
@@ -1132,6 +1320,28 @@
"@img/sharp-libvips-linux-s390x": "1.2.0"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz",
@@ -1238,6 +1448,25 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz",
@@ -2506,50 +2735,6 @@
"win32"
]
},
"node_modules/@siteboon/claude-code-ui": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/@siteboon/claude-code-ui/-/claude-code-ui-1.8.4.tgz",
"integrity": "sha512-9moBlMDNF/6IfIcqShavxdq0TI9aNuY3+33YZcnvYagWsZMdJ/7d5tgDwAZEp3Uup/nHU+bdrkiXmFfLcRQLCQ==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-webgl": "^0.18.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.0",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-fetch": "^2.7.0",
"node-pty": "^1.1.0-beta34",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"ws": "^8.14.2",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
@@ -2806,6 +2991,15 @@
"@xterm/xterm": "^5.4.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
@@ -2819,8 +3013,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/abbrev": {
"version": "2.0.0",
@@ -10674,18 +10867,18 @@
}
},
"node_modules/vite": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz",
"integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==",
"version": "7.1.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.8.tgz",
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"picomatch": "^4.0.2",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
@@ -10749,11 +10942,14 @@
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -11038,23 +11234,6 @@
"node": ">=0.4"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -11184,6 +11363,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -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",

85
public/clear-cache.html Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<title>Clear Cache - Claude Code UI</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
line-height: 1.6;
}
.success { color: green; }
.error { color: red; }
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
margin: 10px 5px;
}
button:hover {
background: #0056b3;
}
#status {
margin-top: 20px;
padding: 15px;
border-radius: 5px;
background: #f0f0f0;
}
</style>
</head>
<body>
<h1>Clear Cache & Service Worker</h1>
<p>If you're seeing a blank page or old content, click the button below to clear all cached data.</p>
<button onclick="clearEverything()">Clear Cache & Reload</button>
<div id="status"></div>
<script>
async function clearEverything() {
const status = document.getElementById('status');
status.innerHTML = '<p>Clearing cache and service workers...</p>';
try {
// Unregister all service workers
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (let registration of registrations) {
await registration.unregister();
status.innerHTML += '<p class="success">✓ Unregistered service worker</p>';
}
}
// Clear all caches
if ('caches' in window) {
const cacheNames = await caches.keys();
for (let cacheName of cacheNames) {
await caches.delete(cacheName);
status.innerHTML += `<p class="success">✓ Deleted cache: ${cacheName}</p>`;
}
}
// Clear localStorage
localStorage.clear();
status.innerHTML += '<p class="success">✓ Cleared localStorage</p>';
// Clear sessionStorage
sessionStorage.clear();
status.innerHTML += '<p class="success">✓ Cleared sessionStorage</p>';
status.innerHTML += '<p class="success"><strong>✓ All caches cleared!</strong></p>';
status.innerHTML += '<p>Cache cleared successfully. You can now close this tab or <a href="/">go to home page</a>.</p>';
} catch (error) {
status.innerHTML += `<p class="error">✗ Error: ${error.message}</p>`;
}
}
</script>
</body>
</html>

View File

@@ -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
};

499
server/claude-sdk.js Normal file
View File

@@ -0,0 +1,499 @@
/**
* Claude SDK Integration
*
* This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
* It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
* and maintainability.
*
* Key features:
* - Direct SDK integration without child processes
* - Session management with abort capability
* - Options mapping between CLI and SDK formats
* - WebSocket message streaming
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Session tracking: Map of session IDs to active query instances
const activeSessions = new Map();
/**
* Maps CLI options to SDK-compatible options format
* @param {Object} options - CLI options
* @returns {Object} SDK-compatible options
*/
function mapCliOptionsToSDK(options = {}) {
const { sessionId, cwd, toolsSettings, permissionMode, images } = options;
const sdkOptions = {};
// Map working directory
if (cwd) {
sdkOptions.cwd = cwd;
}
// Map permission mode
if (permissionMode && permissionMode !== 'default') {
sdkOptions.permissionMode = permissionMode;
}
// Map tool settings
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};
// Handle tool permissions
if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions';
} else {
// Map allowed tools
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
}
}
if (allowedTools.length > 0) {
sdkOptions.allowedTools = allowedTools;
}
// Map disallowed tools
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
sdkOptions.disallowedTools = settings.disallowedTools;
}
}
// Map model (default to sonnet)
// Map model (default to sonnet)
sdkOptions.model = options.model || 'sonnet';
// Map resume session
if (sessionId) {
sdkOptions.resume = sessionId;
}
return sdkOptions;
}
/**
* Adds a session to the active sessions map
* @param {string} sessionId - Session identifier
* @param {Object} queryInstance - SDK query instance
* @param {Array<string>} 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<string>} Array of active session IDs
*/
function getAllSessions() {
return Array.from(activeSessions.keys());
}
/**
* Transforms SDK messages to WebSocket format expected by frontend
* @param {Object} sdkMessage - SDK message object
* @returns {Object} Transformed message ready for WebSocket
*/
function transformMessage(sdkMessage) {
// SDK messages are already in a format compatible with the frontend
// The CLI sends them wrapped in {type: 'claude-response', data: message}
// We'll do the same here to maintain compatibility
return sdkMessage;
}
/**
* Extracts token usage from SDK result messages
* @param {Object} resultMessage - SDK result message
* @returns {Object|null} Token budget object or null
*/
function extractTokenBudget(resultMessage) {
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
return null;
}
// Get the first model's usage data (same as CLI implementation)
const modelKey = Object.keys(resultMessage.modelUsage)[0];
const modelData = resultMessage.modelUsage[modelKey];
if (!modelData || !modelData.contextWindow) {
return null;
}
// Calculate total tokens used (input + output + cache)
const inputTokens = modelData.inputTokens || 0;
const outputTokens = modelData.outputTokens || 0;
const cacheReadTokens = modelData.cacheReadInputTokens || 0;
const cacheCreationTokens = modelData.cacheCreationInputTokens || 0;
// Total used = input + output + cache tokens
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
const contextWindow = modelData.contextWindow;
return {
used: totalUsed,
total: contextWindow
};
}
/**
* Handles image processing for SDK queries
* Saves base64 images to temporary files and returns modified prompt with file paths
* @param {string} command - Original user prompt
* @param {Array} images - Array of image objects with base64 data
* @param {string} cwd - Working directory for temp file creation
* @returns {Promise<Object>} {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<string>} 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<void>}
*/
async function queryClaudeSDK(command, options = {}, ws) {
const { sessionId } = options;
let capturedSessionId = sessionId;
let sessionCreatedSent = false;
let tempImagePaths = [];
let tempDir = null;
try {
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK(options);
// Load MCP configuration
const mcpServers = await loadMcpConfig(options.cwd);
if (mcpServers) {
sdkOptions.mcpServers = mcpServers;
}
// Handle images - save to temp files and modify prompt
const imageResult = await handleImages(command, options.images, options.cwd);
const finalCommand = imageResult.modifiedCommand;
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
// Create SDK query instance
const queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
// Track the query instance for abort capability
if (capturedSessionId) {
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
}
// Process streaming messages
for await (const message of queryInstance) {
// Capture session ID from first message
if (message.session_id && !capturedSessionId) {
capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(JSON.stringify({
type: 'session-created',
sessionId: capturedSessionId
}));
}
}
// Transform and send message to WebSocket
const transformedMessage = transformMessage(message);
ws.send(JSON.stringify({
type: 'claude-response',
data: transformedMessage
}));
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const tokenBudget = extractTokenBudget(message);
if (tokenBudget) {
console.log('📊 Token budget from modelUsage:', tokenBudget);
ws.send(JSON.stringify({
type: 'token-budget',
data: tokenBudget
}));
}
}
}
// Clean up session on completion
if (capturedSessionId) {
removeSession(capturedSessionId);
}
// Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir);
// Send completion event
ws.send(JSON.stringify({
type: 'claude-complete',
sessionId: capturedSessionId,
exitCode: 0,
isNewSession: !sessionId && !!command
}));
} catch (error) {
console.error('SDK query error:', error);
// Clean up session on error
if (capturedSessionId) {
removeSession(capturedSessionId);
}
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
// Send error to WebSocket
ws.send(JSON.stringify({
type: 'claude-error',
error: error.message
}));
throw error;
}
}
/**
* Aborts an active SDK session
* @param {string} sessionId - Session identifier
* @returns {boolean} True if session was aborted, false if not found
*/
async function abortClaudeSDKSession(sessionId) {
const session = getSession(sessionId);
if (!session) {
console.log(`Session ${sessionId} not found`);
return false;
}
try {
console.log(`🛑 Aborting SDK session: ${sessionId}`);
// Call interrupt() on the query instance
await session.instance.interrupt();
// Update session status
session.status = 'aborted';
// Clean up temporary image files
await cleanupTempFiles(session.tempImagePaths, session.tempDir);
// Clean up session
removeSession(sessionId);
return true;
} catch (error) {
console.error(`Error aborting session ${sessionId}:`, error);
return false;
}
}
/**
* Checks if an SDK session is currently active
* @param {string} sessionId - Session identifier
* @returns {boolean} True if session is active
*/
function isClaudeSDKSessionActive(sessionId) {
const session = getSession(sessionId);
return session && session.status === 'active';
}
/**
* Gets all active SDK session IDs
* @returns {Array<string>} Array of active session IDs
*/
function getActiveClaudeSDKSessions() {
return getAllSessions();
}
// Export public API
export {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
getActiveClaudeSDKSessions
};

View File

@@ -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'
}));
@@ -201,6 +202,7 @@ async function spawnCursor(command, options = {}, ws) {
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
};

View File

@@ -28,18 +28,18 @@ console.log('PORT from env:', process.env.PORT);
import express from 'express';
import { WebSocketServer } from 'ws';
import os from 'os';
import http from 'http';
import cors from 'cors';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import os from 'os';
import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
import { spawnCursor, abortCursorSession } from './cursor-cli.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import mcpRoutes from './routes/mcp.js';
@@ -550,7 +550,9 @@ function handleChatConnection(ws) {
console.log('💬 User message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
await spawnClaude(data.command, data.options, ws);
// Use Claude Agents SDK
await queryClaudeSDK(data.command, data.options, ws);
} else if (data.type === 'cursor-command') {
console.log('🖱️ Cursor message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.cwd || 'Unknown');
@@ -568,9 +570,15 @@ function handleChatConnection(ws) {
} else if (data.type === 'abort-session') {
console.log('🛑 Abort session request:', data.sessionId);
const provider = data.provider || 'claude';
const success = provider === 'cursor'
? abortCursorSession(data.sessionId)
: abortClaudeSession(data.sessionId);
let success;
if (provider === 'cursor') {
success = abortCursorSession(data.sessionId);
} else {
// Use Claude Agents SDK
success = await abortClaudeSDKSession(data.sessionId);
}
ws.send(JSON.stringify({
type: 'session-aborted',
sessionId: data.sessionId,
@@ -586,6 +594,35 @@ function handleChatConnection(ws) {
provider: 'cursor',
success
}));
} else if (data.type === 'check-session-status') {
// Check if a specific session is currently processing
const provider = data.provider || 'claude';
const sessionId = data.sessionId;
let isActive;
if (provider === 'cursor') {
isActive = isCursorSessionActive(sessionId);
} else {
// Use Claude Agents SDK
isActive = isClaudeSDKSessionActive(sessionId);
}
ws.send(JSON.stringify({
type: 'session-status',
sessionId,
provider,
isProcessing: isActive
}));
} else if (data.type === 'get-active-sessions') {
// Get all currently active sessions
const activeSessions = {
claude: getActiveClaudeSDKSessions(),
cursor: getActiveCursorSessions()
};
ws.send(JSON.stringify({
type: 'active-sessions',
sessions: activeSessions
}));
}
} catch (error) {
console.error('❌ Chat WebSocket error:', error.message);
@@ -1053,13 +1090,87 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
}
});
// Serve React app for all other routes
// API endpoint to get token usage for a session by reading its JSONL file
app.get('/api/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { sessionId } = req.params;
const { projectPath } = req.query;
if (!projectPath) {
return res.status(400).json({ error: 'projectPath query parameter is required' });
}
// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces /, spaces, ~, and _ with -
const homeDir = os.homedir();
const encodedPath = projectPath.replace(/[\/\s~_]/g, '-');
const jsonlPath = path.join(homeDir, '.claude', 'projects', encodedPath, `${sessionId}.jsonl`);
// Check if file exists
if (!fs.existsSync(jsonlPath)) {
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
}
// Read and parse the JSONL file
const fileContent = fs.readFileSync(jsonlPath, 'utf8');
const lines = fileContent.trim().split('\n');
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0;
let cacheCreationTokens = 0;
let cacheReadTokens = 0;
// Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Only count assistant messages which have usage data
if (entry.type === 'assistant' && entry.message?.usage) {
const usage = entry.message.usage;
// Use token counts from latest assistant message only
inputTokens = usage.input_tokens || 0;
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break; // Stop after finding the latest assistant message
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
// Calculate total context usage (excluding output_tokens, as per ccusage)
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
res.json({
used: totalUsed,
total: contextWindow,
breakdown: {
input: inputTokens,
cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
}
});
} catch (error) {
console.error('Error reading session token usage:', error);
res.status(500).json({ error: 'Failed to read session token usage' });
}
});
// Serve React app for all other routes (excluding static files)
app.get('*', (req, res) => {
// Only serve index.html for HTML routes, not for static assets
// Static assets should already be handled by express.static middleware above
if (process.env.NODE_ENV === 'production') {
res.sendFile(path.join(__dirname, '../dist/index.html'));
} else {
// In development, redirect to Vite dev server
res.redirect(`http://localhost:${process.env.VITE_PORT || 3001}`);
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
}
});
@@ -1153,6 +1264,9 @@ 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}`);

View File

@@ -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
@@ -65,6 +66,10 @@ function AppContent() {
// until the conversation completes or is aborted.
const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations
// Processing Sessions: Track which sessions are currently thinking/processing
// This allows us to restore the "Thinking..." banner when switching back to a processing session
const [processingSessions, setProcessingSessions] = useState(new Set());
const { ws, sendMessage, messages } = useWebSocketContext();
// Detect if running as PWA
@@ -191,7 +196,10 @@ function AppContent() {
if (selectedProject) {
const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name);
if (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) {
@@ -454,6 +462,26 @@ function AppContent() {
}
};
// Processing Session Functions: Track which sessions are currently thinking/processing
// markSessionAsProcessing: Called when Claude starts thinking/processing
const markSessionAsProcessing = (sessionId) => {
if (sessionId) {
setProcessingSessions(prev => new Set([...prev, sessionId]));
}
};
// markSessionAsNotProcessing: Called when Claude finishes thinking/processing
const markSessionAsNotProcessing = (sessionId) => {
if (sessionId) {
setProcessingSessions(prev => {
const newSet = new Set(prev);
newSet.delete(sessionId);
return newSet;
});
}
};
// replaceTemporarySession: Called when WebSocket provides real session ID for new sessions
// Removes temporary "new-session-*" identifiers and adds the real session ID
// This maintains protection continuity during the transition from temporary to real session
@@ -655,11 +683,15 @@ function AppContent() {
onInputFocusChange={setIsInputFocused}
onSessionActive={markSessionAsActive}
onSessionInactive={markSessionAsInactive}
onSessionProcessing={markSessionAsProcessing}
onSessionNotProcessing={markSessionAsNotProcessing}
processingSessions={processingSessions}
onReplaceTemporarySession={replaceTemporarySession}
onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)}
onShowSettings={() => setShowSettings(true)}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/>
@@ -682,6 +714,8 @@ function AppContent() {
onAutoExpandChange={setAutoExpandTools}
showRawParameters={showRawParameters}
onShowRawParametersChange={setShowRawParameters}
showThinking={showThinking}
onShowThinkingChange={setShowThinking}
autoScrollToBottom={autoScrollToBottom}
onAutoScrollChange={setAutoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}

View File

@@ -26,10 +26,22 @@ import NextTaskBanner from './NextTaskBanner.jsx';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import ClaudeStatus from './ClaudeStatus';
import TokenUsagePie from './TokenUsagePie';
import { MicButton } from './MicButton.jsx';
import { api, authenticatedFetch } from '../utils/api';
// Helper function to decode HTML entities in text
function decodeHtmlEntities(text) {
if (!text) return text;
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
}
// Format "Claude AI usage limit reached|<epoch>" into a local time string
function formatUsageLimitText(text) {
try {
@@ -156,7 +168,7 @@ const safeLocalStorage = {
};
// Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking }) => {
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') ||
@@ -1053,7 +1065,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Thinking accordion for reasoning */}
{message.reasoning && (
{showThinking && message.reasoning && (
<details className="mb-3">
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
💭 Thinking...
@@ -1166,7 +1178,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
//
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter, onTaskClick, onShowAllTasks }) {
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, onTaskClick, onShowAllTasks }) {
const { tasksEnabled } = useTasksSettings();
const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
@@ -1216,6 +1228,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [slashCommands, setSlashCommands] = useState([]);
const [filteredCommands, setFilteredCommands] = useState([]);
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [tokenBudget, setTokenBudget] = useState(null);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
const [slashPosition, setSlashPosition] = useState(-1);
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
@@ -1409,10 +1422,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
for (const part of content.content) {
if (part?.type === 'text' && part?.text) {
textParts.push(part.text);
textParts.push(decodeHtmlEntities(part.text));
} else if (part?.type === 'reasoning' && part?.text) {
// Handle reasoning type - will be displayed in a collapsible section
reasoningText = part.text;
reasoningText = decodeHtmlEntities(part.text);
} else if (part?.type === 'tool-call') {
// First, add any text/reasoning we've collected so far as a message
if (textParts.length > 0 || reasoningText) {
@@ -1708,20 +1721,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
for (const part of msg.message.content) {
if (part.type === 'text') {
textParts.push(part.text);
textParts.push(decodeHtmlEntities(part.text));
}
// Skip tool_result parts - they're handled in the first pass
}
content = textParts.join('\n');
} else if (typeof msg.message.content === 'string') {
content = msg.message.content;
content = decodeHtmlEntities(msg.message.content);
} else {
content = String(msg.message.content);
content = decodeHtmlEntities(String(msg.message.content));
}
// Skip command messages and empty content
if (content && !content.startsWith('<command-name>') && !content.startsWith('[Request interrupted')) {
// Unescape double-escaped newlines and other escape sequences
content = content.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r');
converted.push({
type: messageType,
content: content,
@@ -1735,9 +1752,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (Array.isArray(msg.message.content)) {
for (const part of msg.message.content) {
if (part.type === 'text') {
// Unescape double-escaped newlines and other escape sequences
let text = part.text;
if (typeof text === 'string') {
text = text.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r');
}
converted.push({
type: 'assistant',
content: part.text,
content: text,
timestamp: msg.timestamp || new Date().toISOString()
});
} else if (part.type === 'tool_use') {
@@ -1758,9 +1782,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
} else if (typeof msg.message.content === 'string') {
// Unescape double-escaped newlines and other escape sequences
let text = msg.message.content;
text = text.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r');
converted.push({
type: 'assistant',
content: msg.message.content,
content: text,
timestamp: msg.timestamp || new Date().toISOString()
});
}
@@ -1775,6 +1804,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return convertSessionMessages(sessionMessages);
}, [sessionMessages]);
// Note: Token budgets are not saved to JSONL files, only sent via WebSocket
// So we don't try to extract them from loaded sessionMessages
// Define scroll functions early to avoid hoisting issues in useEffect dependencies
const scrollToBottom = useCallback(() => {
if (scrollContainerRef.current) {
@@ -1833,10 +1865,44 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (selectedSession && selectedProject) {
const provider = localStorage.getItem('selected-provider') || 'claude';
// Only reset state if the session ID actually changed (not initial load)
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) {
// Reset pagination state when switching sessions
setMessagesOffset(0);
setHasMoreMessages(false);
setTotalMessages(0);
// Reset token budget when switching sessions
// It will update when user sends a message and receives new budget from WebSocket
setTokenBudget(null);
// Reset loading state when switching sessions (unless the new session is processing)
// The restore effect will set it back to true if needed
setIsLoading(false);
// Check if the session is currently processing on the backend
if (ws && sendMessage) {
sendMessage({
type: 'check-session-status',
sessionId: selectedSession.id,
provider
});
}
} else if (currentSessionId === null) {
// Initial load - reset pagination but not token budget
setMessagesOffset(0);
setHasMoreMessages(false);
setTotalMessages(0);
// Check if the session is currently processing on the backend
if (ws && sendMessage) {
sendMessage({
type: 'check-session-status',
sessionId: selectedSession.id,
provider
});
}
}
if (provider === 'cursor') {
// For Cursor, set the session ID for resuming
@@ -1933,6 +1999,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [selectedProject?.name]);
// Track processing state: notify parent when isLoading becomes true
// Note: onSessionNotProcessing is called directly in completion message handlers
useEffect(() => {
if (currentSessionId && isLoading && onSessionProcessing) {
onSessionProcessing(currentSessionId);
}
}, [isLoading, currentSessionId, onSessionProcessing]);
// Restore processing state when switching to a processing session
useEffect(() => {
if (currentSessionId && processingSessions) {
const shouldBeProcessing = processingSessions.has(currentSessionId);
if (shouldBeProcessing && !isLoading) {
setIsLoading(true);
setCanAbortSession(true); // Assume processing sessions can be aborted
}
}
}, [currentSessionId, processingSessions]);
useEffect(() => {
// Handle WebSocket messages
@@ -1955,14 +2039,20 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
break;
case 'token-budget':
// Token budget is now fetched from API endpoint, ignore WebSocket data
console.log('📊 Ignoring WebSocket token budget (using API instead)');
break;
case 'claude-response':
const messageData = latestMessage.data.message || latestMessage.data;
// Handle Cursor streaming format (content_block_delta / content_block_stop)
if (messageData && typeof messageData === 'object' && messageData.type) {
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
// Buffer deltas and flush periodically to reduce rerenders
streamBufferRef.current += messageData.delta.text;
// Decode HTML entities and buffer deltas
const decodedText = decodeHtmlEntities(messageData.delta.text);
streamBufferRef.current += decodedText;
if (!streamTimerRef.current) {
streamTimerRef.current = setTimeout(() => {
const chunk = streamBufferRef.current;
@@ -2090,8 +2180,9 @@ 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, {
@@ -2102,8 +2193,9 @@ 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, {
@@ -2237,10 +2329,28 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
break;
case 'cursor-result':
// Handle Cursor completion and final result text
// 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);
}
}
// Only process result for current session
if (cursorCompletedSessionId === currentSessionId) {
try {
const r = latestMessage.data || {};
const textResult = typeof r.result === 'string' ? r.result : '';
@@ -2269,16 +2379,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} catch (e) {
console.warn('Error handling cursor-result message:', e);
}
// Mark session as inactive
const cursorSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId');
if (cursorSessionId && onSessionInactive) {
onSessionInactive(cursorSessionId);
}
// Store session ID for future use and trigger refresh
if (cursorSessionId && !currentSessionId) {
setCurrentSessionId(cursorSessionId);
// Store session ID for future use and trigger refresh (for new sessions)
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) {
setCurrentSessionId(cursorCompletedSessionId);
sessionStorage.removeItem('pendingSessionId');
// Trigger a project refresh to update the sidebar with the new session
@@ -2320,17 +2426,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
break;
case 'claude-complete':
// Get session ID from message or fall back to current session
const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId');
// Only update UI state if this is the current session
if (completedSessionId === currentSessionId) {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
}
// 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);
// Always mark the completed session as inactive and not processing
if (completedSessionId) {
if (onSessionInactive) {
onSessionInactive(completedSessionId);
}
if (onSessionNotProcessing) {
onSessionNotProcessing(completedSessionId);
}
}
// If we have a pending session ID and the conversation completed successfully, use it
@@ -2352,14 +2465,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
break;
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);
}
// Session Protection: Mark session as inactive when aborted
// User or system aborted the conversation, re-enable project updates
if (currentSessionId && onSessionInactive) {
onSessionInactive(currentSessionId);
// Always mark the aborted session as inactive and not processing
if (abortedSessionId) {
if (onSessionInactive) {
onSessionInactive(abortedSessionId);
}
if (onSessionNotProcessing) {
onSessionNotProcessing(abortedSessionId);
}
}
setChatMessages(prev => [...prev, {
@@ -2369,6 +2492,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}]);
break;
case 'session-status':
// Response to check-session-status request
const statusSessionId = latestMessage.sessionId;
const isCurrentSession = statusSessionId === currentSessionId ||
(selectedSession && statusSessionId === selectedSession.id);
if (isCurrentSession && latestMessage.isProcessing) {
// Session is currently processing, restore UI state
setIsLoading(true);
setCanAbortSession(true);
if (onSessionProcessing) {
onSessionProcessing(statusSessionId);
}
}
break;
case 'claude-status':
// Handle Claude working status messages
const statusData = latestMessage.data;
@@ -2571,6 +2709,102 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [input]);
// Poll token usage from JSONL file
useEffect(() => {
console.log('🔍 Token usage polling effect triggered', {
sessionId: selectedSession?.id,
projectPath: selectedProject?.path
});
if (!selectedProject) {
console.log('⚠️ Skipping token usage fetch - missing project');
return;
}
// No session selected - reset to zero (new session state)
if (!selectedSession) {
console.log('🆕 No session selected, resetting token budget to zero');
setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 });
return;
}
// For new sessions without an ID yet, reset to zero
if (!selectedSession.id || selectedSession.id.startsWith('new-session-')) {
console.log('🆕 New session detected, resetting token budget to zero');
setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 });
return;
}
// Create AbortController to cancel in-flight requests when session/project changes
let abortController = new AbortController();
const fetchTokenUsage = async () => {
// Abort previous request if still in flight
if (abortController.signal.aborted) {
abortController = new AbortController();
}
// Capture current session/project to verify before updating state
const currentSessionId = selectedSession.id;
const currentProjectPath = selectedProject.path;
try {
const url = `/api/sessions/${currentSessionId}/token-usage?projectPath=${encodeURIComponent(currentProjectPath)}`;
console.log('📊 Fetching token usage from:', url);
const response = await authenticatedFetch(url, {
signal: abortController.signal
});
// Only update state if session/project hasn't changed
if (currentSessionId !== selectedSession?.id || currentProjectPath !== selectedProject?.path) {
console.log('⚠️ Session/project changed during fetch, discarding stale data');
return;
}
if (response.ok) {
const data = await response.json();
console.log('✅ Token usage data received:', data);
setTokenBudget(data);
} else {
console.error('❌ Token usage fetch failed:', response.status, await response.text());
// Reset to zero if fetch fails (likely new session with no JSONL yet)
setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 });
}
} catch (error) {
// Don't log error if request was aborted (expected behavior)
if (error.name === 'AbortError') {
console.log('🚫 Token usage fetch aborted (session/project changed)');
return;
}
console.error('Failed to fetch token usage:', error);
// Reset to zero on error
setTokenBudget({ used: 0, total: parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000, percentage: 0 });
}
};
// Fetch immediately on mount/session change
fetchTokenUsage();
// Then poll every 5 seconds
const interval = setInterval(fetchTokenUsage, 5000);
// Also fetch when page becomes visible (tab focus/refresh)
const handleVisibilityChange = () => {
if (!document.hidden) {
fetchTokenUsage();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
// Abort any in-flight requests when effect cleans up
abortController.abort();
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [selectedSession?.id, selectedProject?.path]);
const handleTranscript = useCallback((text) => {
if (text.trim()) {
setInput(prevInput => {
@@ -3181,6 +3415,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
/>
);
})}
@@ -3265,6 +3500,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</span>
</div>
</button>
{/* Token usage pie chart - positioned next to mode indicator */}
<TokenUsagePie
used={tokenBudget?.used || 0}
total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000}
/>
{/* Scroll to bottom button - positioned next to mode indicator */}
{isUserScrolledUp && chatMessages.length > 0 && (
@@ -3366,7 +3606,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const isExpanded = e.target.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(isExpanded);
}}
placeholder="Ask Claude to help with your code... (@ to reference files)"
placeholder={`Ask ${provider === 'cursor' ? 'Cursor' : 'Claude'} to help with your code...`}
disabled={isLoading}
rows={1}
className="chat-input-placeholder w-full pl-12 pr-28 sm:pr-40 py-3 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
@@ -3465,8 +3705,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Hint text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
{sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes"
: "Press Enter to send • Shift+Enter for new line • Tab to change modes"}
</div>
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0'

View File

@@ -45,11 +45,15 @@ function MainContent({
// These functions control when project updates are paused during active conversations
onSessionActive, // Mark session as active when user sends message
onSessionInactive, // Mark session as inactive when conversation completes/aborts
onSessionProcessing, // Mark session as processing (thinking/working)
onSessionNotProcessing, // Mark session as not processing (finished thinking)
processingSessions, // Set of session IDs currently processing
onReplaceTemporarySession, // Replace temporary session ID with real session ID from WebSocket
onNavigateToSession, // Navigate to a specific session (for Claude CLI session duplication workaround)
onShowSettings, // Show tools settings panel
autoExpandTools, // Auto-expand tool accordions
showRawParameters, // Show raw parameters in tool accordions
showThinking, // Show thinking/reasoning sections
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
}) {
@@ -418,11 +422,15 @@ function MainContent({
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onSessionProcessing={onSessionProcessing}
onSessionNotProcessing={onSessionNotProcessing}
processingSessions={processingSessions}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}

View File

@@ -24,6 +24,8 @@ const QuickSettingsPanel = ({
onAutoExpandChange,
showRawParameters,
onShowRawParametersChange,
showThinking,
onShowThinkingChange,
autoScrollToBottom,
onAutoScrollChange,
sendByCtrlEnter,
@@ -126,6 +128,19 @@ const QuickSettingsPanel = ({
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
</label>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Show thinking
</span>
<input
type="checkbox"
checked={showThinking}
onChange={(e) => onShowThinkingChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
</label>
</div>
{/* View Options */}
<div className="space-y-2">

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { WebglAddon } from '@xterm/addon-webgl';
import 'xterm/css/xterm.css';
import '@xterm/xterm/css/xterm.css';
// CSS to remove xterm focus outline
const xtermStyles = `

View File

@@ -0,0 +1,53 @@
import React from 'react';
function TokenUsagePie({ used, total }) {
// Token usage visualization component
// Only bail out on missing values or nonpositive totals; allow used===0 to render 0%
if (used == null || total == null || total <= 0) return null;
const percentage = Math.min(100, (used / total) * 100);
const radius = 10;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
// Color based on usage level
const getColor = () => {
if (percentage < 50) return '#3b82f6'; // blue
if (percentage < 75) return '#f59e0b'; // orange
return '#ef4444'; // red
};
return (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg width="24" height="24" viewBox="0 0 24 24" className="transform -rotate-90">
{/* Background circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-gray-300 dark:text-gray-600"
/>
{/* Progress circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke={getColor()}
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
/>
</svg>
<span className="hidden sm:inline" title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
{percentage.toFixed(0)}%
</span>
</div>
);
}
export default TokenUsagePie;

View File

@@ -23,7 +23,26 @@ export default defineConfig(({ command, mode }) => {
}
},
build: {
outDir: 'dist'
outDir: 'dist',
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-codemirror': [
'@uiw/react-codemirror',
'@codemirror/lang-css',
'@codemirror/lang-html',
'@codemirror/lang-javascript',
'@codemirror/lang-json',
'@codemirror/lang-markdown',
'@codemirror/lang-python',
'@codemirror/theme-one-dark'
],
'vendor-xterm': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-clipboard', '@xterm/addon-webgl']
}
}
}
}
}
})