63 Commits

Author SHA1 Message Date
viper151
2603b8aaf1 Merge pull request #146 from siteboon/cursor-cli 2025-08-12 15:07:33 +03:00
viper151
e15a78ed62 Merge branch 'main' into cursor-cli 2025-08-12 15:06:37 +03:00
simos
db7ce4dd74 feat: Update README to include Cursor CLI support and enhance chat message handling with streaming improvements 2025-08-12 15:05:36 +03:00
simos
50f6cdfac9 feat: Enhance chat message handling by appending assistant messages and triggering project refresh on session updates 2025-08-12 14:48:47 +03:00
simos
cdce59edb4 feat: Update message count retrieval to count only JSON blobs in sessions 2025-08-12 14:45:07 +03:00
simos
0f45472402 feat: Enhance session retrieval by implementing DAG structure for blob processing and improving JSON message extraction 2025-08-12 14:41:22 +03:00
simos
28e27ed2fb refactor: Improve session message handling and enhance loading logic in ChatInterface 2025-08-12 14:37:02 +03:00
simos
3e7e60a3a8 feat: Enhance session handling by adding cursor support and improving cursor messages order 2025-08-12 13:43:36 +03:00
simos
cd6e5befb8 feat: Add provider logos for session indication in MainContent 2025-08-12 13:25:32 +03:00
simos
003e8f4be3 refactor: Simplify input area layout and remove unused provider selection components in ChatInterface 2025-08-12 13:11:24 +03:00
simos
0a39079c5c feat: Implement Cursor session fetching and enhance message parsing in ChatInterface 2025-08-12 13:09:03 +03:00
simos
4e5aa50505 feat: Add pagination support for session messages and enhance loading logic in ChatInterface 2025-08-12 12:10:23 +03:00
simos
cf6f0e7321 feat: Enhance session management and tool settings for Claude and Cursor
- Updated ClaudeStatus component to accept a provider prop for better flexibility.
- Added CursorLogo component for displaying cursor sessions.
- Modified MainContent to conditionally display session names based on provider.
- Updated Shell component to show session names and summaries based on provider.
- Enhanced Sidebar to handle both Claude and Cursor sessions, including sorting and displaying session icons.
- Introduced new ToolsSettings functionality to manage tools for both Claude and Cursor, including allowed and disallowed commands.
- Implemented fetching and saving of Cursor-specific settings and commands.
- Added UI elements for managing Cursor tools, including permission settings and command lists.
2025-08-12 10:49:04 +03:00
viper151
5dd1fcfb4d Update package.json 2025-08-11 23:56:51 +03:00
viper151
ece52adac2 Merge pull request #144 from siteboon/feature/mcp-project
refactor: remove unnecessary project fetching in ToolsSettings compon…
2025-08-11 19:27:13 +03:00
viper151
6c55638397 Merge branch 'main' into feature/mcp-project 2025-08-11 19:27:07 +03:00
simos
e28d989bee refactor: remove unnecessary project fetching in ToolsSettings component that introduced a bug in saving the settings 2025-08-11 19:26:33 +03:00
viper151
b24f5e421f Merge pull request #142 from siteboon/feature/mcp-project
feat: Local/Project MCPs and Import from JSON
2025-08-11 14:22:21 +03:00
simos
6d17e6db81 feat: update version to 1.6.0 and enhance ToolsSettings component with loading from json and adding project MCP servers 2025-08-11 14:20:54 +03:00
simos
99b204f5bf feat: add JSON import support for MCP server configuration in ToolsSettings 2025-08-11 14:05:31 +03:00
simos
21d9242d50 feat: enhance MCP server management with config file support and improved CLI interactions 2025-08-11 13:51:11 +03:00
viper151
5e4be4d113 Merge pull request #141 from dorage/style/settings-bg-in-mobile
style: remove revert rule causing unexpected transparency in modal
2025-08-11 12:15:53 +03:00
kanghyunlee
46d119685d style: remove revert rule causing unexpected transparency in modal overlays 2025-08-10 15:53:11 +09:00
viper151
12dccbf224 Merge pull request #112 from Difocd/main
Remove executable permissions from non-script files
2025-08-06 19:57:35 +02:00
viper151
2a0e4c58c2 Merge branch 'main' into main 2025-08-06 19:57:24 +02:00
viper151
a01d6c91e0 Merge pull request #117 from GiGiDKR/feature/windows-support
feat(platform): Improve cross-platform compatibility with Windows support
2025-08-06 18:30:45 +02:00
viper151
8774aa4289 Update index.js 2025-08-06 19:30:05 +03:00
viper151
5ba62a2b7b Merge branch 'main' into feature/windows-support 2025-08-06 18:20:36 +02:00
viper151
23c50f8fef Merge pull request #119 from ismaslov/fix/websocket-proxy-config
Update vite.config.js for websocket proxy configuration
2025-08-06 18:20:26 +02:00
viper151
8b40f9f177 Merge branch 'main' into fix/websocket-proxy-config 2025-08-06 18:17:54 +02:00
GiGiDKR
51f935f6d8 Merge branch 'main' into feature/windows-support 2025-08-02 02:31:54 +02:00
viper151
f6408c5106 Merge pull request #97 from mkdir3/fix/sidebar-folder-name-display
fix(sidebar): display project name instead of full path
2025-08-01 22:31:16 +02:00
viper151
41be9a4f63 Merge branch 'main' into fix/sidebar-folder-name-display 2025-08-01 22:31:03 +02:00
viper151
0a9c2484d6 Merge pull request #133 from WolCarlos/main
Fix command escaping in claude-cli.js
2025-08-01 22:29:15 +02:00
WolCarlos
2d912eeb64 Update claude-cli.js
add "``"
2025-07-31 22:19:55 +02:00
WolCarlos
952aeab70a Fix bug - ‘truncated messages’ - Update claude-cli.js
I fixed the ‘truncated messages’ bug in chat (Windows 11/Chrome).
2025-07-31 16:35:09 +02:00
Igor Maslov
95644fd457 Update vite.config.js for websocket proxy configuration 2025-07-23 21:11:58 +00:00
OhMyApps
42a80748af fix(server): change default PORT from 3000 to 3001 2025-07-23 19:17:05 +02:00
aëldrin_sagë
7031d9437c Merge branch 'main' into fix/sidebar-folder-name-display 2025-07-23 18:59:43 +02:00
OhMyApps
2ff59bd28c feat(platform): improve cross-platform compatibility with Windows support
Enhance application to work seamlessly across operating systems by implementing platform-specific adaptations:

- Update node-pty to version 1.1.0-beta34 for Windows support
- Implement PowerShell terminal support for Windows environments
- Integrate cross-spawn library for reliable process execution on all platforms
- Standardize development ports to industry defaults (3001/5173)
- Add dynamic shell detection based on operating system

