feat: Implement slash command menu with fixed positioning and dark mode (#211)

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

* feat: Implement slash command menu with fixed positioning and dark mode support

- Add CommandMenu component with grouped command display
- Implement command routes for listing, loading, and executing commands
- Add command parser utility for argument and file processing
- Fix menu positioning using fixed positioning relative to viewport
- Add dark mode support with proper text contrast
- Preserve metadata badge colors in dark mode
- Support built-in, project, and user-level commands
- Add keyboard navigation and selection

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

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

* Update server/index.js

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

* Update server/utils/commandParser.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 server/routes/commands.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 server/index.js

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

* Update server/utils/commandParser.js

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

* fix: Add responsive width constraints to CommandMenu

- Use min() function to cap width at viewport - 32px
- Add maxWidth constraint for better mobile support
- Update package-lock.json with new dependencies

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

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

* fix: Security and stability improvements for command execution and file operations

Security Fixes:
- Replace blocking fs.existsSync/readFileSync with async fs.promises.readFile in token usage endpoint
- Implement comprehensive command injection protection using shell-quote parser
- Validate commands against exact allowlist matches (no dangerous prefix matching)
- Detect and block shell operators (&&, ||, |, ;, etc.) and metacharacters
- Execute commands with execFile (shell: false) to prevent shell interpretation
- Add argument validation to reject dangerous characters

Bug Fixes:
- Remove premature handleCommandSelect call from selectCommand to prevent double-counting usage
- Add block scoping to 'session-aborted' switch case to prevent variable conflicts
- Fix case fall-through by properly scoping const declarations with braces

Technical Details:
- server/index.js: Replace sync file ops with await fsPromises.readFile()
- server/utils/commandParser.js: Complete security overhaul with shell-quote integration
- src/components/ChatInterface.jsx: Command selection now only inserts text, execution happens on send

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

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

* fix: Wrap orphaned token-usage endpoint code in proper async handler

- Fixed syntax error caused by orphaned code at lines 1097-1114
- Added proper app.get endpoint definition for token-usage API
- Wrapped code in async (req, res) handler with authentication middleware
- Preserves all security features (async file reads, path validation)

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

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

* security: Add path traversal protection to file operation endpoints

- Constrain file reads to project root directory
- Constrain binary file serving to project root
- Constrain file writes to project root
- Use extractProjectDirectory to get actual project path
- Validate resolved paths start with normalized project root
- Prevent authenticated users from accessing files outside their projects

Fixes path traversal vulnerability in:
- GET /api/projects/:projectName/file (read endpoint)
- GET /api/projects/:projectName/files/content (binary serve endpoint)
- PUT /api/projects/:projectName/file (save endpoint)

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

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

* fix: Use WebSocket.OPEN constant instead of instance properties

- Import WebSocket from 'ws' library
- Change all instances from client.OPEN/ws.OPEN to WebSocket.OPEN
- Fixed 4 occurrences: lines 111, 784, 831, 868
- Ensures correct WebSocket state checking using library constant

Addresses CodeRabbit security review feedback.

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

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

* fix: Improve token usage tracking and fix race conditions

- Use cumulative tokens from SDK instead of per-request tokens for accurate session totals
- Add configurable context window budget via CONTEXT_WINDOW env var (default 160000)
- Fix race condition where stale token usage data could overwrite current session data
- Replace polling with one-time fetch on session load + post-message update
- Add comprehensive debug logging for token budget flow
- Show token percentage on all screen sizes (remove sm:inline hiding)
- Add .mcp.json to .gitignore
- Add ARCHITECTURE.md and slash-command-tasks.md documentation

Technical improvements:
- Token budget now fetched after message completion instead of WebSocket
- Removed interval polling that could conflict with WebSocket updates
- Added session/project validation before updating state
- Improved input placeholder to wrap on small screens

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

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

* fix: Improve CommandMenu positioning for mobile devices

- Add responsive positioning logic that detects mobile screens (< 640px)
- On mobile: Position menu from bottom (80px above input) with full width
- On desktop: Use calculated top position with boundary checks
- Ensure menu stays within viewport on all screen sizes
- Use Math.max/min to prevent menu from going off-screen
- Apply consistent positioning to both empty and populated menu states

Technical changes:
- Add getMenuPosition() function to calculate responsive styles
- Mobile: bottom-anchored, full-width with 16px margins
- Desktop: top-anchored with viewport boundary constraints
- Spread menuPosition styles into both menu render cases

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

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

* fix: Add click-outside detection and improve CommandMenu positioning

- Add useEffect hook to detect clicks outside the menu and close it
- Fix mobile positioning to use calculated position from textarea instead of hardcoded bottom value
- Ensure menu appears just above the input on mobile with proper spacing
- Keep full-width layout on mobile screens (< 640px)
- Maintain viewport boundary checks on both mobile and desktop

Technical changes:
- Add mousedown event listener to document when menu is open
- Check if click target is outside menuRef and call onClose
- Remove hardcoded `bottom: '80px'` in favor of calculated `top` position
- Use Math.max to ensure menu stays at least 16px from top edge

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

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

* debug: Add console logging and improve mobile positioning logic

- Add console logs to debug positioning and rendering
- Improve mobile positioning with better space calculations
- Check if there's enough space above textarea before positioning
- Position from top of viewport if insufficient space above input
- Ensure menu stays within visible viewport boundaries

Debugging additions:
- Log isOpen, commandsLength, position, and menuPosition
- Log mobile positioning calculations

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

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

* fix: Use bottom positioning for CommandMenu on mobile

- Change mobile positioning from top-based to bottom-based (90px from bottom)
- This ensures menu always appears just above input, regardless of keyboard state
- Add maxHeight: '50vh' to prevent menu from taking up too much space
- Remove complex position calculations that didn't work well with mobile keyboard
- Remove debug console.log statements
- Menu now correctly appears above input on all mobile screen sizes

Technical changes:
- Mobile: Use fixed bottom positioning instead of calculated top
- Desktop: Continue using top positioning for consistency
- Simplified positioning logic for better maintainability

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

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

* fix: Filter Invalid API key messages from session titles

API error messages were appearing as session titles because they come
from assistant messages with isApiErrorMessage flag, but the filter
only checked user messages. Updated assistant message handling to:
- Skip messages with isApiErrorMessage: true flag
- Filter messages starting with "Invalid API key"

Also improved session title logic to prefer last user message over
last assistant message for better context.

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

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

* fix: Fix Temporal Dead Zone error by reordering function declarations

Reordered function declarations in ChatInterface.jsx to resolve ReferenceError
where executeCommand tried to call handleBuiltInCommand and handleCustomCommand
before they were initialized.

- Moved handleBuiltInCommand before executeCommand (now at line 1441)
- Moved handleCustomCommand before executeCommand (now at line 1533)
- executeCommand now at line 1564, after its dependencies

This fixes the "cannot access uninitialized variable" error that was preventing
the chat interface from loading.

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

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

* fix: Improve session handling, message routing, and mobile UX

Session Handling:
- Fix new session message routing by allowing messages through when currentSessionId is null
- Improve claude-complete event handling to update UI state for new sessions
- Add session loading ref to prevent duplicate scroll triggers during session switches
- Add extensive debug logging in claude-sdk.js to track session lifecycle

Mobile UX:
- Only close sidebar on mobile when switching between different projects
- Keep sidebar open when clicking sessions within the same project
- Add project context to session objects for better tracking

Command Execution:
- Auto-submit commands to Claude for processing after selection
- Set command content in input and programmatically submit form

Scroll Behavior:
- Fix scroll behavior during session loading with isLoadingSessionRef
- Prevent double-scroll effect when switching sessions
- Ensure smooth scroll to bottom after messages fully render

Message Filtering:
- Update global message types to include 'claude-complete'
- Allow messages through for new sessions (when currentSessionId is null)
- Improve session-specific message filtering logic

Dependencies:
- Update @esbuild/darwin-arm64 to direct dependency

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

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

* fix: Improve session handling, message routing, and mobile UX

- Remove stale Playwright debug files (.playwright-mcp/)
- Clean up slash-command-fix-progress.md tracking file
- Improve session switching stability in ClaudeStatus component
- Fix message routing to ensure responses go to correct session
- Enhance mobile UX for CommandMenu with better positioning
- Stabilize sidebar session management
- Fix Temporal Dead Zone errors in ChatInterface

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

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

* fix: Filter sessions containing Task Master subtask JSON from session list

- Change filtering from startsWith to includes for {"subtasks": pattern
- Apply filtering in parseJsonlSessions (line 781) for JSONL parsing
- Apply filtering in getSessions (line 630) before returning to API
- Fix inconsistent filter logic - use OR pattern for both user and assistant messages
- Add filtering for "CRITICAL: You MUST respond with ONLY a JSON" messages
- Prevents Task Master JSON responses from appearing as session titles in sidebar

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

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

* fix: Filter JSON response sessions from session list

- Change session filtering to use general pattern `startsWith('{ "')`
- Catches all Task Master JSON responses (subtasks, complexity analysis, tasks)
- Apply filter in both parseJsonlSessions() and getSessions() functions
- Prevents JSON responses from appearing as session titles in UI

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

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

* Update websocket.js

* Update projects.js

* Update CommandMenu.jsx

---------

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:50:57 -05:00
committed by GitHub
parent 6dd303a321
commit 44c88ec15f
32 changed files with 2690 additions and 308 deletions

1
.gitignore vendored
View File

@@ -107,6 +107,7 @@ temp/
.windsurf/
.serena/
CLAUDE.md
.mcp.json
# Database files

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

133
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@esbuild/darwin-arm64": "^0.25.11",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0",
@@ -31,6 +32,8 @@
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"express": "^4.18.2",
"fuse.js": "^6.6.2",
"gray-matter": "^4.0.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
@@ -658,15 +661,13 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz",
"integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
@@ -3176,6 +3177,15 @@
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -4676,6 +4686,23 @@
"@esbuild/win32-x64": "0.25.8"
}
},
"node_modules/esbuild/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4718,7 +4745,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
@@ -4910,6 +4936,18 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-content-type-parse": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
@@ -5141,6 +5179,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
"integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/gauge": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz",
@@ -5408,6 +5455,21 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
@@ -5837,6 +5899,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -6048,6 +6119,19 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -6129,6 +6213,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -8963,6 +9056,19 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -9574,6 +9680,12 @@
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sqlite": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz",
@@ -10125,6 +10237,15 @@
"node": ">=8"
}
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",

