From 582621caa2cec0c322892709dfdb3aee2aa6ff5f Mon Sep 17 00:00:00 2001 From: joshwilhelmi Date: Sun, 5 Oct 2025 13:52:08 -0500 Subject: [PATCH 01/13] feat: Add token budget tracking and multiple improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- package-lock.json | 163 ++++++++++++++------------ package.json | 12 +- public/clear-cache.html | 85 ++++++++++++++ scripts/start.sh | 74 ++++++++++++ scripts/stop.sh | 46 ++++++++ server/claude-cli.js | 46 ++++++-- server/index.js | 6 +- src/App.jsx | 4 + src/components/ChatInterface.jsx | 81 +++++++++++-- src/components/MainContent.jsx | 2 + src/components/QuickSettingsPanel.jsx | 19 ++- src/components/Shell.jsx | 6 +- src/components/TokenUsagePie.jsx | 52 ++++++++ vite.config.js | 21 +++- 14 files changed, 510 insertions(+), 107 deletions(-) create mode 100644 public/clear-cache.html create mode 100755 scripts/start.sh create mode 100755 scripts/stop.sh create mode 100644 src/components/TokenUsagePie.jsx diff --git a/package-lock.json b/package-lock.json index 103a9040..17b0005c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,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", + "@playwright/test": "^1.55.1", "@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" @@ -2210,6 +2210,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", + "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -2506,50 +2521,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 +2777,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 +2799,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", @@ -7866,6 +7845,50 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -10674,18 +10697,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 +10772,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 +11064,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", diff --git a/package.json b/package.json index 6e8a814c..3880ac8f 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,12 @@ }, "scripts": { "dev": "concurrently --kill-others \"npm run server\" \"npm run client\"", - "server": "node server/index.js", + "server": "NODE_ENV=production node server/index.js", "client": "vite --host", "build": "vite build", "preview": "vite preview", - "start": "npm run build && npm run server", + "start": "bash scripts/start.sh", + "stop": "bash scripts/stop.sh", "release": "release-it" }, "keywords": [ @@ -46,10 +47,13 @@ "@codemirror/lang-markdown": "^6.3.3", "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.2", + "@playwright/test": "^1.55.1", "@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", @@ -72,9 +76,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" }, "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/scripts/start.sh b/scripts/start.sh new file mode 100755 index 00000000..f9c07993 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +# Start script for Claude Code UI +# This ensures clean startup of the server + +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Default port +PORT="${PORT:-3001}" + +echo -e "${YELLOW}Starting Claude Code UI...${NC}" + +# Change to project directory +cd "$PROJECT_DIR" + +# Check for processes using the port and stop them +if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null 2>&1 ; then + echo -e "${YELLOW}Port $PORT is already in use.${NC}" + + # Get process info + PROC_INFO=$(lsof -i:$PORT | grep LISTEN) + echo -e "${YELLOW}Process details:${NC}" + echo "$PROC_INFO" + + # Stop any Claude Code UI servers (local or global npm package) + if pgrep -f "claude-code-ui" >/dev/null 2>&1 ; then + echo -e "${YELLOW}Stopping existing Claude Code UI server...${NC}" + pkill -9 -f "claude-code-ui" || true + sleep 1 + fi + + if pgrep -f "node server/index.js" >/dev/null 2>&1 ; then + echo -e "${YELLOW}Stopping local server process...${NC}" + pkill -9 -f "node server/index.js" || true + sleep 1 + fi + + # Force kill whatever is on the port + if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null 2>&1 ; then + echo -e "${YELLOW}Force stopping process on port $PORT...${NC}" + lsof -ti:$PORT | xargs kill -9 2>/dev/null || true + sleep 2 + fi + + # Verify port is free + if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null 2>&1 ; then + echo -e "${RED}Failed to free port $PORT.${NC}" + echo -e "${RED}The port may be in use by Tailscale serve. Run:${NC}" + echo -e "${YELLOW} /Applications/Tailscale.app/Contents/MacOS/Tailscale serve reset${NC}" + exit 1 + fi + + echo -e "${GREEN}Port $PORT is now available.${NC}" +fi + +# Check if dist folder exists +if [ ! -d "dist" ]; then + echo -e "${YELLOW}No dist folder found. Running build...${NC}" + npm run build +fi + +# Start the server +echo -e "${GREEN}Starting server on port $PORT...${NC}" +NODE_ENV=production node server/index.js diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 00000000..e7d985dc --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Stop script for Claude Code UI +# This cleanly stops the server + +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Default port +PORT="${PORT:-3001}" + +echo -e "${YELLOW}Stopping Claude Code UI...${NC}" + +# Check if server is running on the port +if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null 2>&1 ; then + echo -e "${YELLOW}Found process on port $PORT. Stopping...${NC}" + + # Kill process on the port + lsof -ti:$PORT | xargs kill -9 2>/dev/null || true + + # Wait a moment + sleep 1 + + # Verify it's stopped + if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null 2>&1 ; then + echo -e "${RED}Failed to stop server on port $PORT${NC}" + exit 1 + else + echo -e "${GREEN}Server stopped successfully.${NC}" + fi +else + echo -e "${YELLOW}No server running on port $PORT.${NC}" +fi + +# Also kill any node processes running server/index.js +if pgrep -f "node server/index.js" >/dev/null 2>&1 ; then + echo -e "${YELLOW}Stopping additional server processes...${NC}" + pkill -f "node server/index.js" || true + sleep 1 + echo -e "${GREEN}All server processes stopped.${NC}" +fi diff --git a/server/claude-cli.js b/server/claude-cli.js index 2e685d76..7b500351 100755 --- a/server/claude-cli.js +++ b/server/claude-cli.js @@ -259,25 +259,25 @@ async function spawnClaude(command, options = {}, ws) { 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); - + console.log('📄 Parsed JSON response type:', response.type); + // 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; @@ -287,7 +287,39 @@ async function spawnClaude(command, options = {}, ws) { })); } } - + + // Parse token budget from usage information in result messages + if (response.type === 'result' && response.modelUsage) { + // Get the first (and usually only) model's usage data + const modelKey = Object.keys(response.modelUsage)[0]; + const modelData = response.modelUsage[modelKey]; + + if (modelData && modelData.contextWindow) { + // 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; + + const tokenBudget = { + used: totalUsed, + total: contextWindow + }; + + console.log('📊 Token budget:', tokenBudget); + + // Send token budget update to frontend + ws.send(JSON.stringify({ + type: 'token-budget', + data: tokenBudget + })); + } + } + // Send parsed response to WebSocket ws.send(JSON.stringify({ type: 'claude-response', diff --git a/server/index.js b/server/index.js index 2b7b2605..3123286e 100755 --- a/server/index.js +++ b/server/index.js @@ -1053,13 +1053,15 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r } }); -// Serve React app for all other routes +// 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}`); } }); diff --git a/src/App.jsx b/src/App.jsx index 5024680c..17b6c378 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 @@ -660,6 +661,7 @@ function AppContent() { onShowSettings={() => setShowSettings(true)} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} + showThinking={showThinking} autoScrollToBottom={autoScrollToBottom} sendByCtrlEnter={sendByCtrlEnter} /> @@ -682,6 +684,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..419e535c 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -26,6 +26,7 @@ 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'; @@ -156,7 +157,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 +1054,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile ) : (
{/* Thinking accordion for reasoning */} - {message.reasoning && ( + {showThinking && message.reasoning && (
💭 Thinking... @@ -1166,7 +1167,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, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, onTaskClick, onShowAllTasks }) { const { tasksEnabled } = useTasksSettings(); const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { @@ -1216,6 +1217,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); @@ -1775,6 +1777,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 +1837,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const loadMessages = async () => { if (selectedSession && selectedProject) { const provider = localStorage.getItem('selected-provider') || 'claude'; - - // Reset pagination state when switching sessions - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); + + // Only reset state if the session ID actually changed (not initial load) + const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; + + if (sessionChanged) { + // Reset pagination state when switching sessions + setMessagesOffset(0); + setHasMoreMessages(false); + setTotalMessages(0); + // Reset token budget when switching sessions + // It will update when user sends a message and receives new budget from WebSocket + setTokenBudget(null); + } else if (currentSessionId === null) { + // Initial load - reset pagination but not token budget + setMessagesOffset(0); + setHasMoreMessages(false); + setTotalMessages(0); + } if (provider === 'cursor') { // For Cursor, set the session ID for resuming @@ -1954,7 +1972,19 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } } break; - + + case 'token-budget': + // Update token budget from backend + console.log('📊 Received token budget:', latestMessage.data); + if (latestMessage.data) { + console.log('🔧 Setting tokenBudget state to:', latestMessage.data); + setTokenBudget(latestMessage.data); + console.log('✅ setTokenBudget called'); + } else { + console.warn('⚠️ token-budget data is empty'); + } + break; + case 'claude-response': const messageData = latestMessage.data.message || latestMessage.data; @@ -3181,6 +3211,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess onShowSettings={onShowSettings} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} + showThinking={showThinking} /> ); })} @@ -3265,7 +3296,37 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
- + + {/* Token usage pie chart - positioned next to mode indicator */} + {(() => { + // Default to 0 tokens if no budget received yet + const used = tokenBudget?.used || 0; + const total = tokenBudget?.total || 200000; // Default context window + + const percentage = total > 0 ? Math.min(100, (used / total) * 100) : 0; + console.log('🎨 Rendering pie chart:', { tokenBudget, used, total, percentage: percentage.toFixed(0) + '%' }); + const radius = 10; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (percentage / 100) * circumference; + const getColor = () => { + if (percentage < 50) return '#3b82f6'; + if (percentage < 75) return '#f59e0b'; + return '#ef4444'; + }; + + return ( +
+ + + + + + {percentage.toFixed(0)}% + +
+ ); + })()} + {/* Scroll to bottom button - positioned next to mode indicator */} {isUserScrolledUp && chatMessages.length > 0 && (