BREAKING CHANGE: Development server ports have been changed from 3008/3009 to 3001/5173 to align with standard Vite/Express defaults
2025-07-23 18:36:29 +02:00
Aëldrin Sagë
7fd63d83ac fix(sidebar): display only folder name instead of full path in project list 2025-07-23 17:52:48 +02:00
simos
6db8be5f54 Remove deprecated dependency "@anthropic-ai/claude-code" from package.json 2025-07-23 13:39:19 +00:00
simos
32481356cb Redirect to Vite dev server in development mode for better local testing 2025-07-23 13:37:28 +00:00
viper151
3e0c23b7e2 Merge pull request #55 from krzemienski/fix/react-errors-and-localStorage-quota
fix: resolve React errors and localStorage quota issues
2025-07-23 14:33:59 +02:00
viper151
22fa724503 Delete CLAUDE.md 2025-07-23 14:29:34 +02:00
viper151
67339b0e4b Merge branch 'main' into fix/react-errors-and-localStorage-quota 2025-07-23 14:22:33 +02:00
viper151
4fcf27bd13 Merge pull request #51 from Mirza-Samad-Ahmed-Baig/fix/registration-race-condition
Fix: Prevent race condition in user registration
2025-07-23 14:21:35 +02:00
viper151
4de2f5026e Merge branch 'main' into fix/registration-race-condition 2025-07-23 14:20:07 +02:00
Difocd
9cfccc04f3 Remove executable permissions from non-script files 2025-07-23 11:42:12 +08:00
Natsuki YOKOTA
7f4feb182e feat: add ctrl+enter send option & fix IME problen (#62) 2025-07-21 17:30:53 +02:00
simos
d36890be52 Fixes on Claude limit usage reached message 2025-07-15 14:04:15 +00:00
viper151
c925742df1 Merge branch 'main' into fix/react-errors-and-localStorage-quota 2025-07-14 17:51:56 +02:00
viper151
33aea3f7e8 feat: Publish branch functionality (#66) 2025-07-14 17:46:11 +02:00
viper151
f28dc0140e Add delete functionality for untracked files (#65)
- Add delete button for untracked files in GitPanel
- Implement deleteUntrackedFile function with confirmation dialog
- Add /delete-untracked API endpoint to safely delete untracked files
- Update confirmation modal to handle delete actions
- Maintain consistent UI patterns with existing discard functionality
2025-07-14 17:27:02 +02:00
Nick Krzemienski
2ca929e5e5 Merge branch 'main' into fix/react-errors-and-localStorage-quota 2025-07-13 19:53:23 -04:00
simos
36d0add224 Changing logo to the proper one 2025-07-13 20:51:05 +00:00
simos
7db22fae29 Enhance ChatInterface 2025-07-13 20:43:15 +00:00
simos
b808ca1b68 Update ChatInterface 2025-07-13 20:34:04 +00:00
viper151
62ad40ad71 Merge branch 'main' into fix/registration-race-condition 2025-07-13 19:18:48 +02:00
viper151
a8f212bff8 Merge branch 'main' into fix/react-errors-and-localStorage-quota 2025-07-13 17:31:45 +02:00
Nick Krzemienski
9cf0173bc9 fix: resolve React errors and localStorage quota issues
- Fix DOMException in ChatInterface handleImageFiles function with proper file validation
- Add ErrorBoundary component to catch React errors gracefully
- Implement safeLocalStorage utility to handle QuotaExceededError
- Add intelligent quota management with automatic cleanup
- Limit chat history to 50 messages per project to prevent storage bloat
- Add comprehensive error handling for all localStorage operations
- Create comprehensive CLAUDE.md documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 11:29:20 -04:00
viper151
c0d8241f3d Merge branch 'main' into fix/registration-race-condition 2025-07-13 11:37:21 +02:00
Mirza-Samad-Ahmed-Baig
23e5f7ac2d Fix: Prevent race condition in user registration 2025-07-13 14:30:09 +05:00
76 changed files with 7161 additions and 2016 deletions

View File

@@ -7,6 +7,6 @@
# Backend server port (Express API + WebSocket server)
#API server
PORT=3008
PORT=3001
#Frontend port
VITE_PORT=3009
VITE_PORT=5173

25
.gitignore vendored
View File

@@ -98,10 +98,31 @@ temp/
# Local Netlify folder
.netlify
# Claude specific
# AI specific
.claude/
.cursor/
.roo/
.taskmaster/
.cline/
.windsurf/
# Database files
*.db
*.sqlite
*.sqlite3
*.sqlite3
logs
dev-debug.log
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS specific
# Task files
tasks.json
tasks/

23
README.md Executable file → Normal file
View File

@@ -4,7 +4,7 @@
</div>
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropic's official CLI for AI-assisted coding. You can use it locally or remotely to view your active projects and sessions in claude code and make changes to them the same way you would do it in claude code CLI. This gives you a proper interface that works everywhere.
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), and [Cursor CLI](https://docs.cursor.com/en/cli/overview). You can use it locally or remotely to view your active projects and sessions in Claude Code or Cursor and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere. Supports models including **Claude Sonnet 4**, **Opus 4.1**, and **GPT-5**
## Screenshots
@@ -25,6 +25,14 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
<em>Responsive mobile design with touch navigation</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI Selection</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>Select between Claude Code and Cursor CLI</em>
</td>
</tr>
</table>
@@ -34,11 +42,12 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
## Features
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code
- **Integrated Shell Terminal** - Direct access to Claude Code CLI through built-in shell functionality
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code or Cursor
- **Integrated Shell Terminal** - Direct access to Claude Code or Cursor CLI through built-in shell functionality
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Model Compatibility** - Works with Claude Sonnet 4, Opus 4.1, and GPT-5
## Quick Start
@@ -46,7 +55,8 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
### Prerequisites
- [Node.js](https://nodejs.org/) v20 or higher
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured
### Installation
@@ -108,9 +118,10 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
- **Visual Project Browser** - All available projects with metadata and session counts
- **Project Actions** - Rename, delete, and organize projects
- **Smart Navigation** - Quick access to recent projects and sessions
- **MCP support** - Add your own MCP servers through the UI
#### Chat Interface
- **Use responsive chat or Claude Code CLI** - You can either use the adapted chat interface or use the shell button to connect to Claude Code CLI.
- **Use responsive chat or Claude Code/Cursor CLI** - You can either use the adapted chat interface or use the shell button to connect to your selected CLI.
- **Real-time Communication** - Stream responses from Claude with WebSocket connection
- **Session Management** - Resume previous conversations or start fresh sessions
- **Message History** - Complete conversation history with timestamps and metadata
@@ -152,7 +163,7 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
### Backend (Node.js + Express)
- **Express Server** - RESTful API with static file serving
- **WebSocket Server** - Communication for chats and project refresh
- **Claude CLI Integration** - Process spawning and management
- **CLI Integration (Claude Code / Cursor)** - Process spawning and management
- **Session Management** - JSONL parsing and conversation persistence
- **File System API** - Exposing file browser for projects

0
index.html Executable file → Normal file
View File

2925
package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

8
package.json Executable file → Normal file
View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-ui",
"version": "1.5.0",
"version": "1.6.1",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -22,7 +22,6 @@
"author": "Claude Code UI Contributors",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.24",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -40,18 +39,21 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-fetch": "^2.7.0",
"node-pty": "^1.0.0",
"node-pty": "^1.1.0-beta34",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"ws": "^8.14.2",
"xterm": "^5.3.0",

0
public/convert-icons.md Executable file → Normal file
View File

0
public/favicon.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 281 B

0
public/favicon.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

0
public/icons/claude-ai-icon.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

1
public/icons/cursor.svg Normal file
View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z" fill="url(#lobe-icons-cursorundefined-fill-0)"></path><path d="M22.35 18V6L11.925 0v12l10.425 6z" fill="url(#lobe-icons-cursorundefined-fill-1)"></path><path d="M11.925 0L1.5 6v12l10.425-6V0z" fill="url(#lobe-icons-cursorundefined-fill-2)"></path><path d="M22.35 6L11.925 24V12L22.35 6z" fill="#555"></path><path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="#000"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-0" x1="11.925" x2="11.925" y1="12" y2="24"><stop offset=".16" stop-color="#000" stop-opacity=".39"></stop><stop offset=".658" stop-color="#000" stop-opacity=".8"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-1" x1="22.35" x2="11.925" y1="6.037" y2="12.15"><stop offset=".182" stop-color="#000" stop-opacity=".31"></stop><stop offset=".715" stop-color="#000" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-cursorundefined-fill-2" x1="11.925" x2="1.5" y1="0" y2="18"><stop stop-color="#000" stop-opacity=".6"></stop><stop offset=".667" stop-color="#000" stop-opacity=".22"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

0
public/icons/generate-icons.md Executable file → Normal file
View File

0
public/icons/icon-128x128.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-128x128.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-144x144.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-144x144.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-152x152.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-152x152.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-192x192.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-192x192.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-384x384.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-384x384.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-512x512.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-512x512.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-72x72.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-72x72.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-96x96.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-96x96.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-template.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/logo.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 422 B

0
public/manifest.json Executable file → Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

0
public/screenshots/desktop-main.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 385 KiB

0
public/screenshots/mobile-chat.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

0
public/screenshots/tools-modal.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 295 KiB

View File

@@ -1,8 +1,12 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeClaudeProcesses = new Map(); // Track active processes by session ID
async function spawnClaude(command, options = {}, ws) {
@@ -23,7 +27,11 @@ async function spawnClaude(command, options = {}, ws) {
// Add print flag with command if we have a command
if (command && command.trim()) {
args.push('--print', command);
// Separate arguments for better cross-platform compatibility
// This prevents issues with spaces and quotes on Windows
args.push('--print');
args.push(command);
}
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
@@ -63,9 +71,9 @@ async function spawnClaude(command, options = {}, ws) {
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
const modifiedCommand = command + imageNote;
// Update the command in args
// Update the command in args - now that --print and command are separate
const printIndex = args.indexOf('--print');
if (printIndex !== -1 && args[printIndex + 1] === command) {
if (printIndex !== -1 && printIndex + 1 < args.length && args[printIndex + 1] === command) {
args[printIndex + 1] = modifiedCommand;
}
}
@@ -227,7 +235,7 @@ async function spawnClaude(command, options = {}, ws) {
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
const claudeProcess = spawn('claude', args, {
const claudeProcess = spawnFunction('claude', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
@@ -380,4 +388,4 @@ function abortClaudeSession(sessionId) {
export {
spawnClaude,
abortClaudeSession
};
};

250
server/cursor-cli.js Normal file
View File

@@ -0,0 +1,250 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeCursorProcesses = new Map(); // Track active processes by session ID
async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let messageBuffer = ''; // Buffer for accumulating assistant messages
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedShellCommands: [],
skipPermissions: false
};
// Build Cursor CLI command
const args = [];
// Build flags allowing both resume and prompt together (reply in existing session)
// Treat presence of sessionId as intention to resume, regardless of resume flag
if (sessionId) {
args.push('--resume=' + sessionId);
}
if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions)
args.push('-p', command);
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
if (!sessionId && model) {
args.push('--model', model);
}
// Request streaming JSON when we are providing a prompt
args.push('--output-format', 'stream-json');
}
// Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) {
args.push('-f');
console.log('⚠️ Using -f flag (skip permissions)');
}
// Use cwd (actual project directory) instead of projectPath
const workingDir = cwd || projectPath || process.cwd();
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
// Store process reference for potential abort
const processKey = capturedSessionId || Date.now().toString();
activeCursorProcesses.set(processKey, cursorProcess);
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('📤 Cursor CLI stdout:', rawOutput);
const lines = rawOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
console.log('📄 Parsed JSON response:', response);
// Handle different message types
switch (response.type) {
case 'system':
if (response.subtype === 'init') {
// Capture session ID
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
console.log('📝 Captured session ID:', capturedSessionId);
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeCursorProcesses.delete(processKey);
activeCursorProcesses.set(capturedSessionId, cursorProcess);
}
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(JSON.stringify({
type: 'session-created',
sessionId: capturedSessionId,
model: response.model,
cwd: response.cwd
}));
}
}
// Send system info to frontend
ws.send(JSON.stringify({
type: 'cursor-system',
data: response
}));
}
break;
case 'user':
// Forward user message
ws.send(JSON.stringify({
type: 'cursor-user',
data: response
}));
break;
case 'assistant':
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const textContent = response.message.content[0].text;
messageBuffer += textContent;
// Send as Claude-compatible format for frontend
ws.send(JSON.stringify({
type: 'claude-response',
data: {
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: textContent
}
}
}));
}
break;
case 'result':
// Session complete
console.log('Cursor session result:', response);
// Send final message if we have buffered content
if (messageBuffer) {
ws.send(JSON.stringify({
type: 'claude-response',
data: {
type: 'content_block_stop'
}
}));
}
// Send completion event
ws.send(JSON.stringify({
type: 'cursor-result',
data: response,
success: response.subtype === 'success'
}));
break;
default:
// Forward any other message types
ws.send(JSON.stringify({
type: 'cursor-response',
data: response
}));
}
} catch (parseError) {
console.log('📄 Non-JSON response:', line);
// If not JSON, send as raw text
ws.send(JSON.stringify({
type: 'cursor-output',
data: line
}));
}
}
});
// Handle stderr
cursorProcess.stderr.on('data', (data) => {
console.error('Cursor CLI stderr:', data.toString());
ws.send(JSON.stringify({
type: 'cursor-error',
error: data.toString()
}));
});
// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(JSON.stringify({
type: 'claude-complete',
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
}));
if (code === 0) {
resolve();
} else {
reject(new Error(`Cursor CLI exited with code ${code}`));
}
});
// Handle process errors
cursorProcess.on('error', (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(JSON.stringify({
type: 'cursor-error',
error: error.message
}));
reject(error);
});
// Close stdin since Cursor doesn't need interactive input
cursorProcess.stdin.end();
});
}
function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
console.log(`🛑 Aborting Cursor session: ${sessionId}`);
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;
}
return false;
}
export {
spawnCursor,
abortCursorSession
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,10 @@ import { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path';
import readline from 'readline';
import crypto from 'crypto';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import os from 'os';
// Cache for extracted project directories
const projectDirectoryCache = new Map();
@@ -53,13 +57,8 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
// If it starts with /, it's an absolute path
if (projectPath.startsWith('/')) {
const parts = projectPath.split('/').filter(Boolean);
if (parts.length > 3) {
// Show last 2 folders with ellipsis: "...projects/myapp"
return `.../${parts.slice(-2).join('/')}`;
} else {
// Show full path if short: "/home/user"
return projectPath;
}
// Return only the last folder name
return parts[parts.length - 1] || projectPath;
}
return projectPath;
@@ -212,6 +211,14 @@ async function getProjects() {
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
}
// Also fetch Cursor sessions for this project
try {
project.cursorSessions = await getCursorSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
project.cursorSessions = [];
}
projects.push(project);
}
}
@@ -241,9 +248,17 @@ async function getProjects() {
fullPath: actualProjectDir,
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: []
sessions: [],
cursorSessions: []
};
// Try to fetch Cursor sessions for manual projects too
try {
project.cursorSessions = await getCursorSessions(actualProjectDir);
} catch (e) {
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
}
projects.push(project);
}
}
@@ -390,8 +405,8 @@ async function parseJsonlSessions(filePath) {
);
}
// Get messages for a specific session
async function getSessionMessages(projectName, sessionId) {
// Get messages for a specific session with pagination support
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
@@ -399,7 +414,7 @@ async function getSessionMessages(projectName, sessionId) {
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
return [];
return { messages: [], total: 0, hasMore: false };
}
const messages = [];
@@ -428,12 +443,34 @@ async function getSessionMessages(projectName, sessionId) {
}
// Sort messages by timestamp
return messages.sort((a, b) =>
const sortedMessages = messages.sort((a, b) =>
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
);
const total = sortedMessages.length;
// If no limit is specified, return all messages (backward compatibility)
if (limit === null) {
return sortedMessages;
}
// Apply pagination - for recent messages, we need to slice from the end
// offset 0 should give us the most recent messages
const startIndex = Math.max(0, total - offset - limit);
const endIndex = total - offset;
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
const hasMore = startIndex > 0;
return {
messages: paginatedMessages,
total,
hasMore,
offset,
limit
};
} catch (error) {
console.error(`Error reading messages for session ${sessionId}:`, error);
return [];
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
}
}
@@ -598,6 +635,117 @@ async function addProjectManually(projectPath, displayName = null) {
};
}
// Fetch Cursor sessions for a given project path
async function getCursorSessions(projectPath) {
try {
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
// Check if the directory exists
try {
await fs.access(cursorChatsPath);
} catch (error) {
// No sessions for this project
return [];
}
// List all session directories
const sessionDirs = await fs.readdir(cursorChatsPath);
const sessions = [];
for (const sessionId of sessionDirs) {
const sessionPath = path.join(cursorChatsPath, sessionId);
const storeDbPath = path.join(sessionPath, 'store.db');
try {
// Check if store.db exists
await fs.access(storeDbPath);
// Capture store.db mtime as a reliable fallback timestamp
let dbStatMtimeMs = null;
try {
const stat = await fs.stat(storeDbPath);
dbStatMtimeMs = stat.mtimeMs;
} catch (_) {}
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
// Parse metadata
let metadata = {};
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
metadata[row.key] = JSON.parse(jsonStr);
} else {
metadata[row.key] = row.value.toString();
}
} catch (e) {
metadata[row.key] = row.value.toString();
}
}
}
// Get message count
const messageCountResult = await db.get(`
SELECT COUNT(*) as count FROM blobs
`);
await db.close();
// Extract session info
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
// Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
let createdAt = null;
if (metadata.createdAt) {
createdAt = new Date(metadata.createdAt).toISOString();
} else if (dbStatMtimeMs) {
createdAt = new Date(dbStatMtimeMs).toISOString();
} else {
createdAt = new Date().toISOString();
}
sessions.push({
id: sessionId,
name: sessionName,
createdAt: createdAt,
lastActivity: createdAt, // For compatibility with Claude sessions
messageCount: messageCountResult.count || 0,
projectPath: projectPath
});
} catch (error) {
console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
}
}
// Sort sessions by creation time (newest first)
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
// Return only the first 5 sessions for performance
return sessions.slice(0, 5);
} catch (error) {
console.error('Error fetching Cursor sessions:', error);
return [];
}
}
export {
getProjects,

View File

@@ -1,6 +1,6 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { userDb } from '../database/db.js';
import { userDb, db } from '../database/db.js';
import { generateToken, authenticateToken } from '../middleware/auth.js';
const router = express.Router();
@@ -33,31 +33,41 @@ router.post('/register', async (req, res) => {
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
}
// Check if users already exist (only allow one user)
const hasUsers = userDb.hasUsers();
if (hasUsers) {
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
// Use a transaction to prevent race conditions
db.prepare('BEGIN').run();
try {
// Check if users already exist (only allow one user)
const hasUsers = userDb.hasUsers();
if (hasUsers) {
db.prepare('ROLLBACK').run();
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
userDb.updateLastLogin(user.id);
db.prepare('COMMIT').run();
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
db.prepare('ROLLBACK').run();
throw error;
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Registration error:', error);
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {

794
server/routes/cursor.js Normal file
View File

@@ -0,0 +1,794 @@
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { spawn } from 'child_process';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import crypto from 'crypto';
const router = express.Router();
// GET /api/cursor/config - Read Cursor CLI configuration
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);
res.json({
success: true,
config: config,
path: configPath
});
} catch (error) {
// Config doesn't exist or is invalid
console.log('Cursor config not found or invalid:', error.message);
// Return default config
res.json({
success: true,
config: {
version: 1,
model: {
modelId: "gpt-5",
displayName: "GPT-5"
},
permissions: {
allow: [],
deny: []
}
},
isDefault: true
});
}
} catch (error) {
console.error('Error reading Cursor config:', error);
res.status(500).json({
error: 'Failed to read Cursor configuration',
details: error.message
});
}
});
// POST /api/cursor/config - Update Cursor CLI configuration
router.post('/config', async (req, res) => {
try {
const { permissions, model } = req.body;
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
// Read existing config or create default
let config = {
version: 1,
editor: {
vimMode: false
},
hasChangedDefaultModel: false,
privacyCache: {
ghostMode: false,
privacyMode: 3,
updatedAt: Date.now()
}
};
try {
const existing = await fs.readFile(configPath, 'utf8');
config = JSON.parse(existing);
} catch (error) {
// Config doesn't exist, use defaults
console.log('Creating new Cursor config');
}
// Update permissions if provided
if (permissions) {
config.permissions = {
allow: permissions.allow || [],
deny: permissions.deny || []
};
}
// Update model if provided
if (model) {
config.model = model;
config.hasChangedDefaultModel = true;
}
// Ensure directory exists
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
// Write updated config
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
res.json({
success: true,
config: config,
message: 'Cursor configuration updated successfully'
});
} catch (error) {
console.error('Error updating Cursor config:', error);
res.status(500).json({
error: 'Failed to update Cursor configuration',
details: error.message
});
}
});
// GET /api/cursor/mcp - Read Cursor MCP servers configuration
router.get('/mcp', async (req, res) => {
try {
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
try {
const mcpContent = await fs.readFile(mcpPath, 'utf8');
const mcpConfig = JSON.parse(mcpContent);
// Convert to UI-friendly format
const servers = [];
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
const server = {
id: name,
name: name,
type: 'stdio',
scope: 'cursor',
config: {},
raw: config
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
res.json({
success: true,
servers: servers,
path: mcpPath
});
} catch (error) {
// MCP config doesn't exist
console.log('Cursor MCP config not found:', error.message);
res.json({
success: true,
servers: [],
isDefault: true
});
}
} catch (error) {
console.error('Error reading Cursor MCP config:', error);
res.status(500).json({
error: 'Failed to read Cursor MCP configuration',
details: error.message
});
}
});
// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration
router.post('/mcp/add', async (req, res) => {
try {
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(` Adding MCP server to Cursor config: ${name}`);
// Read existing config or create new
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
console.log('Creating new Cursor MCP config');
}
// Build server config based on type
let serverConfig = {};
if (type === 'stdio') {
serverConfig = {
command: command,
args: args,
env: env
};
} else if (type === 'http' || type === 'sse') {
serverConfig = {
url: url,
transport: type,
headers: headers
};
}
// Add server to config
mcpConfig.mcpServers[name] = serverConfig;
// Ensure directory exists
const mcpDir = path.dirname(mcpPath);
await fs.mkdir(mcpDir, { recursive: true });
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" added to Cursor configuration`,
config: mcpConfig
});
} catch (error) {
console.error('Error adding MCP server to Cursor:', error);
res.status(500).json({
error: 'Failed to add MCP server',
details: error.message
});
}
});
// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration
router.delete('/mcp/:name', async (req, res) => {
try {
const { name } = req.params;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);
// Read existing config
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
} catch (error) {
return res.status(404).json({
error: 'Cursor MCP configuration not found'
});
}
// Check if server exists
if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {
return res.status(404).json({
error: `MCP server "${name}" not found in Cursor configuration`
});
}
// Remove server from config
delete mcpConfig.mcpServers[name];
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" removed from Cursor configuration`,
config: mcpConfig
});
} catch (error) {
console.error('Error removing MCP server from Cursor:', error);
res.status(500).json({
error: 'Failed to remove MCP server',
details: error.message
});
}
});
// POST /api/cursor/mcp/add-json - Add MCP server using JSON format
router.post('/mcp/add-json', async (req, res) => {
try {
const { name, jsonConfig } = req.body;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(` Adding MCP server to Cursor config via JSON: ${name}`);
// Validate and parse JSON config
let parsedConfig;
try {
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
} catch (parseError) {
return res.status(400).json({
error: 'Invalid JSON configuration',
details: parseError.message
});
}
// Read existing config or create new
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
console.log('Creating new Cursor MCP config');
}
// Add server to config
mcpConfig.mcpServers[name] = parsedConfig;
// Ensure directory exists
const mcpDir = path.dirname(mcpPath);
await fs.mkdir(mcpDir, { recursive: true });
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" added to Cursor configuration via JSON`,
config: mcpConfig
});
} catch (error) {
console.error('Error adding MCP server to Cursor via JSON:', error);
res.status(500).json({
error: 'Failed to add MCP server',
details: error.message
});
}
});
// GET /api/cursor/sessions - Get Cursor sessions from SQLite database
router.get('/sessions', async (req, res) => {
try {
const { projectPath } = req.query;
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
// Check if the directory exists
try {
await fs.access(cursorChatsPath);
} catch (error) {
// No sessions for this project
return res.json({
success: true,
sessions: [],
cwdId: cwdId,
path: cursorChatsPath
});
}
// List all session directories
const sessionDirs = await fs.readdir(cursorChatsPath);
const sessions = [];
for (const sessionId of sessionDirs) {
const sessionPath = path.join(cursorChatsPath, sessionId);
const storeDbPath = path.join(sessionPath, 'store.db');
let dbStatMtimeMs = null;
try {
// Check if store.db exists
await fs.access(storeDbPath);
// Capture store.db mtime as a reliable fallback timestamp (last activity)
try {
const stat = await fs.stat(storeDbPath);
dbStatMtimeMs = stat.mtimeMs;
} catch (_) {}
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
let sessionData = {
id: sessionId,
name: 'Untitled Session',
createdAt: null,
mode: null,
projectPath: projectPath,
lastMessage: null,
messageCount: 0
};
// Parse meta table entries
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
const data = JSON.parse(jsonStr);
if (row.key === 'agent') {
sessionData.name = data.name || sessionData.name;
// Normalize createdAt to ISO string in milliseconds
let createdAt = data.createdAt;
if (typeof createdAt === 'number') {
if (createdAt < 1e12) {
createdAt = createdAt * 1000; // seconds -> ms
}
sessionData.createdAt = new Date(createdAt).toISOString();
} else if (typeof createdAt === 'string') {
const n = Number(createdAt);
if (!Number.isNaN(n)) {
const ms = n < 1e12 ? n * 1000 : n;
sessionData.createdAt = new Date(ms).toISOString();
} else {
// Assume it's already an ISO/date string
const d = new Date(createdAt);
sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString();
}
} else {
sessionData.createdAt = sessionData.createdAt || null;
}
sessionData.mode = data.mode;
sessionData.agentId = data.agentId;
sessionData.latestRootBlobId = data.latestRootBlobId;
}
} else {
// If not hex, use raw value for simple keys
if (row.key === 'name') {
sessionData.name = row.value.toString();
}
}
} catch (e) {
console.log(`Could not parse meta value for key ${row.key}:`, e.message);
}
}
}
// Get message count from JSON blobs only (actual messages, not DAG structure)
try {
const blobCount = await db.get(`
SELECT COUNT(*) as count
FROM blobs
WHERE substr(data, 1, 1) = X'7B'
`);
sessionData.messageCount = blobCount.count;
// Get the most recent JSON blob for preview (actual message, not DAG structure)
const lastBlob = await db.get(`
SELECT data FROM blobs
WHERE substr(data, 1, 1) = X'7B'
ORDER BY rowid DESC
LIMIT 1
`);
if (lastBlob && lastBlob.data) {
try {
// Try to extract readable preview from blob (may contain binary with embedded JSON)
const raw = lastBlob.data.toString('utf8');
let preview = '';
// Attempt direct JSON parse
try {
const parsed = JSON.parse(raw);
if (parsed?.content) {
if (Array.isArray(parsed.content)) {
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
preview = firstText;
} else if (typeof parsed.content === 'string') {
preview = parsed.content;
}
}
} catch (_) {}
if (!preview) {
// Strip non-printable and try to find JSON chunk
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
const s = cleaned;
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start !== -1 && end > start) {
const jsonStr = s.slice(start, end + 1);
try {
const parsed = JSON.parse(jsonStr);
if (parsed?.content) {
if (Array.isArray(parsed.content)) {
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
preview = firstText;
} else if (typeof parsed.content === 'string') {
preview = parsed.content;
}
}
} catch (_) {
preview = s;
}
} else {
preview = s;
}
}
if (preview && preview.length > 0) {
sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');
}
} catch (e) {
console.log('Could not parse blob data:', e.message);
}
}
} catch (e) {
console.log('Could not read blobs:', e.message);
}
await db.close();
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
if (!sessionData.createdAt) {
if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) {
sessionData.createdAt = new Date(dbStatMtimeMs).toISOString();
}
}
sessions.push(sessionData);
} catch (error) {
console.log(`Could not read session ${sessionId}:`, error.message);
}
}
// Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort)
for (const s of sessions) {
if (!s.createdAt) {
try {
const sessionDir = path.join(cursorChatsPath, s.id);
const st = await fs.stat(sessionDir);
s.createdAt = new Date(st.mtimeMs).toISOString();
} catch {
s.createdAt = new Date().toISOString();
}
}
}
// Sort sessions by creation date (newest first)
sessions.sort((a, b) => {
if (!a.createdAt) return 1;
if (!b.createdAt) return -1;
return new Date(b.createdAt) - new Date(a.createdAt);
});
res.json({
success: true,
sessions: sessions,
cwdId: cwdId,
path: cursorChatsPath
});
} catch (error) {
console.error('Error reading Cursor sessions:', error);
res.status(500).json({
error: 'Failed to read Cursor sessions',
details: error.message
});
}
});
// GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite
router.get('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
const { projectPath } = req.query;
// Calculate cwdID hash for the project path
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get all blobs to build the DAG structure
const allBlobs = await db.all(`
SELECT rowid, id, data FROM blobs
`);
// Build the DAG structure from parent-child relationships
const blobMap = new Map(); // id -> blob data
const parentRefs = new Map(); // blob id -> [parent blob ids]
const childRefs = new Map(); // blob id -> [child blob ids]
const jsonBlobs = []; // Clean JSON messages
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
// Check if this is a JSON blob (actual message) or protobuf (DAG structure)
if (blob.data && blob.data[0] === 0x7B) { // Starts with '{' - JSON blob
try {
const parsed = JSON.parse(blob.data.toString('utf8'));
jsonBlobs.push({ ...blob, parsed });
} catch (e) {
console.log('Failed to parse JSON blob:', blob.rowid);
}
} else if (blob.data) { // Protobuf blob - extract parent references
const parents = [];
let i = 0;
// Scan for parent references (0x0A 0x20 followed by 32-byte hash)
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i+1] === 0x20) {
const parentHash = blob.data.slice(i+2, i+34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
// Update child references
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId).push(blob.id);
}
}
}
}
// Perform topological sort to get chronological order
const visited = new Set();
const sorted = [];
// DFS-based topological sort
function visit(nodeId) {
if (visited.has(nodeId)) return;
visited.add(nodeId);
// Visit all parents first (dependencies)
const parents = parentRefs.get(nodeId) || [];
for (const parentId of parents) {
visit(parentId);
}
// Add this node after all its parents
const blob = blobMap.get(nodeId);
if (blob) {
sorted.push(blob);
}
}
// Start with nodes that have no parents (roots)
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) {
visit(blob.id);
}
}
// Visit any remaining nodes (disconnected components)
for (const blob of allBlobs) {
visit(blob.id);
}
// Now extract JSON messages in the order they appear in the sorted DAG
const messageOrder = new Map(); // JSON blob id -> order index
let orderIndex = 0;
for (const blob of sorted) {
// Check if this blob references any JSON messages
if (blob.data && blob.data[0] !== 0x7B) { // Protobuf blob
// Look for JSON blob references
for (const jsonBlob of jsonBlobs) {
try {
const jsonIdBytes = Buffer.from(jsonBlob.id, 'hex');
if (blob.data.includes(jsonIdBytes)) {
if (!messageOrder.has(jsonBlob.id)) {
messageOrder.set(jsonBlob.id, orderIndex++);
}
}
} catch (e) {
// Skip if can't convert ID
}
}
}
}
// Sort JSON blobs by their appearance order in the DAG
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const orderA = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const orderB = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
// Fallback to rowid if not in order map
return a.rowid - b.rowid;
});
// Use sorted JSON blobs
const blobs = sortedJsonBlobs.map((blob, idx) => ({
...blob,
sequence_num: idx + 1,
original_rowid: blob.rowid
}));
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
// Parse metadata
let metadata = {};
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
metadata[row.key] = JSON.parse(jsonStr);
} else {
metadata[row.key] = row.value.toString();
}
} catch (e) {
metadata[row.key] = row.value.toString();
}
}
}
// Extract messages from sorted JSON blobs
const messages = [];
for (const blob of blobs) {
try {
// We already parsed JSON blobs earlier
const parsed = blob.parsed;
if (parsed) {
// Filter out ONLY system messages at the server level
// Check both direct role and nested message.role
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue; // Skip only system messages
}
messages.push({
id: blob.id,
sequence: blob.sequence_num,
rowid: blob.original_rowid,
content: parsed
});
}
} catch (e) {
// Skip blobs that cause errors
console.log(`Skipping blob ${blob.id}: ${e.message}`);
}
}
await db.close();
res.json({
success: true,
session: {
id: sessionId,
projectPath: projectPath,
messages: messages,
metadata: metadata,
cwdId: cwdId
}
});
} catch (error) {
console.error('Error reading Cursor session:', error);
res.status(500).json({
error: 'Failed to read Cursor session',
details: error.message
});
}
});
export default router;