View File

@@ -151,23 +151,29 @@ function extractTokenBudget(resultMessage) {
return null;
}
// Get the first model's usage data (same as CLI implementation)
// Get the first model's usage data
const modelKey = Object.keys(resultMessage.modelUsage)[0];
const modelData = resultMessage.modelUsage[modelKey];
if (!modelData || !modelData.contextWindow) {
if (!modelData) {
return null;
}
// Calculate total tokens used (input + output + cache)
const inputTokens = modelData.inputTokens || 0;
const outputTokens = modelData.outputTokens || 0;
const cacheReadTokens = modelData.cacheReadInputTokens || 0;
const cacheCreationTokens = modelData.cacheCreationInputTokens || 0;
// Use cumulative tokens if available (tracks total for the session)
// Otherwise fall back to per-request tokens
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
// Total used = input + output + cache tokens
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
const contextWindow = modelData.contextWindow;
// Use configured context window budget from environment (default 160000)
// This is the user's budget limit, not the model's context window
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
console.log(`📊 Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
return {
used: totalUsed,
@@ -364,9 +370,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
}
// Process streaming messages
console.log('🔄 Starting async generator loop for session:', capturedSessionId || 'NEW');
for await (const message of queryInstance) {
// Capture session ID from first message
if (message.session_id && !capturedSessionId) {
console.log('📝 Captured session ID:', message.session_id);
capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
@@ -377,7 +385,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
type: 'session-created',
sessionId: capturedSessionId
}));
} else {
console.log('⚠️ Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
}
} else {
console.log('⚠️ No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
}
// Transform and send message to WebSocket
@@ -409,12 +421,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
await cleanupTempFiles(tempImagePaths, tempDir);
// Send completion event
console.log('✅ Streaming complete, sending claude-complete event');
ws.send(JSON.stringify({
type: 'claude-complete',
sessionId: capturedSessionId,
exitCode: 0,
isNewSession: !sessionId && !!command
}));
console.log('📤 claude-complete event sent');
} catch (error) {
console.error('SDK query error:', error);

View File

@@ -27,7 +27,7 @@ try {
console.log('PORT from env:', process.env.PORT);
import express from 'express';
import { WebSocketServer } from 'ws';
import { WebSocketServer, WebSocket } from 'ws';
import os from 'os';
import http from 'http';
import cors from 'cors';
@@ -46,6 +46,7 @@ import mcpRoutes from './routes/mcp.js';
import cursorRoutes from './routes/cursor.js';
import taskmasterRoutes from './routes/taskmaster.js';
import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -107,7 +108,7 @@ async function setupProjectsWatcher() {
});
connectedClients.forEach(client => {
if (client.readyState === client.OPEN) {
if (client.readyState === WebSocket.OPEN) {
client.send(updateMessage);
}
});
@@ -192,8 +193,24 @@ app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
// MCP utilities
app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
// Commands API Routes (protected)
app.use('/api/commands', authenticateToken, commandsRoutes);
// Static files served after API routes
app.use(express.static(path.join(__dirname, '../dist')));
// Add cache control: HTML files should not be cached, but assets can be cached
app.use(express.static(path.join(__dirname, '../dist'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
// Prevent HTML caching to avoid service worker issues after builds
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
// Cache static assets for 1 year (they have hashed names)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
}));
// API Routes (protected)
app.get('/api/config', authenticateToken, (req, res) => {
@@ -370,15 +387,24 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
console.log('📄 File read request:', projectName, filePath);
// Using fsPromises from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
// Security: ensure the requested path is inside the project root
if (!filePath) {
return res.status(400).json({ error: 'Invalid file path' });
}
const content = await fsPromises.readFile(filePath, 'utf8');
res.json({ content, path: filePath });
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
const resolved = path.resolve(filePath);
const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) {
return res.status(403).json({ error: 'Path must be under project root' });
}
const content = await fsPromises.readFile(resolved, 'utf8');
res.json({ content, path: resolved });
} catch (error) {
console.error('Error reading file:', error);
if (error.code === 'ENOENT') {
@@ -399,27 +425,35 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
console.log('🖼️ Binary file serve request:', projectName, filePath);
// Using fs from import
// Using mime from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
// Security: ensure the requested path is inside the project root
if (!filePath) {
return res.status(400).json({ error: 'Invalid file path' });
}
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
const resolved = path.resolve(filePath);
const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) {
return res.status(403).json({ error: 'Path must be under project root' });
}
// Check if file exists
try {
await fsPromises.access(filePath);
await fsPromises.access(resolved);
} catch (error) {
return res.status(404).json({ error: 'File not found' });
}
// Get file extension and set appropriate content type
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
const mimeType = mime.lookup(resolved) || 'application/octet-stream';
res.setHeader('Content-Type', mimeType);
// Stream the file
const fileStream = fs.createReadStream(filePath);
const fileStream = fs.createReadStream(resolved);
fileStream.pipe(res);
fileStream.on('error', (error) => {
@@ -445,10 +479,8 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
console.log('💾 File save request:', projectName, filePath);
// Using fsPromises from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
// Security: ensure the requested path is inside the project root
if (!filePath) {
return res.status(400).json({ error: 'Invalid file path' });
}
@@ -456,21 +488,32 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
return res.status(400).json({ error: 'Content is required' });
}
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' });
}
const resolved = path.resolve(filePath);
const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) {
return res.status(403).json({ error: 'Path must be under project root' });
}
// Create backup of original file
try {
const backupPath = filePath + '.backup.' + Date.now();
await fsPromises.copyFile(filePath, backupPath);
const backupPath = resolved + '.backup.' + Date.now();
await fsPromises.copyFile(resolved, backupPath);
console.log('📋 Created backup:', backupPath);
} catch (backupError) {
console.warn('Could not create backup:', backupError.message);
}
// Write the new content
await fsPromises.writeFile(filePath, content, 'utf8');
await fsPromises.writeFile(resolved, content, 'utf8');
res.json({
success: true,
path: filePath,
path: resolved,
message: 'File saved successfully'
});
} catch (error) {
@@ -751,7 +794,7 @@ function handleShellConnection(ws) {
// Handle data output
shellProcess.onData((data) => {
if (ws.readyState === ws.OPEN) {
if (ws.readyState === WebSocket.OPEN) {
let outputData = data;
// Check for various URL opening patterns
@@ -798,7 +841,7 @@ function handleShellConnection(ws) {
// Handle process exit
shellProcess.onExit((exitCode) => {
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
if (ws.readyState === ws.OPEN) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'output',
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
@@ -835,7 +878,7 @@ function handleShellConnection(ws) {
}
} catch (error) {
console.error('❌ Shell WebSocket error:', error.message);
if (ws.readyState === ws.OPEN) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'output',
data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
@@ -1090,30 +1133,50 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
}
});
// API endpoint to get token usage for a session by reading its JSONL file
app.get('/api/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
// Get token usage for a specific session
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { sessionId } = req.params;
const { projectPath } = req.query;
const { projectName, sessionId } = req.params;
const homeDir = os.homedir();
if (!projectPath) {
return res.status(400).json({ error: 'projectPath query parameter is required' });
// Extract actual project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
console.error('Error extracting project directory:', error);
return res.status(500).json({ error: 'Failed to determine project path' });
}
// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces /, spaces, ~, and _ with -
const homeDir = os.homedir();
const encodedPath = projectPath.replace(/[\/\s~_]/g, '-');
const jsonlPath = path.join(homeDir, '.claude', 'projects', encodedPath, `${sessionId}.jsonl`);
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
// Check if file exists
if (!fs.existsSync(jsonlPath)) {
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
// Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Read and parse the JSONL file
const fileContent = fs.readFileSync(jsonlPath, 'utf8');
let fileContent;
try {
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
}
throw error; // Re-throw other errors to be caught by outer try-catch
}
const lines = fileContent.trim().split('\n');
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
@@ -1164,9 +1227,18 @@ app.get('/api/sessions/:sessionId/token-usage', authenticateToken, async (req, r
// Serve React app for all other routes (excluding static files)
app.get('*', (req, res) => {
// Skip requests for static assets (files with extensions)
if (path.extname(req.path)) {
return res.status(404).send('Not found');
}
// Only serve index.html for HTML routes, not for static assets
// Static assets should already be handled by express.static middleware above
if (process.env.NODE_ENV === 'production') {
// Set no-cache headers for HTML to prevent service worker issues
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(path.join(__dirname, '../dist/index.html'));
} else {
// In development, redirect to Vite dev server

View File

@@ -627,8 +627,9 @@ async function getSessions(projectName, limit = 5, offset = 0) {
return session;
});
const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
.filter(session => !session.summary.startsWith('{ "'))
.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
const total = visibleSessions.length;
const paginatedSessions = visibleSessions.slice(offset, offset + limit);
const hasMore = offset + limit < total;
@@ -649,20 +650,26 @@ async function getSessions(projectName, limit = 5, offset = 0) {
async function parseJsonlSessions(filePath) {
const sessions = new Map();
const entries = [];
const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
entries.push(entry);
// Handle summary entries that don't have sessionId yet
if (entry.type === 'summary' && entry.summary && !entry.sessionId && entry.leafUuid) {
pendingSummaries.set(entry.leafUuid, entry.summary);
}
if (entry.sessionId) {
if (!sessions.has(entry.sessionId)) {
sessions.set(entry.sessionId, {
@@ -670,24 +677,84 @@ async function parseJsonlSessions(filePath) {
summary: 'New Session',
messageCount: 0,
lastActivity: new Date(),
cwd: entry.cwd || ''
cwd: entry.cwd || '',
lastUserMessage: null,
lastAssistantMessage: null
});
}
const session = sessions.get(entry.sessionId);
// Update summary from summary entries or first user message
// Apply pending summary if this entry has a parentUuid that matches a pending summary
if (session.summary === 'New Session' && entry.parentUuid && pendingSummaries.has(entry.parentUuid)) {
session.summary = pendingSummaries.get(entry.parentUuid);
}
// Update summary from summary entries with sessionId
if (entry.type === 'summary' && entry.summary) {
session.summary = entry.summary;
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
}
// Track last user and assistant messages (skip system messages)
if (entry.message?.role === 'user' && entry.message?.content) {
const content = entry.message.content;
if (typeof content === 'string' && content.length > 0 && !content.startsWith('<command-name>')) {
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
// Extract text from array format if needed
let textContent = content;
if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
textContent = content[0].text;
}
const isSystemMessage = typeof textContent === 'string' && (
textContent.startsWith('<command-name>') ||
textContent.startsWith('<command-message>') ||
textContent.startsWith('<command-args>') ||
textContent.startsWith('<local-command-stdout>') ||
textContent.startsWith('<system-reminder>') ||
textContent.startsWith('Caveat:') ||
textContent.startsWith('This session is being continued from a previous') ||
textContent.startsWith('Invalid API key') ||
textContent.includes('{"subtasks":') || // Filter Task Master prompts
textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts
textContent === 'Warmup' // Explicitly filter out "Warmup"
);
if (typeof textContent === 'string' && textContent.length > 0 && !isSystemMessage) {
session.lastUserMessage = textContent;
}
} else if (entry.message?.role === 'assistant' && entry.message?.content) {
// Skip API error messages using the isApiErrorMessage flag
if (entry.isApiErrorMessage === true) {
// Skip this message entirely
} else {
// Track last assistant text message
let assistantText = null;
if (Array.isArray(entry.message.content)) {
for (const part of entry.message.content) {
if (part.type === 'text' && part.text) {
assistantText = part.text;
}
}
} else if (typeof entry.message.content === 'string') {
assistantText = entry.message.content;
}
// Additional filter for assistant messages with system content
const isSystemAssistantMessage = typeof assistantText === 'string' && (
assistantText.startsWith('Invalid API key') ||
assistantText.includes('{"subtasks":') ||
assistantText.includes('CRITICAL: You MUST respond with ONLY a JSON')
);
if (assistantText && !isSystemAssistantMessage) {
session.lastAssistantMessage = assistantText;
}
}
}
session.messageCount++;
if (entry.timestamp) {
session.lastActivity = new Date(entry.timestamp);
}
@@ -697,12 +764,36 @@ async function parseJsonlSessions(filePath) {
}
}
}
// After processing all entries, set final summary based on last message if no summary exists
for (const session of sessions.values()) {
if (session.summary === 'New Session') {
// Prefer last user message, fall back to last assistant message
const lastMessage = session.lastUserMessage || session.lastAssistantMessage;
if (lastMessage) {
session.summary = lastMessage.length > 50 ? lastMessage.substring(0, 50) + '...' : lastMessage;
}
}
}
// Filter out sessions that contain JSON responses (Task Master errors)
const allSessions = Array.from(sessions.values());
const filteredSessions = allSessions.filter(session => {
const shouldFilter = session.summary.startsWith('{ "');
if (shouldFilter) {
}
// Log a sample of summaries to debug
if (Math.random() < 0.01) { // Log 1% of sessions
}
return !shouldFilter;
});
return {
sessions: Array.from(sessions.values()),
sessions: filteredSessions,
entries: entries
};
} catch (error) {
console.error('Error reading JSONL file:', error);
return { sessions: [], entries: [] };
@@ -1060,4 +1151,4 @@ export {
saveProjectConfig,
extractProjectDirectory,
clearProjectDirectoryCache
};
};

572
server/routes/commands.js Normal file
View File

@@ -0,0 +1,572 @@
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import os from 'os';
import matter from 'gray-matter';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const router = express.Router();
/**
* Recursively scan directory for command files (.md)
* @param {string} dir - Directory to scan
* @param {string} baseDir - Base directory for relative paths
* @param {string} namespace - Namespace for commands (e.g., 'project', 'user')
* @returns {Promise<Array>} Array of command objects
*/
async function scanCommandsDirectory(dir, baseDir, namespace) {
const commands = [];
try {
// Check if directory exists
await fs.access(dir);
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recursively scan subdirectories
const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
commands.push(...subCommands);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
// Parse markdown file for metadata
try {
const content = await fs.readFile(fullPath, 'utf8');
const { data: frontmatter, content: commandContent } = matter(content);
// Calculate relative path from baseDir for command name
const relativePath = path.relative(baseDir, fullPath);
// Remove .md extension and convert to command name
const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
// Extract description from frontmatter or first line of content
let description = frontmatter.description || '';
if (!description) {
const firstLine = commandContent.trim().split('\n')[0];
description = firstLine.replace(/^#+\s*/, '').trim();
}
commands.push({
name: commandName,
path: fullPath,
relativePath,
description,
namespace,
metadata: frontmatter
});
} catch (err) {
console.error(`Error parsing command file ${fullPath}:`, err.message);
}
}
}
} catch (err) {
// Directory doesn't exist or can't be accessed - this is okay
if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
console.error(`Error scanning directory ${dir}:`, err.message);
}
}
return commands;
}
/**
* Built-in commands that are always available
*/
const builtInCommands = [
{
name: '/help',
description: 'Show help documentation for Claude Code',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/clear',
description: 'Clear the conversation history',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/model',
description: 'Switch or view the current AI model',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/cost',
description: 'Display token usage and cost information',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/memory',
description: 'Open CLAUDE.md memory file for editing',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/config',
description: 'Open settings and configuration',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/status',
description: 'Show system status and version information',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/rewind',
description: 'Rewind the conversation to a previous state',
namespace: 'builtin',
metadata: { type: 'builtin' }
}
];
/**
* Built-in command handlers
* Each handler returns { type: 'builtin', action: string, data: any }
*/
const builtInHandlers = {
'/help': async (args, context) => {
const helpText = `# Claude Code Commands
## Built-in Commands
${builtInCommands.map(cmd => `### ${cmd.name}
${cmd.description}
`).join('\n')}
## Custom Commands
Custom commands can be created in:
- Project: \`.claude/commands/\` (project-specific)
- User: \`~/.claude/commands/\` (available in all projects)
### Command Syntax
- **Arguments**: Use \`$ARGUMENTS\` for all args or \`$1\`, \`$2\`, etc. for positional
- **File Includes**: Use \`@filename\` to include file contents
- **Bash Commands**: Use \`!command\` to execute bash commands
### Examples
\`\`\`markdown
/mycommand arg1 arg2
\`\`\`
`;
return {
type: 'builtin',
action: 'help',
data: {
content: helpText,
format: 'markdown'
}
};
},
'/clear': async (args, context) => {
return {
type: 'builtin',
action: 'clear',
data: {
message: 'Conversation history cleared'
}
};
},
'/model': async (args, context) => {
// Read available models from config or defaults
const availableModels = {
claude: [
'claude-sonnet-4.5',
'claude-sonnet-4',
'claude-opus-4',
'claude-sonnet-3.5'
],
cursor: [
'gpt-5',
'sonnet-4',
'opus-4.1'
]
};
const currentProvider = context?.provider || 'claude';
const currentModel = context?.model || 'claude-sonnet-4.5';
return {
type: 'builtin',
action: 'model',
data: {
current: {
provider: currentProvider,
model: currentModel
},
available: availableModels,
message: args.length > 0
? `Switching to model: ${args[0]}`
: `Current model: ${currentModel}`
}
};
},
'/cost': async (args, context) => {
// Calculate token usage and cost
const sessionId = context?.sessionId;
const tokenUsage = context?.tokenUsage || { used: 0, total: 200000 };
const costPerMillion = {
'claude-sonnet-4.5': { input: 3, output: 15 },
'claude-sonnet-4': { input: 3, output: 15 },
'claude-opus-4': { input: 15, output: 75 },
'gpt-5': { input: 5, output: 15 }
};
const model = context?.model || 'claude-sonnet-4.5';
const rates = costPerMillion[model] || costPerMillion['claude-sonnet-4.5'];
// Estimate 70% input, 30% output
const estimatedInputTokens = Math.floor(tokenUsage.used * 0.7);
const estimatedOutputTokens = Math.floor(tokenUsage.used * 0.3);
const inputCost = (estimatedInputTokens / 1000000) * rates.input;
const outputCost = (estimatedOutputTokens / 1000000) * rates.output;
const totalCost = inputCost + outputCost;
return {
type: 'builtin',
action: 'cost',
data: {
tokenUsage: {
used: tokenUsage.used,
total: tokenUsage.total,
percentage: ((tokenUsage.used / tokenUsage.total) * 100).toFixed(1)
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
currency: 'USD'
},
model,
rates
}
};
},
'/status': async (args, context) => {
// Read version from package.json
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
let version = 'unknown';
let packageName = 'claude-code-ui';
try {
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
version = packageJson.version;
packageName = packageJson.name;
} catch (err) {
console.error('Error reading package.json:', err);
}
const uptime = process.uptime();
const uptimeMinutes = Math.floor(uptime / 60);
const uptimeHours = Math.floor(uptimeMinutes / 60);
const uptimeFormatted = uptimeHours > 0
? `${uptimeHours}h ${uptimeMinutes % 60}m`
: `${uptimeMinutes}m`;
return {
type: 'builtin',
action: 'status',
data: {
version,
packageName,
uptime: uptimeFormatted,
uptimeSeconds: Math.floor(uptime),
model: context?.model || 'claude-sonnet-4.5',
provider: context?.provider || 'claude',
nodeVersion: process.version,
platform: process.platform
}
};
},
'/memory': async (args, context) => {
const projectPath = context?.projectPath;
if (!projectPath) {
return {
type: 'builtin',
action: 'memory',
data: {
error: 'No project selected',
message: 'Please select a project to access its CLAUDE.md file'
}
};
}
const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
// Check if CLAUDE.md exists
let exists = false;
try {
await fs.access(claudeMdPath);
exists = true;
} catch (err) {
// File doesn't exist
}
return {
type: 'builtin',
action: 'memory',
data: {
path: claudeMdPath,
exists,
message: exists
? `Opening CLAUDE.md at ${claudeMdPath}`
: `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`
}
};
},
'/config': async (args, context) => {
return {
type: 'builtin',
action: 'config',
data: {
message: 'Opening settings...'
}
};
},
'/rewind': async (args, context) => {
const steps = args[0] ? parseInt(args[0]) : 1;
if (isNaN(steps) || steps < 1) {
return {
type: 'builtin',
action: 'rewind',
data: {
error: 'Invalid steps parameter',
message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
}
};
}
return {
type: 'builtin',
action: 'rewind',
data: {
steps,
message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
}
};
}
};
/**
* POST /api/commands/list
* List all available commands from project and user directories
*/
router.post('/list', async (req, res) => {
try {
const { projectPath } = req.body;
const allCommands = [...builtInCommands];
// Scan project-level commands (.claude/commands/)
if (projectPath) {
const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
const projectCommands = await scanCommandsDirectory(
projectCommandsDir,
projectCommandsDir,
'project'
);
allCommands.push(...projectCommands);
}
// Scan user-level commands (~/.claude/commands/)
const homeDir = os.homedir();
const userCommandsDir = path.join(homeDir, '.claude', 'commands');
const userCommands = await scanCommandsDirectory(
userCommandsDir,
userCommandsDir,
'user'
);
allCommands.push(...userCommands);
// Separate built-in and custom commands
const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
// Sort commands alphabetically by name
customCommands.sort((a, b) => a.name.localeCompare(b.name));
res.json({
builtIn: builtInCommands,
custom: customCommands,
count: allCommands.length
});
} catch (error) {
console.error('Error listing commands:', error);
res.status(500).json({
error: 'Failed to list commands',
message: error.message
});
}
});
/**
* POST /api/commands/load
* Load a specific command file and return its content and metadata
*/
router.post('/load', async (req, res) => {
try {
const { commandPath } = req.body;
if (!commandPath) {
return res.status(400).json({
error: 'Command path is required'
});
}
// Security: Prevent path traversal
const resolvedPath = path.resolve(commandPath);
if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
!resolvedPath.includes('.claude/commands')) {
return res.status(403).json({
error: 'Access denied',
message: 'Command must be in .claude/commands directory'
});
}
// Read and parse the command file
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = matter(content);
res.json({
path: commandPath,
metadata,
content: commandContent
});
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({
error: 'Command not found',
message: `Command file not found: ${req.body.commandPath}`
});
}
console.error('Error loading command:', error);
res.status(500).json({
error: 'Failed to load command',
message: error.message
});
}
});
/**
* POST /api/commands/execute
* Execute a command with argument replacement
* This endpoint prepares the command content but doesn't execute bash commands yet
* (that will be handled in the command parser utility)
*/
router.post('/execute', async (req, res) => {
try {
const { commandName, commandPath, args = [], context = {} } = req.body;
if (!commandName) {
return res.status(400).json({
error: 'Command name is required'
});
}
// Handle built-in commands
const handler = builtInHandlers[commandName];
if (handler) {
try {
const result = await handler(args, context);
return res.json({
...result,
command: commandName
});
} catch (error) {
console.error(`Error executing built-in command ${commandName}:`, error);
return res.status(500).json({
error: 'Command execution failed',
message: error.message,
command: commandName
});
}
}
// Handle custom commands
if (!commandPath) {
return res.status(400).json({
error: 'Command path is required for custom commands'
});
}
// Load command content
// Security: validate commandPath is within allowed directories
{
const resolvedPath = path.resolve(commandPath);
const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
const projectBase = context?.projectPath
? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
: null;
const isUnder = (base) => {
const rel = path.relative(base, resolvedPath);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
};
if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {
return res.status(403).json({
error: 'Access denied',
message: 'Command must be in .claude/commands directory'
});
}
}
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = matter(content);
// Basic argument replacement (will be enhanced in command parser utility)
let processedContent = commandContent;
// Replace $ARGUMENTS with all arguments joined
const argsString = args.join(' ');
processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
// Replace $1, $2, etc. with positional arguments
args.forEach((arg, index) => {
const placeholder = `$${index + 1}`;
processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
});
res.json({
type: 'custom',
command: commandName,
content: processedContent,
metadata,
hasFileIncludes: processedContent.includes('@'),
hasBashCommands: processedContent.includes('!')
});
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({
error: 'Command not found',
message: `Command file not found: ${req.body.commandPath}`
});
}
console.error('Error executing command:', error);
res.status(500).json({
error: 'Failed to execute command',
message: error.message
});
}
});
export default router;