View File

@@ -56,7 +56,6 @@ router.get('/status', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
console.log('Git status for project:', project, '-> path:', projectPath);
// Validate git repository
await validateGitRepository(projectPath);
@@ -136,13 +135,16 @@ router.get('/diff', async (req, res) => {
lines.map(line => `+${line}`).join('\n');
} else {
// Get diff for tracked files
const { stdout } = await execAsync(`git diff HEAD -- "${file}"`, { cwd: projectPath });
diff = stdout || '';
// First check for unstaged changes (working tree vs index)
const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
// If no unstaged changes, check for staged changes
if (!diff) {
if (unstagedDiff) {
// Show unstaged changes if they exist
diff = unstagedDiff;
} else {
// If no unstaged changes, check for staged changes (index vs HEAD)
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
diff = stagedDiff;
diff = stagedDiff || '';
}
}
@@ -192,7 +194,6 @@ router.get('/branches', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
console.log('Git branches for project:', project, '-> path:', projectPath);
// Validate git repository
await validateGitRepository(projectPath);
@@ -444,10 +445,25 @@ router.get('/remote-status', async (req, res) => {
trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) {
// No upstream branch configured
// No upstream branch configured - but check if we have remotes
let hasRemote = false;
let remoteName = null;
try {
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length > 0) {
hasRemote = true;
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
}
} catch (remoteError) {
// No remotes configured
}
return res.json({
hasRemote: false,
hasRemote,
hasUpstream: false,
branch,
remoteName,
message: 'No remote tracking branch configured'
});
}
@@ -462,6 +478,7 @@ router.get('/remote-status', async (req, res) => {
res.json({
hasRemote: true,
hasUpstream: true,
branch,
remoteBranch: trackingBranch,
remoteName,
@@ -653,6 +670,82 @@ router.post('/push', async (req, res) => {
}
});
// Publish branch to remote (set upstream and push)
router.post('/publish', async (req, res) => {
const { project, branch } = req.body;
if (!project || !branch) {
return res.status(400).json({ error: 'Project name and branch are required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Get current branch to verify it matches the requested branch
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const currentBranchName = currentBranch.trim();
if (currentBranchName !== branch) {
return res.status(400).json({
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
});
}
// Check if remote exists
let remoteName = 'origin';
try {
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length === 0) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
} catch (error) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
// Publish the branch (set upstream and push)
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Branch published successfully',
remoteName,
branch
});
} catch (error) {
console.error('Git publish error:', error);
// Enhanced error handling for common publish scenarios
let errorMessage = 'Publish failed';
let details = error.message;
if (error.message.includes('rejected')) {
errorMessage = 'Publish rejected';
details = 'The remote branch already exists and has different commits. Use push instead.';
} else if (error.message.includes('Could not resolve hostname')) {
errorMessage = 'Network error';
details = 'Unable to connect to remote repository. Check your internet connection.';
} else if (error.message.includes('Permission denied')) {
errorMessage = 'Authentication failed';
details = 'Permission denied. Check your credentials or SSH keys.';
} else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
errorMessage = 'Remote not configured';
details = 'Remote repository not properly configured. Check your remote URL.';
}
res.status(500).json({
error: errorMessage,
details: details
});
}
});
// Discard changes for a specific file
router.post('/discard', async (req, res) => {
const { project, file } = req.body;
@@ -692,4 +785,39 @@ router.post('/discard', async (req, res) => {
}
});
// Delete untracked file
router.post('/delete-untracked', async (req, res) => {
const { project, file } = req.body;
if (!project || !file) {
return res.status(400).json({ error: 'Project name and file path are required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Check if file is actually untracked
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'File is not untracked or does not exist' });
}
const status = statusOutput.substring(0, 2);
if (status !== '??') {
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
}
// Delete the untracked file
await fs.unlink(path.join(projectPath, file));
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
} catch (error) {
console.error('Git delete untracked error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -21,7 +21,7 @@ router.get('/cli/list', async (req, res) => {
const { promisify } = await import('util');
const exec = promisify(spawn);
const process = spawn('claude', ['mcp', 'list', '-s', 'user'], {
const process = spawn('claude', ['mcp', 'list'], {
stdio: ['pipe', 'pipe', 'pipe']
});
@@ -58,29 +58,32 @@ router.get('/cli/list', async (req, res) => {
// POST /api/mcp/cli/add - Add MCP server using Claude CLI
router.post('/cli/add', async (req, res) => {
try {
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {}, scope = 'user', projectPath } = req.body;
console.log(' Adding MCP server using Claude CLI:', name);
console.log(` Adding MCP server using Claude CLI (${scope} scope):`, name);
const { spawn } = await import('child_process');
let cliArgs = ['mcp', 'add'];
// Add scope flag
cliArgs.push('--scope', scope);
if (type === 'http') {
cliArgs.push('--transport', 'http', name, '-s', 'user', url);
cliArgs.push('--transport', 'http', name, url);
// Add headers if provided
Object.entries(headers).forEach(([key, value]) => {
cliArgs.push('--header', `${key}: ${value}`);
});
} else if (type === 'sse') {
cliArgs.push('--transport', 'sse', name, '-s', 'user', url);
cliArgs.push('--transport', 'sse', name, url);
// Add headers if provided
Object.entries(headers).forEach(([key, value]) => {
cliArgs.push('--header', `${key}: ${value}`);
});
} else {
// stdio (default): claude mcp add <name> -s user <command> [args...]
cliArgs.push(name, '-s', 'user');
// stdio (default): claude mcp add --scope user <name> <command> [args...]
cliArgs.push(name);
// Add environment variables
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
@@ -93,9 +96,17 @@ router.post('/cli/add', async (req, res) => {
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
const process = spawn('claude', cliArgs, {
// For local scope, we need to run the command in the project directory
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe']
});
};
if (scope === 'local' && projectPath) {
spawnOptions.cwd = projectPath;
console.log('📁 Running in project directory:', projectPath);
}
const process = spawn('claude', cliArgs, spawnOptions);
let stdout = '';
let stderr = '';
@@ -127,16 +138,136 @@ router.post('/cli/add', async (req, res) => {
}
});
// POST /api/mcp/cli/add-json - Add MCP server using JSON format
router.post('/cli/add-json', async (req, res) => {
try {
const { name, jsonConfig, scope = 'user', projectPath } = req.body;
console.log(' Adding MCP server using JSON format:', name);
// Validate and parse JSON config
let parsedConfig;
try {
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
} catch (parseError) {
return res.status(400).json({
error: 'Invalid JSON configuration',
details: parseError.message
});
}
// Validate required fields
if (!parsedConfig.type) {
return res.status(400).json({
error: 'Invalid configuration',
details: 'Missing required field: type'
});
}
if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
return res.status(400).json({
error: 'Invalid configuration',
details: 'stdio type requires a command field'
});
}
if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
return res.status(400).json({
error: 'Invalid configuration',
details: `${parsedConfig.type} type requires a url field`
});
}
const { spawn } = await import('child_process');
// Build the command: claude mcp add-json --scope <scope> <name> '<json>'
const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
// Add the JSON config as a properly formatted string
const jsonString = JSON.stringify(parsedConfig);
cliArgs.push(jsonString);
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
// For local scope, we need to run the command in the project directory
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe']
};
if (scope === 'local' && projectPath) {
spawnOptions.cwd = projectPath;
console.log('📁 Running in project directory:', projectPath);
}
const process = spawn('claude', cliArgs, spawnOptions);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
} else {
console.error('Claude CLI error:', stderr);
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error adding MCP server via JSON:', error);
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
// DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI
router.delete('/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const { scope } = req.query; // Get scope from query params
console.log('🗑️ Removing MCP server using Claude CLI:', name);
// Handle the ID format (remove scope prefix if present)
let actualName = name;
let actualScope = scope;
// If the name includes a scope prefix like "local:test", extract it
if (name.includes(':')) {
const [prefix, serverName] = name.split(':');
actualName = serverName;
actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
}
console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
const { spawn } = await import('child_process');
const process = spawn('claude', ['mcp', 'remove', '-s', 'user', name], {
// Build command args based on scope
let cliArgs = ['mcp', 'remove'];
// Add scope flag if it's local scope
if (actualScope === 'local') {
cliArgs.push('--scope', 'local');
} else if (actualScope === 'user' || !actualScope) {
// User scope is default, but we can be explicit
cliArgs.push('--scope', 'user');
}
cliArgs.push(actualName);
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
const process = spawn('claude', cliArgs, {
stdio: ['pipe', 'pipe', 'pipe']
});
@@ -179,7 +310,7 @@ router.get('/cli/get/:name', async (req, res) => {
const { spawn } = await import('child_process');
const process = spawn('claude', ['mcp', 'get', '-s', 'user', name], {
const process = spawn('claude', ['mcp', 'get', name], {
stdio: ['pipe', 'pipe', 'pipe']
});
@@ -213,37 +344,172 @@ router.get('/cli/get/:name', async (req, res) => {
}
});
// GET /api/mcp/config/read - Read MCP servers directly from Claude config files
router.get('/config/read', async (req, res) => {
try {
console.log('📖 Reading MCP servers from Claude config files');
const homeDir = os.homedir();
const configPaths = [
path.join(homeDir, '.claude.json'),
path.join(homeDir, '.claude', 'settings.json')
];
let configData = null;
let configPath = null;
// Try to read from either config file
for (const filepath of configPaths) {
try {
const fileContent = await fs.readFile(filepath, 'utf8');
configData = JSON.parse(fileContent);
configPath = filepath;
console.log(`✅ Found Claude config at: ${filepath}`);
break;
} catch (error) {
// File doesn't exist or is not valid JSON, try next
console.log(` Config not found or invalid at: ${filepath}`);
}
}
if (!configData) {
return res.json({
success: false,
message: 'No Claude configuration file found',
servers: []
});
}
// Extract MCP servers from the config
const servers = [];
// Check for user-scoped MCP servers (at root level)
if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {
console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));
for (const [name, config] of Object.entries(configData.mcpServers)) {
const server = {
id: name,
name: name,
type: 'stdio', // Default type
scope: 'user', // User scope - available across all projects
config: {},
raw: config // Include raw config for full details
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
// Check for local-scoped MCP servers (project-specific)
const currentProjectPath = process.cwd();
// Check under 'projects' key
if (configData.projects && configData.projects[currentProjectPath]) {
const projectConfig = configData.projects[currentProjectPath];
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
const server = {
id: `local:${name}`, // Prefix with scope for uniqueness
name: name, // Keep original name
type: 'stdio', // Default type
scope: 'local', // Local scope - only for this project
projectPath: currentProjectPath,
config: {},
raw: config // Include raw config for full details
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
}
console.log(`📋 Found ${servers.length} MCP servers in config`);
res.json({
success: true,
configPath: configPath,
servers: servers
});
} catch (error) {
console.error('Error reading Claude config:', error);
res.status(500).json({
error: 'Failed to read Claude configuration',
details: error.message
});
}
});
// Helper functions to parse Claude CLI output
function parseClaudeListOutput(output) {
// Parse the output from 'claude mcp list' command
// Format: "name: command/url" or "name: url (TYPE)"
const servers = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
// Skip the header line
if (line.includes('Checking MCP server health')) continue;
// Parse lines like "test: test test - ✗ Failed to connect"
// or "server-name: command or description - ✓ Connected"
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
// Skip empty names
if (!name) continue;
// Extract the rest after the name
const rest = line.substring(colonIndex + 1).trim();
// Try to extract description and status
let description = rest;
let status = 'unknown';
let type = 'stdio'; // default type
// Check if it has transport type in parentheses like "(SSE)" or "(HTTP)"
const typeMatch = rest.match(/\((\w+)\)\s*$/);
if (typeMatch) {
type = typeMatch[1].toLowerCase();
} else if (rest.startsWith('http://') || rest.startsWith('https://')) {
// If it's a URL but no explicit type, assume HTTP
// Check for status indicators
if (rest.includes('✓') || rest.includes('✗')) {
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
if (statusMatch) {
description = statusMatch[1].trim();
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
}
}
// Try to determine type from description
if (description.startsWith('http://') || description.startsWith('https://')) {
type = 'http';
}
if (name) {
servers.push({
name,
type,
status: 'active'
});
}
servers.push({
name,
type,
status: status || 'active',
description
});
}
}

60
src/App.jsx Executable file → Normal file
View File

@@ -31,7 +31,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { useVersionCheck } from './hooks/useVersionCheck';
import { api } from './utils/api';
import { api, authenticatedFetch } from './utils/api';
// Main App component with routing
@@ -64,6 +64,10 @@ function AppContent() {
const saved = localStorage.getItem('autoScrollToBottom');
return saved !== null ? JSON.parse(saved) : true;
});
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
const saved = localStorage.getItem('sendByCtrlEnter');
return saved !== null ? JSON.parse(saved) : false;
});
// Session Protection System: Track sessions with active conversations to prevent
// automatic project updates from interrupting ongoing chats. When a user sends
// a message, the session is marked as "active" and project updates are paused
@@ -188,6 +192,27 @@ function AppContent() {
const response = await api.projects();
const data = await response.json();
// Always fetch Cursor sessions for each project so we can combine views
for (let project of data) {
try {
const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`;
const cursorResponse = await authenticatedFetch(url);
if (cursorResponse.ok) {
const cursorData = await cursorResponse.json();
if (cursorData.success && cursorData.sessions) {
project.cursorSessions = cursorData.sessions;
} else {
project.cursorSessions = [];
}
} else {
project.cursorSessions = [];
}
} catch (error) {
console.error(`Error fetching Cursor sessions for project ${project.name}:`, error);
project.cursorSessions = [];
}
}
// Optimize to preserve object references when data hasn't changed
setProjects(prevProjects => {
// If no previous projects, just set the new data
@@ -206,7 +231,8 @@ function AppContent() {
newProject.displayName !== prevProject.displayName ||
newProject.fullPath !== prevProject.fullPath ||
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions)
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) ||
JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions)
);
}) || data.length !== prevProjects.length;
@@ -232,16 +258,26 @@ function AppContent() {
const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
// Find the session across all projects
for (const project of projects) {
const session = project.sessions?.find(s => s.id === sessionId);
let session = project.sessions?.find(s => s.id === sessionId);
if (session) {
setSelectedProject(project);
setSelectedSession(session);
setSelectedSession({ ...session, __provider: 'claude' });
// Only switch to chat tab if we're loading a different session
if (shouldSwitchTab) {
setActiveTab('chat');
}
return;
}
// Also check Cursor sessions
const cSession = project.cursorSessions?.find(s => s.id === sessionId);
if (cSession) {
setSelectedProject(project);
setSelectedSession({ ...cSession, __provider: 'cursor' });
if (shouldSwitchTab) {
setActiveTab('chat');
}
return;
}
}
// If session not found, it might be a newly created session
@@ -266,6 +302,15 @@ 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';
if (provider === 'cursor') {
// Cursor sessions have persistent IDs
sessionStorage.setItem('cursorSessionId', session.id);
}
if (isMobile) {
setSidebarOpen(false);
}
@@ -586,6 +631,7 @@ function AppContent() {
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/>
</div>
@@ -617,6 +663,11 @@ function AppContent() {
setAutoScrollToBottom(value);
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
}}
sendByCtrlEnter={sendByCtrlEnter}
onSendByCtrlEnterChange={(value) => {
setSendByCtrlEnter(value);
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
}}
isMobile={isMobile}
/>
)}
@@ -625,6 +676,7 @@ function AppContent() {
<ToolsSettings
isOpen={showToolsSettings}
onClose={() => setShowToolsSettings(false)}
projects={projects}
/>
{/* Version Upgrade Modal */}

1280
src/components/ChatInterface.jsx Executable file → Normal file

File diff suppressed because it is too large Load Diff

0
src/components/ClaudeLogo.jsx Executable file → Normal file
View File

2
src/components/ClaudeStatus.jsx Executable file → Normal file
View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
function ClaudeStatus({ status, onAbort, isLoading }) {
function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
const [fakeTokens, setFakeTokens] = useState(0);

0
src/components/CodeEditor.jsx Executable file → Normal file
View File

View File

@@ -0,0 +1,9 @@
import React from 'react';
const CursorLogo = ({ className = 'w-5 h-5' }) => {
return (
<img src="/icons/cursor.svg" alt="Cursor" className={className} />
);
};
export default CursorLogo;

0
src/components/DarkModeToggle.jsx Executable file → Normal file
View File

View File

@@ -0,0 +1,73 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log the error details
console.error('ErrorBoundary caught an error:', error, errorInfo);
// You can also log the error to an error reporting service here
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (this.state.hasError) {
// Fallback UI
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{this.props.showDetails && this.state.error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={() => {
this.setState({ hasError: false, error: null, errorInfo: null });
if (this.props.onRetry) this.props.onRetry();
}}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

0
src/components/FileTree.jsx Executable file → Normal file
View File

199
src/components/GitPanel.jsx Executable file → Normal file
View File

@@ -28,6 +28,7 @@ function GitPanel({ selectedProject, isMobile }) {
const [isFetching, setIsFetching] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
const textareaRef = useRef(null);
@@ -59,14 +60,12 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchGitStatus = async () => {
if (!selectedProject) return;
console.log('Fetching git status for project:', selectedProject.name, 'path:', selectedProject.path);
setIsLoading(true);
try {
const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
const data = await response.json();
console.log('Git status response:', data);
if (data.error) {
console.error('Git status error:', data.error);
@@ -266,6 +265,34 @@ function GitPanel({ selectedProject, isMobile }) {
}
};
const handlePublish = async () => {
setIsPublishing(true);
try {
const response = await authenticatedFetch('/api/git/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
branch: currentBranch
})
});
const data = await response.json();
if (data.success) {
// Refresh status after successful publish
fetchGitStatus();
fetchRemoteStatus();
} else {
console.error('Publish failed:', data.error);
// TODO: Show user-friendly error message
}
} catch (error) {
console.error('Error publishing branch:', error);
} finally {
setIsPublishing(false);
}
};
const discardChanges = async (filePath) => {
try {
const response = await authenticatedFetch('/api/git/discard', {
@@ -294,6 +321,34 @@ function GitPanel({ selectedProject, isMobile }) {
}
};
const deleteUntrackedFile = async (filePath) => {
try {
const response = await authenticatedFetch('/api/git/delete-untracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
file: filePath
})
});
const data = await response.json();
if (data.success) {
// Remove from selected files and refresh status
setSelectedFiles(prev => {
const newSet = new Set(prev);
newSet.delete(filePath);
return newSet;
});
fetchGitStatus();
} else {
console.error('Delete failed:', data.error);
}
} catch (error) {
console.error('Error deleting untracked file:', error);
}
};
const confirmAndExecute = async () => {
if (!confirmAction) return;
@@ -305,6 +360,9 @@ function GitPanel({ selectedProject, isMobile }) {
case 'discard':
await discardChanges(file);
break;
case 'delete':
await deleteUntrackedFile(file);
break;
case 'commit':
await handleCommit();
break;
@@ -314,6 +372,9 @@ function GitPanel({ selectedProject, isMobile }) {
case 'push':
await handlePush();
break;
case 'publish':
await handlePublish();
break;
}
} catch (error) {
console.error(`Error executing ${type}:`, error);
@@ -578,6 +639,23 @@ function GitPanel({ selectedProject, isMobile }) {
{isMobile && <span>Discard</span>}
</button>
)}
{status === 'U' && (
<button
onClick={(e) => {
e.stopPropagation();
setConfirmAction({
type: 'delete',
file: filePath,
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
});
}}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
title="Delete untracked file"
>
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
{isMobile && <span>Delete</span>}
</button>
)}
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
@@ -716,51 +794,72 @@ function GitPanel({ selectedProject, isMobile }) {
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
{/* Remote action buttons - smart logic based on ahead/behind status */}
{remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && (
{remoteStatus?.hasRemote && (
<>
{/* Pull button - show when behind (primary action) */}
{remoteStatus.behind > 0 && (
{/* Publish button - show when branch doesn't exist on remote */}
{!remoteStatus?.hasUpstream && (
<button
onClick={() => setConfirmAction({
type: 'pull',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
type: 'publish',
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
})}
disabled={isPulling}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
disabled={isPublishing}
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1"
title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
>
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
</button>
)}
{/* Push button - show when ahead (primary action when ahead only) */}
{remoteStatus.ahead > 0 && (
<button
onClick={() => setConfirmAction({
type: 'push',
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
})}
disabled={isPushing}
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
>
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
</button>
)}
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
<button
onClick={handleFetch}
disabled={isFetching}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
title={`Fetch from ${remoteStatus.remoteName}`}
>
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
</button>
{/* Show normal push/pull buttons only if branch has upstream */}
{remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && (
<>
{/* Pull button - show when behind (primary action) */}
{remoteStatus.behind > 0 && (
<button
onClick={() => setConfirmAction({
type: 'pull',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
})}
disabled={isPulling}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
>
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
</button>
)}
{/* Push button - show when ahead (primary action when ahead only) */}
{remoteStatus.ahead > 0 && (
<button
onClick={() => setConfirmAction({
type: 'push',
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
})}
disabled={isPushing}
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
>
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
</button>
)}
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
<button
onClick={handleFetch}
disabled={isFetching}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
title={`Fetch from ${remoteStatus.remoteName}`}
>
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
</button>
)}
</>
)}
</>
)}
@@ -1120,16 +1219,18 @@ function GitPanel({ selectedProject, isMobile }) {
<div className="p-6">
<div className="flex items-center mb-4">
<div className={`p-2 rounded-full mr-3 ${
confirmAction.type === 'discard' ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
}`}>
<AlertTriangle className={`w-5 h-5 ${
confirmAction.type === 'discard' ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
}`} />
</div>
<h3 className="text-lg font-semibold">
{confirmAction.type === 'discard' ? 'Discard Changes' :
confirmAction.type === 'delete' ? 'Delete File' :
confirmAction.type === 'commit' ? 'Confirm Commit' :
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'}
confirmAction.type === 'pull' ? 'Confirm Pull' :
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
</h3>
</div>
@@ -1147,12 +1248,14 @@ function GitPanel({ selectedProject, isMobile }) {
<button
onClick={confirmAndExecute}
className={`px-4 py-2 text-sm text-white rounded-md ${
confirmAction.type === 'discard'
(confirmAction.type === 'discard' || confirmAction.type === 'delete')
? 'bg-red-600 hover:bg-red-700'
: confirmAction.type === 'commit'
? 'bg-blue-600 hover:bg-blue-700'
: confirmAction.type === 'pull'
? 'bg-green-600 hover:bg-green-700'
: confirmAction.type === 'publish'
? 'bg-purple-600 hover:bg-purple-700'
: 'bg-orange-600 hover:bg-orange-700'
} flex items-center space-x-2`}
>
@@ -1161,6 +1264,11 @@ function GitPanel({ selectedProject, isMobile }) {
<Trash2 className="w-4 h-4" />
<span>Discard</span>
</>
) : confirmAction.type === 'delete' ? (
<>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</>
) : confirmAction.type === 'commit' ? (
<>
<Check className="w-4 h-4" />
@@ -1171,6 +1279,11 @@ function GitPanel({ selectedProject, isMobile }) {
<Download className="w-4 h-4" />
<span>Pull</span>
</>
) : confirmAction.type === 'publish' ? (
<>
<Upload className="w-4 h-4" />
<span>Publish</span>
</>
) : (
<>
<Upload className="w-4 h-4" />

0
src/components/ImageViewer.jsx Executable file → Normal file
View File

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ClaudeLogo from './ClaudeLogo';
import { MessageSquare } from 'lucide-react';
const LoginForm = () => {
const [username, setUsername] = useState('');
@@ -37,7 +37,9 @@ const LoginForm = () => {
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
<p className="text-muted-foreground mt-2">

108
src/components/MainContent.jsx Executable file → Normal file
View File

@@ -17,6 +17,9 @@ import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
import Shell from './Shell';
import GitPanel from './GitPanel';
import ErrorBoundary from './ErrorBoundary';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo';
function MainContent({
selectedProject,
@@ -39,7 +42,8 @@ function MainContent({
onShowSettings, // Show tools settings panel
autoExpandTools, // Auto-expand tool accordions
showRawParameters, // Show raw parameters in tool accordions
autoScrollToBottom // Auto-scroll to bottom when new messages arrive
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
}) {
const [editingFile, setEditingFile] = useState(null);
@@ -151,35 +155,46 @@ function MainContent({
</svg>
</button>
)}
<div className="min-w-0">
{activeTab === 'chat' && selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
{selectedSession.summary}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName} <span className="hidden sm:inline"> {selectedSession.id}</span>
</div>
</div>
) : activeTab === 'chat' && !selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
New Session
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
) : (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
{activeTab === 'files' ? 'Project Files' : activeTab === 'git' ? 'Source Control' : 'Project'}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
<div className="min-w-0 flex items-center gap-2">
{activeTab === 'chat' && selectedSession && (
<div className="w-6 h-6 flex-shrink-0 flex items-center justify-center">
{selectedSession.__provider === 'cursor' ? (
<CursorLogo className="w-5 h-5" />
) : (
<ClaudeLogo className="w-5 h-5" />
)}
</div>
)}
<div className="flex-1 min-w-0">
{activeTab === 'chat' && selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
{selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session')}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName} <span className="hidden sm:inline"> {selectedSession.id}</span>
</div>
</div>
) : activeTab === 'chat' && !selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
New Session
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
) : (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
{activeTab === 'files' ? 'Project Files' : activeTab === 'git' ? 'Source Control' : 'Project'}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
)}
</div>
</div>
</div>
@@ -269,23 +284,26 @@ function MainContent({
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
/>
<ErrorBoundary showDetails={true}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/>
</ErrorBoundary>
</div>
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
<FileTree selectedProject={selectedProject} />

0
src/components/MicButton.jsx Executable file → Normal file
View File

0
src/components/MobileNav.jsx Executable file → Normal file
View File

View File

@@ -2,13 +2,15 @@ import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import SetupForm from './SetupForm';
import LoginForm from './LoginForm';
import ClaudeLogo from './ClaudeLogo';
import { MessageSquare } from 'lucide-react';
const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">

26
src/components/QuickSettingsPanel.jsx Executable file → Normal file
View File

@@ -11,7 +11,8 @@ import {
Mic,
Brain,
Sparkles,
FileText
FileText,
Languages
} from 'lucide-react';
import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext';
@@ -25,6 +26,8 @@ const QuickSettingsPanel = ({
onShowRawParametersChange,
autoScrollToBottom,
onAutoScrollChange,
sendByCtrlEnter,
onSendByCtrlEnterChange,
isMobile
}) => {
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
@@ -142,6 +145,27 @@ const QuickSettingsPanel = ({
</label>
</div>
{/* Input Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Send by Ctrl+Enter
</span>
<input
type="checkbox"
checked={sendByCtrlEnter}
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
</p>
</div>
{/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>

23
src/components/Shell.jsx Executable file → Normal file
View File

@@ -436,6 +436,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
projectPath: selectedProject.fullPath || selectedProject.path,
sessionId: selectedSession?.id,
hasSession: !!selectedSession,
provider: selectedSession?.__provider || 'claude',
cols: terminal.current.cols,
rows: terminal.current.rows
};
@@ -530,11 +531,16 @@ function Shell({ selectedProject, selectedSession, isActive }) {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (
<span className="text-xs text-blue-300">
({selectedSession.summary.slice(0, 30)}...)
</span>
)}
{selectedSession && (() => {
const displaySessionName = selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
return (
<span className="text-xs text-blue-300">
({displaySessionName.slice(0, 30)}...)
</span>
);
})()}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
)}
@@ -601,7 +607,12 @@ function Shell({ selectedProject, selectedSession, isActive }) {
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{selectedSession ?
`Resume session: ${selectedSession.summary.slice(0, 50)}...` :
(() => {
const displaySessionName = selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
return `Resume session: ${displaySessionName.slice(0, 50)}...`;
})() :
'Start a new Claude session'
}
</p>

101
src/components/Sidebar.jsx Executable file → Normal file
View File

@@ -7,6 +7,7 @@ import { Input } from './ui/input';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
import { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo.jsx';
import { api } from '../utils/api';
// Move formatTimeAgo outside component to avoid recreation on every render
@@ -202,9 +203,12 @@ function Sidebar({
// Helper function to get all sessions for a project (initial + additional)
const getAllSessions = (project) => {
const initialSessions = project.sessions || [];
const additional = additionalSessions[project.name] || [];
return [...initialSessions, ...additional];
// Combine Claude and Cursor sessions; Sidebar will display icon per row
const claudeSessions = [...(project.sessions || []), ...(additionalSessions[project.name] || [])].map(s => ({ ...s, __provider: 'claude' }));
const cursorSessions = (project.cursorSessions || []).map(s => ({ ...s, __provider: 'cursor' }));
// Sort by most recent activity/date
const normalizeDate = (s) => new Date(s.__provider === 'cursor' ? s.createdAt : s.lastActivity);
return [...claudeSessions, ...cursorSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a));
};
// Helper function to get the last activity date for a project
@@ -374,9 +378,7 @@ function Sidebar({
try {
const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
const response = await fetch(
`/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}`
);
const response = await api.sessions(project.name, 5, currentSessionCount);
if (response.ok) {
const result = await response.json();
@@ -981,11 +983,19 @@ function Sidebar({
</div>
) : (
getAllSessions(project).map((session) => {
// Handle both Claude and Cursor session formats
const isCursorSession = session.__provider === 'cursor';
// Calculate if session is active (within last 10 minutes)
const sessionDate = new Date(session.lastActivity);
const sessionDate = new Date(isCursorSession ? session.createdAt : session.lastActivity);
const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60));
const isActive = diffInMinutes < 10;
// Get session display values
const sessionName = isCursorSession ? (session.name || 'Untitled Session') : (session.summary || 'New Session');
const sessionTime = isCursorSession ? session.createdAt : session.lastActivity;
const messageCount = session.messageCount || 0;
return (
<div key={session.id} className="group relative">
{/* Active session indicator dot */}
@@ -1016,38 +1026,49 @@ function Sidebar({
"w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
)}>
<MessageSquare className={cn(
"w-3 h-3",
selectedSession?.id === session.id ? "text-primary" : "text-muted-foreground"
)} />
{isCursorSession ? (
<CursorLogo className="w-3 h-3" />
) : (
<ClaudeLogo className="w-3 h-3" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate text-foreground">
{session.summary || 'New Session'}
{sessionName}
</div>
<div className="flex items-center gap-1 mt-0.5">
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(session.lastActivity, currentTime)}
{formatTimeAgo(sessionTime, currentTime)}
</span>
{session.messageCount > 0 && (
{messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
{session.messageCount}
{messageCount}
</Badge>
)}
{/* Provider tiny icon */}
<span className="ml-1 opacity-70">
{isCursorSession ? (
<CursorLogo className="w-3 h-3" />
) : (
<ClaudeLogo className="w-3 h-3" />
)}
</span>
</div>
</div>
{/* Mobile delete button */}
<button
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id);
}}
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
>
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button>
{/* Mobile delete button - only for Claude sessions */}
{!isCursorSession && (
<button
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id);
}}
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
>
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button>
)}
</div>
</div>
</div>
@@ -1064,26 +1085,39 @@ function Sidebar({
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
>
<div className="flex items-start gap-2 min-w-0 w-full">
<MessageSquare className="w-3 h-3 text-muted-foreground mt-0.5 flex-shrink-0" />
{isCursorSession ? (
<CursorLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
) : (
<ClaudeLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate text-foreground">
{session.summary || 'New Session'}
{sessionName}
</div>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(session.lastActivity, currentTime)}
{formatTimeAgo(sessionTime, currentTime)}
</span>
{session.messageCount > 0 && (
{messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
{session.messageCount}
{messageCount}
</Badge>
)}
{/* Provider tiny icon */}
<span className="ml-1 opacity-70">
{isCursorSession ? (
<CursorLogo className="w-3 h-3" />
) : (
<ClaudeLogo className="w-3 h-3" />
)}
</span>
</div>
</div>
</div>
</Button>
{/* Desktop hover buttons */}
{/* Desktop hover buttons - only for Claude sessions */}
{!isCursorSession && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
{editingSession === session.id ? (
<>
@@ -1170,6 +1204,7 @@ function Sidebar({
</>
)}
</div>
)}
</div>
</div>
);

0
src/components/TodoList.jsx Executable file → Normal file
View File

820
src/components/ToolsSettings.jsx Executable file → Normal file

File diff suppressed because it is too large Load Diff

0
src/components/ui/badge.jsx Executable file → Normal file
View File

0
src/components/ui/button.jsx Executable file → Normal file
View File

0
src/components/ui/input.jsx Executable file → Normal file
View File

0
src/components/ui/scroll-area.jsx Executable file → Normal file
View File

0
src/contexts/ThemeContext.jsx Executable file → Normal file
View File

12
src/index.css Executable file → Normal file
View File

@@ -559,16 +559,6 @@
transform: inherit !important;
}
/* Preserve backgrounds for containers and modals */
.fixed:hover,
.modal:hover,
.bg-white:hover,
.bg-gray-800:hover,
.bg-gray-900:hover,
[class*="bg-"]:hover {
background-color: revert !important;
}
/* Force buttons to be immediately clickable */
button, [role="button"], .cursor-pointer {
cursor: pointer !important;
@@ -746,4 +736,4 @@
background-color: rgb(31 41 55) !important;
color: rgb(243 244 246) !important;
}
}
}

0
src/main.jsx Executable file → Normal file
View File

View File

@@ -43,8 +43,16 @@ export const api = {
projects: () => authenticatedFetch('/api/projects'),
sessions: (projectName, limit = 5, offset = 0) =>
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
sessionMessages: (projectName, sessionId) =>
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`),
sessionMessages: (projectName, sessionId, limit = null, offset = 0) => {
const params = new URLSearchParams();
if (limit !== null) {
params.append('limit', limit);
params.append('offset', offset);
}
const queryString = params.toString();
const url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
return authenticatedFetch(url);
},
renameProject: (projectName, displayName) =>
authenticatedFetch(`/api/projects/${projectName}/rename`, {
method: 'PUT',

BIN
store.db-shm Normal file

Binary file not shown.

0
store.db-wal Normal file
View File

1
test.html Normal file
View File

@@ -0,0 +1 @@
hello world 5

View File

@@ -9,10 +9,14 @@ export default defineConfig(({ command, mode }) => {
return {
plugins: [react()],
server: {
port: parseInt(env.VITE_PORT) || 3001,
port: parseInt(env.VITE_PORT) || 5173,
proxy: {
'/api': `http://localhost:${env.PORT || 3002}`,
'/api': `http://localhost:${env.PORT || 3001}`,
'/ws': {
target: `ws://localhost:${env.PORT || 3001}`,
ws: true
},
'/shell': {
target: `ws://localhost:${env.PORT || 3002}`,
ws: true
}