View File

@@ -0,0 +1,303 @@
import matter from 'gray-matter';
import { promises as fs } from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { parse as parseShellCommand } from 'shell-quote';
const execFileAsync = promisify(execFile);
// Configuration
const MAX_INCLUDE_DEPTH = 3;
const BASH_TIMEOUT = 30000; // 30 seconds
const BASH_COMMAND_ALLOWLIST = [
'echo',
'ls',
'pwd',
'date',
'whoami',
'git',
'npm',
'node',
'cat',
'grep',
'find',
'task-master'
];
/**
* Parse a markdown command file and extract frontmatter and content
* @param {string} content - Raw markdown content
* @returns {object} Parsed command with data (frontmatter) and content
*/
export function parseCommand(content) {
try {
const parsed = matter(content);
return {
data: parsed.data || {},
content: parsed.content || '',
raw: content
};
} catch (error) {
throw new Error(`Failed to parse command: ${error.message}`);
}
}
/**
* Replace argument placeholders in content
* @param {string} content - Content with placeholders
* @param {string|array} args - Arguments to replace (string or array)
* @returns {string} Content with replaced arguments
*/
export function replaceArguments(content, args) {
if (!content) return content;
let result = content;
// Convert args to array if it's a string
const argsArray = Array.isArray(args) ? args : (args ? [args] : []);
// Replace $ARGUMENTS with all arguments joined by space
const allArgs = argsArray.join(' ');
result = result.replace(/\$ARGUMENTS/g, allArgs);
// Replace positional arguments $1-$9
for (let i = 1; i <= 9; i++) {
const regex = new RegExp(`\\$${i}`, 'g');
const value = argsArray[i - 1] || '';
result = result.replace(regex, value);
}
return result;
}
/**
* Validate file path to prevent directory traversal
* @param {string} filePath - Path to validate
* @param {string} basePath - Base directory path
* @returns {boolean} True if path is safe
*/
export function isPathSafe(filePath, basePath) {
const resolvedPath = path.resolve(basePath, filePath);
const resolvedBase = path.resolve(basePath);
const relative = path.relative(resolvedBase, resolvedPath);
return (
relative !== '' &&
!relative.startsWith('..') &&
!path.isAbsolute(relative)
);
}
/**
* Process file includes in content (@filename syntax)
* @param {string} content - Content with @filename includes
* @param {string} basePath - Base directory for resolving file paths
* @param {number} depth - Current recursion depth
* @returns {Promise<string>} Content with includes resolved
*/
export async function processFileIncludes(content, basePath, depth = 0) {
if (!content) return content;
// Prevent infinite recursion
if (depth >= MAX_INCLUDE_DEPTH) {
throw new Error(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`);
}
// Match @filename patterns (at start of line or after whitespace)
const includePattern = /(?:^|\s)@([^\s]+)/gm;
const matches = [...content.matchAll(includePattern)];
if (matches.length === 0) {
return content;
}
let result = content;
for (const match of matches) {
const fullMatch = match[0];
const filename = match[1];
// Security: prevent directory traversal
if (!isPathSafe(filename, basePath)) {
throw new Error(`Invalid file path (directory traversal detected): ${filename}`);
}
try {
const filePath = path.resolve(basePath, filename);
const fileContent = await fs.readFile(filePath, 'utf-8');
// Recursively process includes in the included file
const processedContent = await processFileIncludes(fileContent, basePath, depth + 1);
// Replace the @filename with the file content
result = result.replace(fullMatch, fullMatch.startsWith(' ') ? ' ' + processedContent : processedContent);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`File not found: ${filename}`);
}
throw error;
}
}
return result;
}
/**
* Validate that a command and its arguments are safe
* @param {string} commandString - Command string to validate
* @returns {{ allowed: boolean, command: string, args: string[], error?: string }} Validation result
*/
export function validateCommand(commandString) {
const trimmedCommand = commandString.trim();
if (!trimmedCommand) {
return { allowed: false, command: '', args: [], error: 'Empty command' };
}
// Parse the command using shell-quote to handle quotes properly
const parsed = parseShellCommand(trimmedCommand);
// Check for shell operators or control structures
const hasOperators = parsed.some(token =>
typeof token === 'object' && token.op
);
if (hasOperators) {
return {
allowed: false,
command: '',
args: [],
error: 'Shell operators (&&, ||, |, ;, etc.) are not allowed'
};
}
// Extract command and args (all should be strings after validation)
const tokens = parsed.filter(token => typeof token === 'string');
if (tokens.length === 0) {
return { allowed: false, command: '', args: [], error: 'No valid command found' };
}
const [command, ...args] = tokens;
// Extract just the command name (remove path if present)
const commandName = path.basename(command);
// Check if command exactly matches allowlist (no prefix matching)
const isAllowed = BASH_COMMAND_ALLOWLIST.includes(commandName);
if (!isAllowed) {
return {
allowed: false,
command: commandName,
args,
error: `Command '${commandName}' is not in the allowlist`
};
}
// Validate arguments don't contain dangerous metacharacters
const dangerousPattern = /[;&|`$()<>{}[\]\\]/;
for (const arg of args) {
if (dangerousPattern.test(arg)) {
return {
allowed: false,
command: commandName,
args,
error: `Argument contains dangerous characters: ${arg}`
};
}
}
return { allowed: true, command: commandName, args };
}
/**
* Backward compatibility: Check if command is allowed (deprecated)
* @deprecated Use validateCommand() instead for better security
* @param {string} command - Command to validate
* @returns {boolean} True if command is allowed
*/
export function isBashCommandAllowed(command) {
const result = validateCommand(command);
return result.allowed;
}
/**
* Sanitize bash command output
* @param {string} output - Raw command output
* @returns {string} Sanitized output
*/
export function sanitizeOutput(output) {
if (!output) return '';
// Remove control characters except \t, \n, \r
return [...output]
.filter(ch => {
const code = ch.charCodeAt(0);
return code === 9 // \t
|| code === 10 // \n
|| code === 13 // \r
|| (code >= 32 && code !== 127);
})
.join('');
}
/**
* Process bash commands in content (!command syntax)
* @param {string} content - Content with !command syntax
* @param {object} options - Options for bash execution
* @returns {Promise<string>} Content with bash commands executed and replaced
*/
export async function processBashCommands(content, options = {}) {
if (!content) return content;
const { cwd = process.cwd(), timeout = BASH_TIMEOUT } = options;
// Match !command patterns (at start of line or after whitespace)
const commandPattern = /(?:^|\n)!(.+?)(?=\n|$)/g;
const matches = [...content.matchAll(commandPattern)];
if (matches.length === 0) {
return content;
}
let result = content;
for (const match of matches) {
const fullMatch = match[0];
const commandString = match[1].trim();
// Security: validate command and parse args
const validation = validateCommand(commandString);
if (!validation.allowed) {
throw new Error(`Command not allowed: ${commandString} - ${validation.error}`);
}
try {
// Execute without shell using execFile with parsed args
const { stdout, stderr } = await execFileAsync(
validation.command,
validation.args,
{
cwd,
timeout,
maxBuffer: 1024 * 1024, // 1MB max output
shell: false, // IMPORTANT: No shell interpretation
env: { ...process.env, PATH: process.env.PATH } // Inherit PATH for finding commands
}
);
const output = sanitizeOutput(stdout || stderr || '');
// Replace the !command with the output
result = result.replace(fullMatch, fullMatch.startsWith('\n') ? '\n' + output : output);
} catch (error) {
if (error.killed) {
throw new Error(`Command timeout: ${commandString}`);
}
throw new Error(`Command failed: ${commandString} - ${error.message}`);
}
}
return result;
}

View File

@@ -0,0 +1,151 @@
# Slash Command Execution Fix - Progress Report
## Issue
Slash commands weren't executing when selected from the command menu. After typing a command like `/tm:list` and selecting it from the menu, nothing would happen - the page would stay on "Choose Your AI Assistant" screen.
## Root Cause
The `handleCustomCommand` function was trying to call `handleSubmit` via a ref, but the ref wasn't being set properly. Originally attempted to set the ref inside `handleSubmit` itself, which meant it was only set AFTER the first submit - too late for command execution.
## Solution Implemented
1. Converted `handleSubmit` to use `useCallback` with proper dependencies
2. Added a `useEffect` hook that runs after `handleSubmit` is defined to store it in `handleSubmitRef`
3. Now `handleCustomCommand` can access `handleSubmit` via the ref and call it with a fake event
## Code Changes
### File: src/components/ChatInterface.jsx
**Added ref declaration (around line 1534):**
```javascript
// Ref to store handleSubmit so we can call it from handleCustomCommand
const handleSubmitRef = useRef(null);
```
**Modified handleCustomCommand (around line 1555):**
```javascript
// Set the input to the command content
setInput(content);
// Wait for state to update, then directly call handleSubmit
setTimeout(() => {
if (handleSubmitRef.current) {
// Create a fake event to pass to handleSubmit
const fakeEvent = { preventDefault: () => {} };
handleSubmitRef.current(fakeEvent);
}
}, 50);
```
**Converted handleSubmit to useCallback (line 3292):**
```javascript
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
if (!input.trim() || isLoading || !selectedProject) return;
// ... rest of function
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
```
**Added useEffect to store ref (line 3437):**
```javascript
// Store handleSubmit in ref so handleCustomCommand can access it
useEffect(() => {
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
```
## Fixed Issues
### 1. Commands Button Visibility ✅ FIXED
- **Problem**: Button was not showing in active chat sessions with provider selected
- **Root Cause**: Button was positioned at `right-14 sm:right-16` which overlapped with the clear button at `sm:right-28`
- **Solution**: Changed button position to `right-14 sm:right-36` to place it left of the clear button
- **File**: src/components/ChatInterface.jsx:4255
- **Status**: Fixed in build dist/assets/index-CWRjcZ7A.js
### 2. Slash Command Menu Positioning ✅ FIXED
- **Problem**: Mobile positioning was inconsistent - used wrong ref for bottom calculation
- **Root Cause**: Position calculation used `inputContainerRef` (permission mode selector) instead of `textareaRef` (actual input)
- **Solution**:
- Changed bottom calculation to use `textareaRef` instead of `inputContainerRef`
- Updated formula: `window.innerHeight - textareaRef.getBoundingClientRect().top + 8`
- Removed extra `+ 8` in CommandMenu.jsx since spacing is already in the calculation
- Added explicit `maxHeight: '300px'` to desktop positioning for consistency
- Mobile maxHeight now uses `min(50vh, 300px)` for better consistency
- **Files Modified**:
- src/components/ChatInterface.jsx:4132-4134 - Fixed bottom position calculation
- src/components/CommandMenu.jsx:30-46 - Improved positioning logic and max heights
## Related Issues Found (Not Fixed Yet)
### 3. Service Worker Caching Issue
- After building, the service worker caches old build files
- Requires manual unregistration of service worker on first load after build
- Causes 404 errors for old asset filenames (e.g., index-n_2V3_vw.js when new build has index-Wp3pq386.js)
- Need to implement proper cache busting or service worker update strategy
### 4. Chat Screen Jumping
- Screen jumps/scrolls when Task Master widget appears/disappears
- Likely due to layout shifts from the task widget
## Testing Status
- ✅ Slash command execution fix implemented and built
- ✅ Commands button visibility fix implemented and built
- ⏳ Not yet tested end-to-end due to service worker caching issues requiring manual cache clearing
- Need to test:
1. Verify commands button is now visible to the left of clear button
2. Click commands button to open menu
3. Type `/tm:list` in chat input
4. Select command from menu
5. Verify command content loads and sends to Claude
6. Verify session is created if none exists
## Next Steps
1. Test the slash command button visibility fix
2. Test the slash command execution fix end-to-end
3. Fix service worker caching to enable easier testing
4. Fix chat screen jumping issue
## Build Info
- Latest build: dist/assets/index-C5zDTo8x.js (657.55 kB)
- Commands button positioned at `right-14 sm:right-36` (mobile/desktop)
- Menu positioning uses `textareaRef` for accurate placement
- Mobile menu: `bottom` calculated from textarea top + 8px spacing
- Desktop menu: `top` calculated with 316px offset, max 300px height
- Server running on port 3001
- Using Claude Agents SDK for Claude integration
## Implementation Details
### Mobile Positioning
```javascript
// ChatInterface.jsx - Position calculation
bottom: textareaRef.current
? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8
: 90
// CommandMenu.jsx - Mobile layout
{
position: 'fixed',
bottom: `${inputBottom}px`,
left: '16px',
right: '16px',
maxHeight: 'min(50vh, 300px)'
}
```
### Desktop Positioning
```javascript
// ChatInterface.jsx - Position calculation
top: textareaRef.current
? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316)
: 0
// CommandMenu.jsx - Desktop layout
{
position: 'fixed',
top: `${calculatedTop}px`,
left: `${position.left}px`,
width: 'min(400px, calc(100vw - 32px))',
maxHeight: '300px'
}
```

View File

@@ -70,6 +70,10 @@ function AppContent() {
// This allows us to restore the "Thinking..." banner when switching back to a processing session
const [processingSessions, setProcessingSessions] = useState(new Set());
// External Message Update Trigger: Incremented when external CLI modifies current session's JSONL
// Triggers ChatInterface to reload messages without switching sessions
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
const { ws, sendMessage, messages } = useWebSocketContext();
// Detect if running as PWA
@@ -164,7 +168,32 @@ function AppContent() {
const latestMessage = messages[messages.length - 1];
if (latestMessage.type === 'projects_updated') {
// External Session Update Detection: Check if the changed file is the current session's JSONL
// If so, and the session is not active, trigger a message reload in ChatInterface
if (latestMessage.changedFile && selectedSession && selectedProject) {
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
const changedFileParts = latestMessage.changedFile.split('/');
if (changedFileParts.length >= 2) {
const filename = changedFileParts[changedFileParts.length - 1];
const changedSessionId = filename.replace('.jsonl', '');
// Check if this is the currently-selected session
if (changedSessionId === selectedSession.id) {
const isSessionActive = activeSessions.has(selectedSession.id);
if (!isSessionActive) {
// Session is not active - safe to reload messages
console.log('🔄 External CLI update detected for current session:', changedSessionId);
setExternalMessageUpdate(prev => prev + 1);
} else {
// Session is active - skip reload to avoid interrupting user
console.log('⏸️ External update paused - session is active:', changedSessionId);
}
}
}
}
// Session Protection Logic: Allow additions but prevent changes during active conversations
// This allows new sessions/projects to appear in sidebar while protecting active chat messages
// We check for two types of active sessions:
@@ -332,7 +361,7 @@ function AppContent() {
if (activeTab !== 'git' && activeTab !== 'preview') {
setActiveTab('chat');
}
// For Cursor sessions, we need to set the session ID differently
// since they're persistent and not created by Claude
const provider = localStorage.getItem('selected-provider') || 'claude';
@@ -340,9 +369,17 @@ function AppContent() {
// Cursor sessions have persistent IDs
sessionStorage.setItem('cursorSessionId', session.id);
}
// Only close sidebar on mobile if switching to a different project
if (isMobile) {
setSidebarOpen(false);
const sessionProjectName = session.__projectName;
const currentProjectName = selectedProject?.name;
// Close sidebar if clicking a session from a different project
// Keep it open if clicking a session from the same project
if (sessionProjectName !== currentProjectName) {
setSidebarOpen(false);
}
}
navigate(`/session/${session.id}`);
};
@@ -694,6 +731,7 @@ function AppContent() {
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate}
/>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
const [fakeTokens, setFakeTokens] = useState(0);
// Update elapsed time every second
useEffect(() => {
if (!isLoading) {
@@ -13,7 +13,7 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
setFakeTokens(0);
return;
}
const startTime = Date.now();
const timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
@@ -21,21 +21,23 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
// Simulate token count increasing over time (roughly 30-50 tokens per second)
setFakeTokens(Math.floor(elapsed * (30 + Math.random() * 20)));
}, 1000);
return () => clearInterval(timer);
}, [isLoading]);
// Animate the status indicator
useEffect(() => {
if (!isLoading) return;
const timer = setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4);
}, 500);
return () => clearInterval(timer);
}, [isLoading]);
// Don't show if loading is false
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
if (!isLoading) return null;
// Clever action words that cycle

View File

@@ -0,0 +1,344 @@
import React, { useEffect, useRef } from 'react';
/**
* CommandMenu - Autocomplete dropdown for slash commands
*
* @param {Array} commands - Array of command objects to display
* @param {number} selectedIndex - Currently selected command index
* @param {Function} onSelect - Callback when a command is selected
* @param {Function} onClose - Callback when menu should close
* @param {Object} position - Position object { top, left } for absolute positioning
* @param {boolean} isOpen - Whether the menu is open
* @param {Array} frequentCommands - Array of frequently used command objects
*/
const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, position = { top: 0, left: 0 }, isOpen = false, frequentCommands = [] }) => {
const menuRef = useRef(null);
const selectedItemRef = useRef(null);
// Calculate responsive positioning
const getMenuPosition = () => {
const isMobile = window.innerWidth < 640;
const viewportHeight = window.innerHeight;
const menuHeight = 300; // Max height of menu
if (isMobile) {
// On mobile, calculate bottom position dynamically to appear above the input
// Use the bottom value which is calculated as: window.innerHeight - textarea.top + spacing
const inputBottom = position.bottom || 90; // Use provided bottom or default
return {
position: 'fixed',
bottom: `${inputBottom}px`, // Position above the input with spacing already included
left: '16px',
right: '16px',
width: 'auto',
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'min(50vh, 300px)' // Limit to smaller of 50vh or 300px
};
}
// On desktop, use provided position but ensure it stays on screen
return {
position: 'fixed',
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
left: `${position.left}px`,
width: 'min(400px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '300px'
};
};
const menuPosition = getMenuPosition();
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [isOpen, onClose]);
// Scroll selected item into view
useEffect(() => {
if (selectedItemRef.current && menuRef.current) {
const menuRect = menuRef.current.getBoundingClientRect();
const itemRect = selectedItemRef.current.getBoundingClientRect();
if (itemRect.bottom > menuRect.bottom) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else if (itemRect.top < menuRect.top) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}, [selectedIndex]);
if (!isOpen) {
return null;
}
// Show a message if no commands are available
if (commands.length === 0) {
return (
<div
ref={menuRef}
className="command-menu command-menu-empty"
style={{
...menuPosition,
maxHeight: '300px',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
padding: '20px',
opacity: 1,
transform: 'translateY(0)',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
textAlign: 'center'
}}
>
No commands available
</div>
);
}
// Add frequent commands as a special group if provided
const hasFrequentCommands = frequentCommands.length > 0;
// Group commands by namespace
const groupedCommands = commands.reduce((groups, command) => {
const namespace = command.namespace || command.type || 'other';
if (!groups[namespace]) {
groups[namespace] = [];
}
groups[namespace].push(command);
return groups;
}, {});
// Add frequent commands as a separate group
if (hasFrequentCommands) {
groupedCommands['frequent'] = frequentCommands;
}
// Order: frequent, builtin, project, user, other
const namespaceOrder = hasFrequentCommands
? ['frequent', 'builtin', 'project', 'user', 'other']
: ['builtin', 'project', 'user', 'other'];
const orderedNamespaces = namespaceOrder.filter(ns => groupedCommands[ns]);
const namespaceLabels = {
frequent: '⭐ Frequently Used',
builtin: 'Built-in Commands',
project: 'Project Commands',
user: 'User Commands',
other: 'Other Commands'
};
// Calculate global index for each command
let globalIndex = 0;
const commandsWithIndex = [];
orderedNamespaces.forEach(namespace => {
groupedCommands[namespace].forEach(command => {
commandsWithIndex.push({
...command,
globalIndex: globalIndex++,
namespace
});
});
});
return (
<div
ref={menuRef}
role="listbox"
aria-label="Available commands"
className="command-menu"
style={{
...menuPosition,
maxHeight: '300px',
overflowY: 'auto',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
padding: '8px',
opacity: isOpen ? 1 : 0,
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out'
}}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && (
<div
style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
color: '#6b7280',
padding: '8px 12px 4px',
letterSpacing: '0.05em'
}}
>
{namespaceLabels[namespace] || namespace}
</div>
)}
{groupedCommands[namespace].map((command) => {
const cmdWithIndex = commandsWithIndex.find(c => c.name === command.name && c.namespace === namespace);
const isSelected = cmdWithIndex && cmdWithIndex.globalIndex === selectedIndex;
return (
<div
key={`${namespace}-${command.name}`}
ref={isSelected ? selectedItemRef : null}
role="option"
aria-selected={isSelected}
className="command-item"
onMouseEnter={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, true)}
onClick={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, false)}
style={{
display: 'flex',
alignItems: 'flex-start',
padding: '10px 12px',
borderRadius: '6px',
cursor: 'pointer',
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
transition: 'background-color 100ms ease-in-out',
marginBottom: '2px'
}}
onMouseDown={(e) => e.preventDefault()} // Prevent textarea blur
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: command.description ? '4px' : 0
}}
>
{/* Command icon based on namespace */}
<span
style={{
fontSize: '16px',
flexShrink: 0
}}
>
{namespace === 'builtin' && '⚡'}
{namespace === 'project' && '📁'}
{namespace === 'user' && '👤'}
{namespace === 'other' && '📝'}
</span>
{/* Command name */}
<span
style={{
fontWeight: 600,
fontSize: '14px',
color: '#111827',
fontFamily: 'monospace'
}}
>
{command.name}
</span>
{/* Command metadata badge */}
{command.metadata?.type && (
<span
className="command-metadata-badge"
style={{
fontSize: '10px',
padding: '2px 6px',
borderRadius: '4px',
backgroundColor: '#f3f4f6',
color: '#6b7280',
fontWeight: 500
}}
>
{command.metadata.type}
</span>
)}
</div>
{/* Command description */}
{command.description && (
<div
style={{
fontSize: '13px',
color: '#6b7280',
marginLeft: '24px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{command.description}
</div>
)}
</div>
{/* Selection indicator */}
{isSelected && (
<span
style={{
marginLeft: '8px',
color: '#3b82f6',
fontSize: '12px',
fontWeight: 600
}}
>
</span>
)}
</div>
);
})}
</div>
))}
{/* Default light mode styles */}
<style>{`
.command-menu {
background-color: white;
border: 1px solid #e5e7eb;
}
.command-menu-empty {
color: #6b7280;
}
@media (prefers-color-scheme: dark) {
.command-menu {
background-color: #1f2937 !important;
border: 1px solid #374151 !important;
}
.command-menu-empty {
color: #9ca3af !important;
}
.command-item[aria-selected="true"] {
background-color: #1e40af !important;
}
.command-item span:not(.command-metadata-badge) {
color: #f3f4f6 !important;
}
.command-metadata-badge {
background-color: #f3f4f6 !important;
color: #6b7280 !important;
}
.command-item div {
color: #d1d5db !important;
}
.command-group > div:first-child {
color: #9ca3af !important;
}
}
`}</style>
</div>
);
};
export default CommandMenu;

View File

@@ -55,7 +55,8 @@ function MainContent({
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
sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
externalMessageUpdate // Trigger for external CLI updates to current session
}) {
const [editingFile, setEditingFile] = useState(null);
const [selectedTask, setSelectedTask] = useState(null);
@@ -433,6 +434,7 @@ function MainContent({
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate}
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
/>
</ErrorBoundary>

View File

@@ -301,15 +301,20 @@ function Sidebar({
};
const toggleProject = (projectName) => {
const newExpanded = new Set(expandedProjects);
if (newExpanded.has(projectName)) {
newExpanded.delete(projectName);
} else {
const newExpanded = new Set();
// If clicking the already-expanded project, collapse it (newExpanded stays empty)
// If clicking a different project, expand only that one
if (!expandedProjects.has(projectName)) {
newExpanded.add(projectName);
}
setExpandedProjects(newExpanded);
};
// Wrapper to attach project context when session is clicked
const handleSessionClick = (session, projectName) => {
onSessionSelect({ ...session, __projectName: projectName });
};
// Starred projects utility functions
const toggleStarProject = (projectName) => {
const newStarred = new Set(starredProjects);
@@ -1029,7 +1034,7 @@ function Sidebar({
{project.displayName}
</h3>
{tasksEnabled && (
<TaskIndicator
<TaskIndicator
status={(() => {
const projectConfigured = project.taskmaster?.hasTaskmaster;
const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
@@ -1037,9 +1042,9 @@ function Sidebar({
if (projectConfigured) return 'taskmaster-only';
if (mcpConfigured) return 'mcp-only';
return 'not-configured';
})()}
})()}
size="xs"
className="flex-shrink-0 ml-2"
className="hidden md:inline-flex flex-shrink-0 ml-2"
/>
)}
</div>
@@ -1337,11 +1342,11 @@ function Sidebar({
)}
onClick={() => {
handleProjectSelect(project);
onSessionSelect(session);
handleSessionClick(session, project.name);
}}
onTouchEnd={handleTouchClick(() => {
handleProjectSelect(project);
onSessionSelect(session);
handleSessionClick(session, project.name);
})}
>
<div className="flex items-center gap-2">
@@ -1404,8 +1409,8 @@ function Sidebar({
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200",
selectedSession?.id === session.id && "bg-accent text-accent-foreground"
)}
onClick={() => onSessionSelect(session)}
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
onClick={() => handleSessionClick(session, project.name)}
onTouchEnd={handleTouchClick(() => handleSessionClick(session, project.name))}
>
<div className="flex items-start gap-2 min-w-0 w-full">
{isCursorSession ? (

View File

@@ -43,8 +43,8 @@ function TokenUsagePie({ used, total }) {
strokeLinecap="round"
/>
</svg>
<span className="hidden sm:inline" title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
{percentage.toFixed(0)}%
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
{percentage.toFixed(1)}%
</span>
</div>
);

View File

@@ -443,18 +443,18 @@
color: rgb(156 163 175) !important;
opacity: 1 !important;
}
.dark .chat-input-placeholder::placeholder {
color: rgb(75 85 99) !important;
opacity: 1 !important;
-webkit-text-fill-color: rgb(75 85 99) !important;
}
.chat-input-placeholder::-webkit-input-placeholder {
color: rgb(156 163 175) !important;
opacity: 1 !important;
}
.dark .chat-input-placeholder::-webkit-input-placeholder {
color: rgb(75 85 99) !important;
opacity: 1 !important;
@@ -722,9 +722,15 @@
/* Improved textarea styling */
.chat-input-placeholder {
display: block !important;
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
}
/* Ensure container fits textarea tightly */
.chat-input-placeholder:not(:focus) {
height: auto;
}
.chat-input-placeholder::-webkit-scrollbar {
width: 6px;

View File

@@ -3,6 +3,17 @@ import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
// Clean up stale service workers on app load to prevent caching issues after builds
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => {
registration.unregister();
});
}).catch(err => {
console.warn('Failed to unregister service workers:', err);
});
}
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />

View File

@@ -106,4 +106,4 @@ export function useWebSocket() {
messages,
isConnected
};
}
}