Compare commits
217 Commits
v1.10.4
...
e36d849b5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e36d849b5e | ||
|
|
216932e7f9 | ||
|
|
a35140bc5e | ||
|
|
0b4d048e9a | ||
|
|
312654fdc6 | ||
|
|
438b9698cc | ||
|
|
20d31da4f4 | ||
|
|
4f87018e61 | ||
|
|
b2fdb90203 | ||
|
|
8bea3d83c8 | ||
|
|
cfd766819a | ||
|
|
471892b2bd | ||
|
|
eca96c6973 | ||
|
|
5a4813f9bd | ||
|
|
f6970d6ad9 | ||
|
|
e65a210cb3 | ||
|
|
8e9f7f0536 | ||
|
|
51b316f69c | ||
|
|
dc21fb532a | ||
|
|
d9233f60b6 | ||
|
|
430d0ddc4a | ||
|
|
e9719256fc | ||
|
|
55caaf060c | ||
|
|
f9c7321c8c | ||
|
|
88bda6e5c0 | ||
|
|
86b421c790 | ||
|
|
41ef84c283 | ||
|
|
53224e47b6 | ||
|
|
bbb51dbf99 | ||
|
|
2d06cae0ca | ||
|
|
14fb81586c | ||
|
|
4d2b592ec6 | ||
|
|
4957220a05 | ||
|
|
3debc3a249 | ||
|
|
5512e2e15b | ||
|
|
1b42dba902 | ||
|
|
ede56ad81b | ||
|
|
36094fb73f | ||
|
|
57828653bf | ||
|
|
8ef0951901 | ||
|
|
ab50c5c1a8 | ||
|
|
6726e8f44e | ||
|
|
07f89e5240 | ||
|
|
8a675a713b | ||
|
|
5724c11253 | ||
|
|
c7b9976986 | ||
|
|
f16e3e763d | ||
|
|
477bc404b0 | ||
|
|
ae5a21cd6e | ||
|
|
b2c69d6ea8 | ||
|
|
8825baf5b4 | ||
|
|
0d1a3df1f7 | ||
|
|
80732923b5 | ||
|
|
6362d35d66 | ||
|
|
10bfeed614 | ||
|
|
dab089b29f | ||
|
|
38745bdf85 | ||
|
|
9da7c1cbae | ||
|
|
844677caee | ||
|
|
e1c67fd5d0 | ||
|
|
9cd0cfc88f | ||
|
|
09f1021c59 | ||
|
|
cf0f60bc48 | ||
|
|
053d94ab9d | ||
|
|
79f7bf9a63 | ||
|
|
e85cc746b1 | ||
|
|
cc3368c591 | ||
|
|
5131d2ae27 | ||
|
|
394b95ae29 | ||
|
|
4948aa3d64 | ||
|
|
6e07f140e3 | ||
|
|
fea8e30725 | ||
|
|
9f534ce15b | ||
|
|
8cb34a73b5 | ||
|
|
74640a7f31 | ||
|
|
5800d84255 | ||
|
|
33c70a372d | ||
|
|
396f058b46 | ||
|
|
b899695772 | ||
|
|
a173817d37 | ||
|
|
73375d7653 | ||
|
|
1d48b78af2 | ||
|
|
7928285ed0 | ||
|
|
92cbb3e7d9 | ||
|
|
0517ee609e | ||
|
|
3bbf38125a | ||
|
|
9e03acb0db | ||
|
|
515ad3b336 | ||
|
|
b68a903781 | ||
|
|
a08deee6b7 | ||
|
|
9cd1b5811a | ||
|
|
ee43adb311 | ||
|
|
740f3a7f0e | ||
|
|
e1f2af1a34 | ||
|
|
50f8c4ba72 | ||
|
|
1e8e52ce8d | ||
|
|
133c762eea | ||
|
|
4216676395 | ||
|
|
ddb26c7652 | ||
|
|
b3c6e95971 | ||
|
|
f8d1ec7b9e | ||
|
|
e73960ae78 | ||
|
|
1f6c0c3899 | ||
|
|
9da8e69476 | ||
|
|
15e4db386f | ||
|
|
66e85fb2c1 | ||
|
|
42b2d5e1d9 | ||
|
|
d3c4821258 | ||
|
|
72c4b0749e | ||
|
|
35e140b941 | ||
|
|
b70728254b | ||
|
|
64ebbaf387 | ||
|
|
cdaff9d146 | ||
|
|
3f66179e72 | ||
|
|
c654f489af | ||
|
|
97ebef016a | ||
|
|
ef44942767 | ||
|
|
7b63a68e7e | ||
|
|
005033136b | ||
|
|
ee3917b3f9 | ||
|
|
8fb43d358c | ||
|
|
4c40a33255 | ||
|
|
4086fdaa4e | ||
|
|
124c1ac600 | ||
|
|
9efe433d99 | ||
|
|
189a1b174c | ||
|
|
04a0ff311e | ||
|
|
efae890e34 | ||
|
|
ea33810a4f | ||
|
|
4fe6cc4272 | ||
|
|
ba70ad8e81 | ||
|
|
b066ec4c01 | ||
|
|
104e4260a7 | ||
|
|
8af982e706 | ||
|
|
81c0773358 | ||
|
|
29783f609f | ||
|
|
ea19bd9a00 | ||
|
|
6d4e5017d0 | ||
|
|
9b217ada0d | ||
|
|
04efaa41f6 | ||
|
|
5aef9c683a | ||
|
|
724cb5bb5c | ||
|
|
4e163c8c10 | ||
|
|
b315360f8a | ||
|
|
04821b8ad5 | ||
|
|
00278a13d8 | ||
|
|
676d2415a0 | ||
|
|
babe96eedd | ||
|
|
60c8bda755 | ||
|
|
d98b112302 | ||
|
|
8186c4039f | ||
|
|
02c13b0794 | ||
|
|
a8c141cb8e | ||
|
|
fbbf7465fb | ||
|
|
7a173071f1 | ||
|
|
6bf3696991 | ||
|
|
d822a96818 | ||
|
|
19bb741af0 | ||
|
|
1f4cd16b89 | ||
|
|
09688a09ca | ||
|
|
1cc3f61b81 | ||
|
|
89c9aec5b7 | ||
|
|
e74a813093 | ||
|
|
73a0b5bebd | ||
|
|
3a72a262a9 | ||
|
|
e952cf0a42 | ||
|
|
18d0874142 | ||
|
|
33e70c4b55 | ||
|
|
98c8b14b4f | ||
|
|
544c72434a | ||
|
|
b892985700 | ||
|
|
8c629a1a05 | ||
|
|
2df8c8e786 | ||
|
|
f91f9f702d | ||
|
|
6219c273a2 | ||
|
|
33834d808b | ||
|
|
abe8cd46a2 | ||
|
|
521fce32d0 | ||
|
|
2815e206dc | ||
|
|
71e400c54f | ||
|
|
05b2b59e23 | ||
|
|
ad219c8716 | ||
|
|
ed65399dfb | ||
|
|
2fb1e1cfb0 | ||
|
|
0f4b3666fc | ||
|
|
69b7b59f00 | ||
|
|
51431832d8 | ||
|
|
1e50cfdad6 | ||
|
|
289c2334e0 | ||
|
|
de8c4d1845 | ||
|
|
23de8c7863 | ||
|
|
041c72b160 | ||
|
|
519b5e5209 | ||
|
|
7ab14750de | ||
|
|
255aed0b01 | ||
|
|
b416e542c7 | ||
|
|
43cbbb10d9 | ||
|
|
003b64f8f0 | ||
|
|
401223dcd5 | ||
|
|
499e33d910 | ||
|
|
0181883c8a | ||
|
|
a100aa598c | ||
|
|
c875907f55 | ||
|
|
b2c16002e4 | ||
|
|
c7dbab086b | ||
|
|
b31f7afdf5 | ||
|
|
06d17eb22e | ||
|
|
57739a659f | ||
|
|
a5813e66d9 | ||
|
|
18ea4a19dd | ||
|
|
1c95c598eb | ||
|
|
72e97c4fbc | ||
|
|
b5d1fed354 | ||
|
|
d1733f34e0 | ||
|
|
fefcc0f338 | ||
|
|
36f8f50d63 | ||
|
|
4e14222487 |
22
.env.example
@@ -1,5 +1,15 @@
|
||||
# Claude Code UI Environment Configuration
|
||||
# Only includes variables that are actually used in the code
|
||||
#
|
||||
# TIP: Run 'cloudcli status' to see where this file should be located
|
||||
# and to view your current configuration.
|
||||
#
|
||||
# Available CLI commands:
|
||||
# claude-code-ui - Start the server (default)
|
||||
# cloudcli start - Start the server
|
||||
# cloudcli status - Show configuration and data locations
|
||||
# cloudcli help - Show help information
|
||||
# cloudcli version - Show version information
|
||||
|
||||
# =============================================================================
|
||||
# SERVER CONFIGURATION
|
||||
@@ -19,11 +29,13 @@ VITE_PORT=5173
|
||||
# =============================================================================
|
||||
|
||||
# Path to the authentication database file
|
||||
# This should be set to a persistent volume path when running in containers
|
||||
# Default: server/database/auth.db (relative to project root)
|
||||
# Example for Docker: /data/auth.db
|
||||
# DATABASE_PATH=/data/auth.db
|
||||
# This is where user credentials, API keys, and tokens are stored.
|
||||
#
|
||||
# To use a custom location:
|
||||
# DATABASE_PATH=/path/to/your/custom/auth.db
|
||||
#
|
||||
# Claude Code context window size (maximum tokens per session)
|
||||
# Note: VITE_ prefix makes it available to frontend
|
||||
VITE_CONTEXT_WINDOW=160000
|
||||
CONTEXT_WINDOW=160000
|
||||
|
||||
# VITE_IS_PLATFORM=false
|
||||
|
||||
57
.npmignore
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
*.md
|
||||
!README.md
|
||||
.env*
|
||||
.gitignore
|
||||
.nvmrc
|
||||
.release-it.json
|
||||
release.sh
|
||||
postcss.config.js
|
||||
vite.config.js
|
||||
tailwind.config.js
|
||||
|
||||
# Database files
|
||||
authdb/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# AI specific
|
||||
.claude/
|
||||
.cursor/
|
||||
.roo/
|
||||
.taskmaster/
|
||||
.cline/
|
||||
.windsurf/
|
||||
.serena/
|
||||
CLAUDE.md
|
||||
.mcp.json
|
||||
|
||||
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
Before Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 452 KiB |
|
Before Width: | Height: | Size: 47 KiB |
117
README.md
@@ -1,10 +1,12 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Claude Code UI</h1>
|
||||
<h1>Cloud CLI (aka Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
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**
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
|
||||
|
||||
[English](./README.md) | [中文](./README.zh-CN.md)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,7 +32,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
<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>
|
||||
<em>Select between Claude Code, Cursor CLI and Codex</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -41,14 +43,14 @@ 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 or Cursor
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code or Cursor CLI through built-in shell functionality
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex 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
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4, Opus 4.1, and GPT-5
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2
|
||||
|
||||
|
||||
## Quick Start
|
||||
@@ -57,7 +59,8 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
|
||||
- [Node.js](https://nodejs.org/) v20 or higher
|
||||
- [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
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
|
||||
- [Codex](https://developers.openai.com/codex) installed and configured
|
||||
|
||||
### One-click Operation (Recommended)
|
||||
|
||||
@@ -69,8 +72,7 @@ npx @siteboon/claude-code-ui
|
||||
|
||||
The server will start and be accessible at `http://localhost:3001` (or your configured PORT).
|
||||
|
||||
**To restart**: Simply run the same `npx` command again after stopping the server (Ctrl+C or Cmd+C).
|
||||
|
||||
**To restart**: Simply run the same `npx` command again after stopping the server
|
||||
### Global Installation (For Regular Use)
|
||||
|
||||
For frequent use, install globally once:
|
||||
@@ -85,32 +87,73 @@ Then start with a simple command:
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Faster startup (no download/cache check)
|
||||
- Simple command to remember
|
||||
- Same experience every time
|
||||
|
||||
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
|
||||
|
||||
### Run as Background Service (Optional)
|
||||
**To update**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
To keep the server running in the background, use PM2:
|
||||
### CLI Usage
|
||||
|
||||
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
|
||||
|
||||
| Command / Option | Short | Description |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` or `claude-code-ui` | | Start the server (default) |
|
||||
| `cloudcli start` | | Start the server explicitly |
|
||||
| `cloudcli status` | | Show configuration and data locations |
|
||||
| `cloudcli update` | | Update to the latest version |
|
||||
| `cloudcli help` | | Show help information |
|
||||
| `cloudcli version` | | Show version information |
|
||||
| `--port <port>` | `-p` | Set server port (default: 3001) |
|
||||
| `--database-path <path>` | | Set custom database location |
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
cloudcli # Start with defaults
|
||||
cloudcli -p 8080 # Start on custom port
|
||||
cloudcli status # Show current configuration
|
||||
```
|
||||
|
||||
### Run as Background Service (Recommended for Production)
|
||||
|
||||
For production use, run Claude Code UI as a background service using PM2 (Process Manager 2):
|
||||
|
||||
#### Install PM2
|
||||
|
||||
```bash
|
||||
# Install PM2 globally (one-time)
|
||||
npm install -g pm2
|
||||
|
||||
# Start the server
|
||||
pm2 start claude-code-ui --name "claude-ui"
|
||||
|
||||
# Manage the service
|
||||
pm2 list # View status
|
||||
pm2 restart claude-ui # Restart
|
||||
pm2 stop claude-ui # Stop
|
||||
pm2 logs claude-ui # View logs
|
||||
pm2 startup # Auto-start on system boot
|
||||
```
|
||||
|
||||
#### Start as Background Service
|
||||
|
||||
```bash
|
||||
# Start the server in background
|
||||
pm2 start claude-code-ui --name "claude-code-ui"
|
||||
|
||||
# Or using the shorter alias
|
||||
pm2 start cloudcli --name "claude-code-ui"
|
||||
|
||||
# Start on a custom port
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### Auto-Start on System Boot
|
||||
|
||||
To make Claude Code UI start automatically when your system boots:
|
||||
|
||||
```bash
|
||||
# Generate startup script for your platform
|
||||
pm2 startup
|
||||
|
||||
# Save current process list
|
||||
pm2 save
|
||||
```
|
||||
|
||||
|
||||
### Local Development Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
@@ -180,15 +223,15 @@ After installing it you should be able to enable it from the Settings
|
||||
### Core Features
|
||||
|
||||
#### Project Management
|
||||
The UI automatically discovers Claude Code projects from `~/.claude/projects/` and provides:
|
||||
- **Visual Project Browser** - All available projects with metadata and session counts
|
||||
It automatically discovers Claude Code, Cursor or Codex sessions when available and groups them together into projects
|
||||
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/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
|
||||
- **Use responsive chat or Claude Code/Cursor CLI/Codex 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 your selected CLI (Claude Code/Cursor/Codex) with WebSocket connection
|
||||
- **Session Management** - Resume previous conversations or start fresh sessions
|
||||
- **Message History** - Complete conversation history with timestamps and metadata
|
||||
- **Multi-format Support** - Text, code blocks, and file references
|
||||
@@ -226,16 +269,16 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Claude CLI │
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Backend (Node.js + Express)
|
||||
- **Express Server** - RESTful API with static file serving
|
||||
- **WebSocket Server** - Communication for chats and project refresh
|
||||
- **CLI Integration (Claude Code / Cursor)** - Process spawning and management
|
||||
- **Session Management** - JSONL parsing and conversation persistence
|
||||
- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management
|
||||
- **File System API** - Exposing file browser for projects
|
||||
|
||||
### Frontend (React + Vite)
|
||||
@@ -282,7 +325,7 @@ We welcome contributions! Please follow these guidelines:
|
||||
#### "No Claude projects found"
|
||||
**Problem**: The UI shows no projects or empty project list
|
||||
**Solutions**:
|
||||
- Ensure [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) is properly installed
|
||||
- Ensure [Claude Code](https://docs.anthropic.com/en/docs/claude-code) is properly installed
|
||||
- Run `claude` command in at least one project directory to initialize
|
||||
- Verify `~/.claude/projects/` directory exists and has proper permissions
|
||||
|
||||
@@ -305,6 +348,8 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
|
||||
### Built With
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[React](https://react.dev/)** - User interface library
|
||||
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
|
||||
@@ -323,5 +368,5 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>Made with care for the Claude Code community.</strong>
|
||||
<strong>Made with care for the Claude Code, Cursor and Codex community.</strong>
|
||||
</div>
|
||||
|
||||
371
README.zh-CN.md
Normal file
@@ -0,0 +1,371 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Cloud CLI (又名 Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
|
||||
|
||||
[English](./README.md) | [中文](./README.zh-CN.md)
|
||||
|
||||
## 截图
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>桌面视图</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
|
||||
<br>
|
||||
<em>显示项目概览和聊天界面的主界面</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>移动端体验</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
|
||||
<br>
|
||||
<em>具有触摸导航的响应式移动设计</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 选择</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>在 Claude Code、Cursor CLI 和 Codex 之间选择</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **响应式设计** - 在桌面、平板和移动设备上无缝运行,您也可以在移动端使用 Claude Code、Cursor 或 Codex
|
||||
- **交互式聊天界面** - 内置聊天界面,与 Claude Code、Cursor 或 Codex 无缝通信
|
||||
- **集成 Shell 终端** - 通过内置 shell 功能直接访问 Claude Code、Cursor CLI 或 Codex
|
||||
- **文件浏览器** - 交互式文件树,支持语法高亮和实时编辑
|
||||
- **Git 浏览器** - 查看、暂存和提交您的更改。您还可以切换分支
|
||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||
- **TaskMaster AI 集成** *(可选)* - 通过 AI 驱动的任务规划、PRD 解析和工作流自动化实现高级项目管理
|
||||
- **模型兼容性** - 适用于 Claude Sonnet 4.5、Opus 4.5 和 GPT-5.2
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- [Node.js](https://nodejs.org/) v20 或更高版本
|
||||
- 已安装并配置 [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code),和/或
|
||||
- 已安装并配置 [Cursor CLI](https://docs.cursor.com/en/cli/overview),和/或
|
||||
- 已安装并配置 [Codex](https://developers.openai.com/codex)
|
||||
|
||||
### 一键操作(推荐)
|
||||
|
||||
无需安装,直接运行:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
服务器将启动并可通过 `http://localhost:3001`(或您配置的 PORT)访问。
|
||||
|
||||
**重启**: 停止服务器后只需再次运行相同的 `npx` 命令
|
||||
|
||||
### 全局安装(供常规使用)
|
||||
|
||||
为了频繁使用,一次性全局安装:
|
||||
|
||||
```bash
|
||||
npm install -g @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
然后使用简单命令启动:
|
||||
|
||||
```bash
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
|
||||
**重启**: 使用 Ctrl+C 停止,然后再次运行 `claude-code-ui`。
|
||||
|
||||
**更新**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### CLI 使用方法
|
||||
|
||||
全局安装后,您可以访问 `claude-code-ui` 和 `cloudcli` 命令:
|
||||
|
||||
| 命令 / 选项 | 简写 | 描述 |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` 或 `claude-code-ui` | | 启动服务器(默认) |
|
||||
| `cloudcli start` | | 显式启动服务器 |
|
||||
| `cloudcli status` | | 显示配置和数据位置 |
|
||||
| `cloudcli update` | | 更新到最新版本 |
|
||||
| `cloudcli help` | | 显示帮助信息 |
|
||||
| `cloudcli version` | | 显示版本信息 |
|
||||
| `--port <port>` | `-p` | 设置服务器端口(默认: 3001) |
|
||||
| `--database-path <path>` | | 设置自定义数据库位置 |
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
cloudcli # 使用默认设置启动
|
||||
cloudcli -p 8080 # 在自定义端口启动
|
||||
cloudcli status # 显示当前配置
|
||||
```
|
||||
|
||||
### 作为后台服务运行(推荐用于生产环境)
|
||||
|
||||
在生产环境中,使用 PM2(Process Manager 2)将 Claude Code UI 作为后台服务运行:
|
||||
|
||||
#### 安装 PM2
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
#### 作为后台服务启动
|
||||
|
||||
```bash
|
||||
# 在后台启动服务器
|
||||
pm2 start claude-code-ui --name "claude-code-ui"
|
||||
|
||||
# 或使用更短的别名
|
||||
pm2 start cloudcli --name "claude-code-ui"
|
||||
|
||||
# 在自定义端口启动
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### 系统启动时自动启动
|
||||
|
||||
要使 Claude Code UI 在系统启动时自动启动:
|
||||
|
||||
```bash
|
||||
# 为您的平台生成启动脚本
|
||||
pm2 startup
|
||||
|
||||
# 保存当前进程列表
|
||||
pm2 save
|
||||
```
|
||||
|
||||
|
||||
### 本地开发安装
|
||||
|
||||
1. **克隆仓库:**
|
||||
```bash
|
||||
git clone https://github.com/siteboon/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
|
||||
2. **安装依赖:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **配置环境:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 使用您喜欢的设置编辑 .env
|
||||
```
|
||||
|
||||
4. **启动应用程序:**
|
||||
```bash
|
||||
# 开发模式(支持热重载)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
应用程序将在您在 .env 中指定的端口启动
|
||||
|
||||
5. **打开浏览器:**
|
||||
- 开发环境: `http://localhost:3001`
|
||||
|
||||
## 安全与工具配置
|
||||
|
||||
**🔒 重要提示**: 所有 Claude Code 工具**默认禁用**。这可以防止潜在的有害操作自动运行。
|
||||
|
||||
### 启用工具
|
||||
|
||||
要使用 Claude Code 的完整功能,您需要手动启用工具:
|
||||
|
||||
1. **打开工具设置** - 点击侧边栏中的齿轮图标
|
||||
3. **选择性启用** - 仅打开您需要的工具
|
||||
4. **应用设置** - 您的偏好设置将保存在本地
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*工具设置界面 - 仅启用您需要的内容*
|
||||
|
||||
</div>
|
||||
|
||||
**推荐方法**: 首先启用基本工具,然后根据需要添加更多。您可以随时调整这些设置。
|
||||
|
||||
## TaskMaster AI 集成 *(可选)*
|
||||
|
||||
Claude Code UI 支持 **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(aka claude-task-master)集成,用于高级项目管理和 AI 驱动的任务规划。
|
||||
|
||||
它提供
|
||||
- 从 PRD(产品需求文档)生成 AI 驱动的任务
|
||||
- 智能任务分解和依赖管理
|
||||
- 可视化任务板和进度跟踪
|
||||
|
||||
**设置与文档**: 访问 [TaskMaster AI GitHub 仓库](https://github.com/eyaltoledano/claude-task-master)获取安装说明、配置指南和使用示例。
|
||||
安装后,您应该能够从设置中启用它
|
||||
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 核心功能
|
||||
|
||||
#### 项目管理
|
||||
当可用时,它会自动发现 Claude Code、Cursor 或 Codex 会话并将它们分组到项目中
|
||||
- **项目操作** - 重命名、删除和组织项目
|
||||
- **智能导航** - 快速访问最近的项目和会话
|
||||
- **MCP 支持** - 通过 UI 添加您自己的 MCP 服务器
|
||||
|
||||
#### 聊天界面
|
||||
- **使用响应式聊天或 Claude Code/Cursor CLI/Codex CLI** - 您可以使用自适应聊天界面或使用 shell 按钮连接到您选择的 CLI
|
||||
- **实时通信** - 通过 WebSocket 连接从您选择的 CLI(Claude Code/Cursor/Codex)流式传输响应
|
||||
- **会话管理** - 恢复之前的对话或启动新会话
|
||||
- **消息历史** - 带有时间戳和元数据的完整对话历史
|
||||
- **多格式支持** - 文本、代码块和文件引用
|
||||
|
||||
#### 文件浏览器与编辑器
|
||||
- **交互式文件树** - 使用展开/折叠导航浏览项目结构
|
||||
- **实时文件编辑** - 直接在界面中读取、修改和保存文件
|
||||
- **语法高亮** - 支持多种编程语言
|
||||
- **文件操作** - 创建、重命名、删除文件和目录
|
||||
|
||||
#### Git 浏览器
|
||||
|
||||
|
||||
#### TaskMaster AI 集成 *(可选)*
|
||||
- **可视化任务板** - 用于管理开发任务的看板风格界面
|
||||
- **PRD 解析器** - 创建产品需求文档并将其解析为结构化任务
|
||||
- **进度跟踪** - 实时状态更新和完成跟踪
|
||||
|
||||
#### 会话管理
|
||||
- **会话持久化** - 所有对话自动保存
|
||||
- **会话组织** - 按项目和 timestamp 分组会话
|
||||
- **会话操作** - 重命名、删除和导出对话历史
|
||||
- **跨设备同步** - 从任何设备访问会话
|
||||
|
||||
### 移动应用
|
||||
- **响应式设计** - 针对所有屏幕尺寸进行优化
|
||||
- **触摸友好界面** - 滑动手势和触摸导航
|
||||
- **移动导航** - 底部选项卡栏,方便拇指导航
|
||||
- **自适应布局** - 可折叠侧边栏和智能内容优先级
|
||||
- **添加到主屏幕快捷方式** - 添加快捷方式到主屏幕,应用程序将像 PWA 一样运行
|
||||
|
||||
## 架构
|
||||
|
||||
### 系统概览
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 后端 (Node.js + Express)
|
||||
- **Express 服务器** - 具有静态文件服务的 RESTful API
|
||||
- **WebSocket 服务器** - 用于聊天和项目刷新的通信
|
||||
- **Agent 集成 (Claude Code / Cursor CLI / Codex)** - 进程生成和管理
|
||||
- **文件系统 API** - 为项目公开文件浏览器
|
||||
|
||||
### 前端 (React + Vite)
|
||||
- **React 18** - 带有 hooks 的现代组件架构
|
||||
- **CodeMirror** - 具有语法高亮的高级代码编辑器
|
||||
|
||||
|
||||
|
||||
|
||||
### 贡献
|
||||
|
||||
我们欢迎贡献!请遵循以下指南:
|
||||
|
||||
#### 入门
|
||||
1. **Fork** 仓库
|
||||
2. **克隆** 您的 fork: `git clone <your-fork-url>`
|
||||
3. **安装** 依赖: `npm install`
|
||||
4. **创建** 特性分支: `git checkout -b feature/amazing-feature`
|
||||
|
||||
#### 开发流程
|
||||
1. **进行更改**,遵循现有代码风格
|
||||
2. **彻底测试** - 确保所有功能正常工作
|
||||
3. **运行质量检查**: `npm run lint && npm run format`
|
||||
4. **提交**,遵循 [Conventional Commits](https://conventionalcommits.org/)的描述性消息
|
||||
5. **推送** 到您的分支: `git push origin feature/amazing-feature`
|
||||
6. **提交** 拉取请求,包括:
|
||||
- 更改的清晰描述
|
||||
- UI 更改的截图
|
||||
- 适用时的测试结果
|
||||
|
||||
#### 贡献内容
|
||||
- **错误修复** - 帮助我们提高稳定性
|
||||
- **新功能** - 增强功能(先在 issue 中讨论)
|
||||
- **文档** - 改进指南和 API 文档
|
||||
- **UI/UX 改进** - 更好的用户体验
|
||||
- **性能优化** - 让它更快
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题与解决方案
|
||||
|
||||
|
||||
#### "未找到 Claude 项目"
|
||||
**问题**: UI 显示没有项目或项目列表为空
|
||||
**解决方案**:
|
||||
- 确保已正确安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- 至少在一个项目目录中运行 `claude` 命令以进行初始化
|
||||
- 验证 `~/.claude/projects/` 目录存在并具有适当的权限
|
||||
|
||||
#### 文件浏览器问题
|
||||
**问题**: 文件无法加载、权限错误、空目录
|
||||
**解决方案**:
|
||||
- 检查项目目录权限(在终端中使用 `ls -la`)
|
||||
- 验证项目路径存在且可访问
|
||||
- 查看服务器控制台日志以获取详细错误消息
|
||||
- 确保您未尝试访问项目范围之外的系统目录
|
||||
|
||||
|
||||
## 许可证
|
||||
|
||||
GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。
|
||||
|
||||
本项目是开源的,在 GPL v3 许可下可自由使用、修改和分发。
|
||||
|
||||
## 致谢
|
||||
|
||||
### 构建工具
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 的官方 CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 的官方 CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[React](https://react.dev/)** - 用户界面库
|
||||
- **[Vite](https://vitejs.dev/)** - 快速构建工具和开发服务器
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - 实用优先的 CSS 框架
|
||||
- **[CodeMirror](https://codemirror.net/)** - 高级代码编辑器
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(可选)* - AI 驱动的项目管理和任务规划
|
||||
|
||||
## 支持与社区
|
||||
|
||||
### 保持更新
|
||||
- **Star** 此仓库以表示支持
|
||||
- **Watch** 以获取更新和新版本
|
||||
- **Follow** 项目以获取公告
|
||||
|
||||
### 赞助商
|
||||
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- iOS Safari PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Claude UI" />
|
||||
|
||||
|
||||
761
package-lock.json
generated
25
package.json
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.10.4",
|
||||
"version": "1.16.3",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
"bin": {
|
||||
"claude-code-ui": "server/index.js"
|
||||
"claude-code-ui": "server/cli.js",
|
||||
"cloudcli": "server/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"server/",
|
||||
"shared/",
|
||||
"dist/",
|
||||
"README.md"
|
||||
],
|
||||
"homepage": "https://claudecodeui.siteboon.ai",
|
||||
"homepage": "https://cloudcli.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||
@@ -26,18 +28,19 @@
|
||||
"client": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "./release.sh"
|
||||
},
|
||||
"keywords": [
|
||||
"claude coode",
|
||||
"claude code",
|
||||
"ai",
|
||||
"anthropic",
|
||||
"ui",
|
||||
"mobile"
|
||||
],
|
||||
"author": "Claude Code UI Contributors",
|
||||
"license": "MIT",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -48,12 +51,15 @@
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/merge": "^6.11.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@openai/codex-sdk": "^0.75.0",
|
||||
"@replit/codemirror-minimap": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@uiw/react-codemirror": "^4.23.13",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -66,7 +72,10 @@
|
||||
"express": "^4.18.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.1",
|
||||
@@ -75,15 +84,20 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-i18next": "^16.5.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
@@ -95,6 +109,7 @@
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
<span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span>
|
||||
</div>
|
||||
|
||||
<p>Trigger an AI agent (Claude or Cursor) to work on a project.</p>
|
||||
<p>Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.</p>
|
||||
|
||||
<h4>Request Body Parameters</h4>
|
||||
<table>
|
||||
@@ -524,7 +524,7 @@
|
||||
<td><code>provider</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td><code>claude</code> or <code>cursor</code> (default: <code>claude</code>)</td>
|
||||
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>stream</code></td>
|
||||
@@ -536,7 +536,9 @@
|
||||
<td><code>model</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td>Model to use (for Cursor)</td>
|
||||
<td id="model-options-cell">
|
||||
Model identifier for the AI provider (loading from constants...)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>cleanup</code></td>
|
||||
@@ -818,31 +820,51 @@ data: {"type":"done"}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script type="module">
|
||||
// Import model constants
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
|
||||
|
||||
// Dynamic URL replacement
|
||||
const apiUrl = window.location.origin;
|
||||
document.querySelectorAll('.api-url').forEach(el => {
|
||||
el.textContent = apiUrl;
|
||||
});
|
||||
|
||||
// Dynamically populate model documentation
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (modelCell) {
|
||||
const claudeModels = CLAUDE_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
const cursorModels = CURSOR_MODELS.OPTIONS.slice(0, 8).map(m => `<code>${m.value}</code>`).join(', ');
|
||||
const codexModels = CODEX_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
|
||||
modelCell.innerHTML = `
|
||||
Model identifier for the AI provider:<br><br>
|
||||
<strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>
|
||||
<strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>
|
||||
<strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Tab switching
|
||||
function showTab(tabName) {
|
||||
window.showTab = function(tabName) {
|
||||
const parentBlock = event.target.closest('.example-block');
|
||||
if (!parentBlock) return;
|
||||
|
||||
|
||||
parentBlock.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
parentBlock.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
|
||||
const targetTab = parentBlock.querySelector('#' + tabName);
|
||||
if (targetTab) {
|
||||
targetTab.classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Prism.js -->
|
||||
|
||||
3
public/icons/codex-white.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="100 100 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M304.246 295.411V249.828C304.246 245.989 305.687 243.109 309.044 241.191L400.692 188.412C413.167 181.215 428.042 177.858 443.394 177.858C500.971 177.858 537.44 222.482 537.44 269.982C537.44 273.34 537.44 277.179 536.959 281.018L441.954 225.358C436.197 222 430.437 222 424.68 225.358L304.246 295.411ZM518.245 472.945V364.024C518.245 357.304 515.364 352.507 509.608 349.149L389.174 279.096L428.519 256.543C431.877 254.626 434.757 254.626 438.115 256.543L529.762 309.323C556.154 324.679 573.905 357.304 573.905 388.971C573.905 425.436 552.315 459.024 518.245 472.941V472.945ZM275.937 376.982L236.592 353.952C233.235 352.034 231.794 349.154 231.794 345.315V239.756C231.794 188.416 271.139 149.548 324.4 149.548C344.555 149.548 363.264 156.268 379.102 168.262L284.578 222.964C278.822 226.321 275.942 231.119 275.942 237.838V376.986L275.937 376.982ZM360.626 425.922L304.246 394.255V327.083L360.626 295.416L417.002 327.083V394.255L360.626 425.922ZM396.852 571.789C376.698 571.789 357.989 565.07 342.151 553.075L436.674 498.374C442.431 495.017 445.311 490.219 445.311 483.499V344.352L485.138 367.382C488.495 369.299 489.936 372.179 489.936 376.018V481.577C489.936 532.917 450.109 571.785 396.852 571.785V571.789ZM283.134 464.79L191.486 412.01C165.094 396.654 147.343 364.029 147.343 332.362C147.343 295.416 169.415 262.309 203.48 248.393V357.791C203.48 364.51 206.361 369.308 212.117 372.665L332.074 442.237L292.729 464.79C289.372 466.707 286.491 466.707 283.134 464.79ZM277.859 543.48C223.639 543.48 183.813 502.695 183.813 452.314C183.813 448.475 184.294 444.636 184.771 440.797L279.295 495.498C285.051 498.856 290.812 498.856 296.568 495.498L417.002 425.927V471.509C417.002 475.349 415.562 478.229 412.204 480.146L320.557 532.926C308.081 540.122 293.206 543.48 277.854 543.48H277.859ZM396.852 600.576C454.911 600.576 503.37 559.313 514.41 504.612C568.149 490.696 602.696 440.315 602.696 388.976C602.696 355.387 588.303 322.762 562.392 299.25C564.791 289.173 566.231 279.096 566.231 269.024C566.231 200.411 510.571 149.067 446.274 149.067C433.322 149.067 420.846 150.984 408.37 155.305C386.775 134.192 357.026 120.758 324.4 120.758C266.342 120.758 217.883 162.02 206.843 216.721C153.104 230.637 118.557 281.018 118.557 332.357C118.557 365.946 132.95 398.571 158.861 422.083C156.462 432.16 155.022 442.237 155.022 452.309C155.022 520.922 210.682 572.266 274.978 572.266C287.931 572.266 300.407 570.349 312.883 566.028C334.473 587.141 364.222 600.576 396.852 600.576Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
3
public/icons/codex.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="100 100 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M304.246 294.611V249.028C304.246 245.189 305.687 242.309 309.044 240.392L400.692 187.612C413.167 180.415 428.042 177.058 443.394 177.058C500.971 177.058 537.44 221.682 537.44 269.182C537.44 272.54 537.44 276.379 536.959 280.218L441.954 224.558C436.197 221.201 430.437 221.201 424.68 224.558L304.246 294.611ZM518.245 472.145V363.224C518.245 356.505 515.364 351.707 509.608 348.349L389.174 278.296L428.519 255.743C431.877 253.826 434.757 253.826 438.115 255.743L529.762 308.523C556.154 323.879 573.905 356.505 573.905 388.171C573.905 424.636 552.315 458.225 518.245 472.141V472.145ZM275.937 376.182L236.592 353.152C233.235 351.235 231.794 348.354 231.794 344.515V238.956C231.794 187.617 271.139 148.749 324.4 148.749C344.555 148.749 363.264 155.468 379.102 167.463L284.578 222.164C278.822 225.521 275.942 230.319 275.942 237.039V376.186L275.937 376.182ZM360.626 425.122L304.246 393.455V326.283L360.626 294.616L417.002 326.283V393.455L360.626 425.122ZM396.852 570.989C376.698 570.989 357.989 564.27 342.151 552.276L436.674 497.574C442.431 494.217 445.311 489.419 445.311 482.699V343.552L485.138 366.582C488.495 368.499 489.936 371.379 489.936 375.219V480.778C489.936 532.117 450.109 570.985 396.852 570.985V570.989ZM283.134 463.99L191.486 411.211C165.094 395.854 147.343 363.229 147.343 331.562C147.343 294.616 169.415 261.509 203.48 247.593V356.991C203.48 363.71 206.361 368.508 212.117 371.866L332.074 441.437L292.729 463.99C289.372 465.907 286.491 465.907 283.134 463.99ZM277.859 542.68C223.639 542.68 183.813 501.895 183.813 451.514C183.813 447.675 184.294 443.836 184.771 439.997L279.295 494.698C285.051 498.056 290.812 498.056 296.568 494.698L417.002 425.127V470.71C417.002 474.549 415.562 477.429 412.204 479.346L320.557 532.126C308.081 539.323 293.206 542.68 277.854 542.68H277.859ZM396.852 599.776C454.911 599.776 503.37 558.513 514.41 503.812C568.149 489.896 602.696 439.515 602.696 388.176C602.696 354.587 588.303 321.962 562.392 298.45C564.791 288.373 566.231 278.296 566.231 268.224C566.231 199.611 510.571 148.267 446.274 148.267C433.322 148.267 420.846 150.184 408.37 154.505C386.775 133.392 357.026 119.958 324.4 119.958C266.342 119.958 217.883 161.22 206.843 215.921C153.104 229.837 118.557 280.218 118.557 331.557C118.557 365.146 132.95 397.771 158.861 421.283C156.462 431.36 155.022 441.437 155.022 451.51C155.022 520.123 210.682 571.466 274.978 571.466C287.931 571.466 300.407 569.549 312.883 565.228C334.473 586.341 364.222 599.776 396.852 599.776Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
12
public/icons/cursor-white.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
|
||||
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #edecec;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
BIN
public/logo-128.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/logo-256.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/logo-32.png
Normal file
|
After Width: | Height: | Size: 496 B |
BIN
public/logo-512.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
public/logo-64.png
Normal file
|
After Width: | Height: | Size: 870 B |
@@ -1,9 +1,17 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="hsl(262.1 83.3% 57.8%)"/>
|
||||
<path d="M8 9C8 8.44772 8.44772 8 9 8H23C23.5523 8 24 8.44772 24 9V18C24 18.5523 23.5523 19 23 19H12L8 23V9Z"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<rect width="32" height="32" rx="8" fill="hsl(221.2 83.2% 53.3%)"/>
|
||||
<path
|
||||
d="M8 9C8 8.44772 8.44772 8 9 8H23C23.5523 8 24 8.44772 24 9V18C24 18.5523 23.5523 19 23 19H12L8 23V9Z"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 413 B |
@@ -13,12 +13,134 @@
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
// Used to mint unique approval request IDs when randomUUID is not available.
|
||||
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
|
||||
// Session tracking: Map of session IDs to active query instances
|
||||
const activeSessions = new Map();
|
||||
// In-memory registry of pending tool approvals keyed by requestId.
|
||||
// This does not persist approvals or share across processes; it exists so the
|
||||
// SDK can pause tool execution while the UI decides what to do.
|
||||
const pendingToolApprovals = new Map();
|
||||
|
||||
// Default approval timeout kept under the SDK's 60s control timeout.
|
||||
// This does not change SDK limits; it only defines how long we wait for the UI,
|
||||
// introduced to avoid hanging the run when no decision arrives.
|
||||
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||
|
||||
// Generate a stable request ID for UI approval flows.
|
||||
// This does not encode tool details or get shown to users; it exists so the UI
|
||||
// can respond to the correct pending request without collisions.
|
||||
function createRequestId() {
|
||||
// if clause is used because randomUUID is not available in older Node.js versions
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
// Wait for a UI approval decision, honoring SDK cancellation.
|
||||
// This does not auto-approve or auto-deny; it only resolves with UI input,
|
||||
// and it cleans up the pending map to avoid leaks, introduced to prevent
|
||||
// replying after the SDK cancels the control request.
|
||||
function waitForToolApproval(requestId, options = {}) {
|
||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
|
||||
const finalize = (decision) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(decision);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
pendingToolApprovals.delete(requestId);
|
||||
clearTimeout(timeout);
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
};
|
||||
|
||||
// Timeout is local to this process; it does not override SDK timing.
|
||||
// It exists to prevent the UI prompt from lingering indefinitely.
|
||||
const timeout = setTimeout(() => {
|
||||
onCancel?.('timeout');
|
||||
finalize(null);
|
||||
}, timeoutMs);
|
||||
|
||||
const abortHandler = () => {
|
||||
// If the SDK cancels the control request, stop waiting to avoid
|
||||
// replying after the process is no longer ready for writes.
|
||||
onCancel?.('cancelled');
|
||||
finalize({ cancelled: true });
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onCancel?.('cancelled');
|
||||
finalize({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
pendingToolApprovals.set(requestId, (decision) => {
|
||||
finalize(decision);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve a pending approval. This does not validate the decision payload;
|
||||
// validation and tool matching remain in canUseTool, which keeps this as a
|
||||
// lightweight WebSocket -> SDK relay.
|
||||
function resolveToolApproval(requestId, decision) {
|
||||
const resolver = pendingToolApprovals.get(requestId);
|
||||
if (resolver) {
|
||||
resolver(decision);
|
||||
}
|
||||
}
|
||||
|
||||
// Match stored permission entries against a tool + input combo.
|
||||
// This only supports exact tool names and the Bash(command:*) shorthand
|
||||
// used by the UI; it intentionally does not implement full glob semantics,
|
||||
// introduced to stay consistent with the UI's "Allow rule" format.
|
||||
function matchesToolPermission(entry, toolName, input) {
|
||||
if (!entry || !toolName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry === toolName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
|
||||
if (toolName === 'Bash' && bashMatch) {
|
||||
const allowedPrefix = bashMatch[1];
|
||||
let command = '';
|
||||
|
||||
if (typeof input === 'string') {
|
||||
command = input.trim();
|
||||
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
|
||||
command = input.command.trim();
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return command.startsWith(allowedPrefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CLI options to SDK-compatible options format
|
||||
@@ -51,33 +173,43 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
if (settings.skipPermissions && permissionMode !== 'plan') {
|
||||
// When skipping permissions, use bypassPermissions mode
|
||||
sdkOptions.permissionMode = 'bypassPermissions';
|
||||
} else {
|
||||
// Map allowed tools
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
}
|
||||
|
||||
// Add plan mode default tools
|
||||
if (permissionMode === 'plan') {
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
|
||||
for (const tool of planModeTools) {
|
||||
if (!allowedTools.includes(tool)) {
|
||||
allowedTools.push(tool);
|
||||
}
|
||||
// Map allowed tools (always set to avoid implicit "allow all" defaults).
|
||||
// This does not grant permissions by itself; it just configures the SDK,
|
||||
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
|
||||
// Add plan mode default tools
|
||||
if (permissionMode === 'plan') {
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
|
||||
for (const tool of planModeTools) {
|
||||
if (!allowedTools.includes(tool)) {
|
||||
allowedTools.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedTools.length > 0) {
|
||||
sdkOptions.allowedTools = allowedTools;
|
||||
}
|
||||
|
||||
// Map disallowed tools
|
||||
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
|
||||
sdkOptions.disallowedTools = settings.disallowedTools;
|
||||
}
|
||||
}
|
||||
|
||||
sdkOptions.allowedTools = allowedTools;
|
||||
|
||||
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
|
||||
// This does not override allowlists; it only feeds the canUseTool gate.
|
||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||
|
||||
// Map model (default to sonnet)
|
||||
// Map model (default to sonnet)
|
||||
sdkOptions.model = options.model || 'sonnet';
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||
console.log(`Using model: ${sdkOptions.model}`);
|
||||
|
||||
// Map system prompt configuration
|
||||
sdkOptions.systemPrompt = {
|
||||
type: 'preset',
|
||||
preset: 'claude_code' // Required to use CLAUDE.md
|
||||
};
|
||||
|
||||
// Map setting sources for CLAUDE.md loading
|
||||
// This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
|
||||
sdkOptions.settingSources = ['project', 'user', 'local'];
|
||||
|
||||
// Map resume session
|
||||
if (sessionId) {
|
||||
@@ -173,7 +305,7 @@ function extractTokenBudget(resultMessage) {
|
||||
// This is the user's budget limit, not the model's context window
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
||||
|
||||
console.log(`📊 Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
|
||||
console.log(`Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
|
||||
|
||||
return {
|
||||
used: totalUsed,
|
||||
@@ -229,7 +361,7 @@ async function handleImages(command, images, cwd) {
|
||||
modifiedCommand = command + imageNote;
|
||||
}
|
||||
|
||||
console.log(`📸 Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
|
||||
console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
|
||||
return { modifiedCommand, tempImagePaths, tempDir };
|
||||
} catch (error) {
|
||||
console.error('Error processing images for SDK:', error);
|
||||
@@ -262,7 +394,7 @@ async function cleanupTempFiles(tempImagePaths, tempDir) {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🧹 Cleaned up ${tempImagePaths.length} temp image files`);
|
||||
console.log(`Cleaned up ${tempImagePaths.length} temp image files`);
|
||||
} catch (error) {
|
||||
console.error('Error during temp file cleanup:', error);
|
||||
}
|
||||
@@ -282,7 +414,7 @@ async function loadMcpConfig(cwd) {
|
||||
await fs.access(claudeConfigPath);
|
||||
} catch (error) {
|
||||
// File doesn't exist, return null
|
||||
console.log('📡 No ~/.claude.json found, proceeding without MCP servers');
|
||||
console.log('No ~/.claude.json found, proceeding without MCP servers');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -292,7 +424,7 @@ async function loadMcpConfig(cwd) {
|
||||
const configContent = await fs.readFile(claudeConfigPath, 'utf8');
|
||||
claudeConfig = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to parse ~/.claude.json:', error.message);
|
||||
console.error('Failed to parse ~/.claude.json:', error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -302,7 +434,7 @@ async function loadMcpConfig(cwd) {
|
||||
// Add global MCP servers
|
||||
if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
|
||||
mcpServers = { ...claudeConfig.mcpServers };
|
||||
console.log(`📡 Loaded ${Object.keys(mcpServers).length} global MCP servers`);
|
||||
console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`);
|
||||
}
|
||||
|
||||
// Add/override with project-specific MCP servers
|
||||
@@ -310,20 +442,20 @@ async function loadMcpConfig(cwd) {
|
||||
const projectConfig = claudeConfig.claudeProjects[cwd];
|
||||
if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
|
||||
mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
|
||||
console.log(`📡 Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`);
|
||||
console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`);
|
||||
}
|
||||
}
|
||||
|
||||
// Return null if no servers found
|
||||
if (Object.keys(mcpServers).length === 0) {
|
||||
console.log('📡 No MCP servers configured');
|
||||
console.log('No MCP servers configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`✅ Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
|
||||
console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
|
||||
return mcpServers;
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading MCP config:', error.message);
|
||||
console.error('Error loading MCP config:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -358,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
tempDir = imageResult.tempDir;
|
||||
|
||||
// Gate tool usage with explicit UI approval when not auto-approved.
|
||||
// This does not render UI or persist permissions; it only bridges to the UI
|
||||
// via WebSocket and waits for the response, introduced so tool calls pause
|
||||
// instead of auto-running when the allowlist is empty.
|
||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
}
|
||||
|
||||
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
||||
matchesToolPermission(entry, toolName, input)
|
||||
);
|
||||
if (isDisallowed) {
|
||||
return { behavior: 'deny', message: 'Tool disallowed by settings' };
|
||||
}
|
||||
|
||||
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
|
||||
matchesToolPermission(entry, toolName, input)
|
||||
);
|
||||
if (isAllowed) {
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
}
|
||||
|
||||
const requestId = createRequestId();
|
||||
ws.send({
|
||||
type: 'claude-permission-request',
|
||||
requestId,
|
||||
toolName,
|
||||
input,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
|
||||
// This does not retry or resurface the prompt; it just reflects the cancellation.
|
||||
const decision = await waitForToolApproval(requestId, {
|
||||
signal: context?.signal,
|
||||
onCancel: (reason) => {
|
||||
ws.send({
|
||||
type: 'claude-permission-cancelled',
|
||||
requestId,
|
||||
reason,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!decision) {
|
||||
return { behavior: 'deny', message: 'Permission request timed out' };
|
||||
}
|
||||
|
||||
if (decision.cancelled) {
|
||||
return { behavior: 'deny', message: 'Permission request cancelled' };
|
||||
}
|
||||
|
||||
if (decision.allow) {
|
||||
// rememberEntry only updates this run's in-memory allowlist to prevent
|
||||
// repeated prompts in the same session; persistence is handled by the UI.
|
||||
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
|
||||
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
|
||||
sdkOptions.allowedTools.push(decision.rememberEntry);
|
||||
}
|
||||
if (Array.isArray(sdkOptions.disallowedTools)) {
|
||||
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
|
||||
}
|
||||
}
|
||||
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
|
||||
}
|
||||
|
||||
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
|
||||
};
|
||||
|
||||
// Create SDK query instance
|
||||
const queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
@@ -370,11 +572,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
// Process streaming messages
|
||||
console.log('🔄 Starting async generator loop for session:', capturedSessionId || 'NEW');
|
||||
console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
|
||||
for await (const message of queryInstance) {
|
||||
// Capture session ID from first message
|
||||
if (message.session_id && !capturedSessionId) {
|
||||
console.log('📝 Captured session ID:', message.session_id);
|
||||
|
||||
capturedSessionId = message.session_id;
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
||||
|
||||
@@ -386,33 +588,35 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
// Send session-created event only once for new sessions
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
console.log('⚠️ Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
|
||||
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
|
||||
console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
|
||||
}
|
||||
|
||||
// Transform and send message to WebSocket
|
||||
const transformedMessage = transformMessage(message);
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: transformedMessage
|
||||
}));
|
||||
data: transformedMessage,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
// Extract and send token budget updates from result messages
|
||||
if (message.type === 'result') {
|
||||
const tokenBudget = extractTokenBudget(message);
|
||||
if (tokenBudget) {
|
||||
console.log('📊 Token budget from modelUsage:', tokenBudget);
|
||||
ws.send(JSON.stringify({
|
||||
console.log('Token budget from modelUsage:', tokenBudget);
|
||||
ws.send({
|
||||
type: 'token-budget',
|
||||
data: tokenBudget
|
||||
}));
|
||||
data: tokenBudget,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,14 +630,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send completion event
|
||||
console.log('✅ Streaming complete, sending claude-complete event');
|
||||
ws.send(JSON.stringify({
|
||||
console.log('Streaming complete, sending claude-complete event');
|
||||
ws.send({
|
||||
type: 'claude-complete',
|
||||
sessionId: capturedSessionId,
|
||||
exitCode: 0,
|
||||
isNewSession: !sessionId && !!command
|
||||
}));
|
||||
console.log('📤 claude-complete event sent');
|
||||
});
|
||||
console.log('claude-complete event sent');
|
||||
|
||||
} catch (error) {
|
||||
console.error('SDK query error:', error);
|
||||
@@ -447,10 +651,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send error to WebSocket
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-error',
|
||||
error: error.message
|
||||
}));
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -470,7 +675,7 @@ async function abortClaudeSDKSession(sessionId) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🛑 Aborting SDK session: ${sessionId}`);
|
||||
console.log(`Aborting SDK session: ${sessionId}`);
|
||||
|
||||
// Call interrupt() on the query instance
|
||||
await session.instance.interrupt();
|
||||
@@ -514,5 +719,6 @@ export {
|
||||
queryClaudeSDK,
|
||||
abortClaudeSDKSession,
|
||||
isClaudeSDKSessionActive,
|
||||
getActiveClaudeSDKSessions
|
||||
getActiveClaudeSDKSessions,
|
||||
resolveToolApproval
|
||||
};
|
||||
|
||||
327
server/cli.js
Executable file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Claude Code UI CLI
|
||||
*
|
||||
* Provides command-line utilities for managing Claude Code UI
|
||||
*
|
||||
* Commands:
|
||||
* (no args) - Start the server (default)
|
||||
* start - Start the server
|
||||
* status - Show configuration and data locations
|
||||
* help - Show help information
|
||||
* version - Show version information
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
|
||||
// Foreground colors
|
||||
cyan: '\x1b[36m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
white: '\x1b[37m',
|
||||
gray: '\x1b[90m',
|
||||
};
|
||||
|
||||
// Helper to colorize text
|
||||
const c = {
|
||||
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
||||
ok: (text) => `${colors.green}${text}${colors.reset}`,
|
||||
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
|
||||
error: (text) => `${colors.yellow}${text}${colors.reset}`,
|
||||
tip: (text) => `${colors.blue}${text}${colors.reset}`,
|
||||
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||
};
|
||||
|
||||
// Load package.json for version info
|
||||
const packageJsonPath = path.join(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
|
||||
// Load environment variables from .env file if it exists
|
||||
function loadEnvFile() {
|
||||
try {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmedLine.split('=');
|
||||
if (key && valueParts.length > 0 && !process.env[key]) {
|
||||
process.env[key] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// .env file is optional
|
||||
}
|
||||
}
|
||||
|
||||
// Get the database path (same logic as db.js)
|
||||
function getDatabasePath() {
|
||||
loadEnvFile();
|
||||
return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db');
|
||||
}
|
||||
|
||||
// Get the installation directory
|
||||
function getInstallDir() {
|
||||
return path.join(__dirname, '..');
|
||||
}
|
||||
|
||||
// Show status command
|
||||
function showStatus() {
|
||||
console.log(`\n${c.bright('Claude Code UI - Status')}\n`);
|
||||
console.log(c.dim('═'.repeat(60)));
|
||||
|
||||
// Version info
|
||||
console.log(`\n${c.info('[INFO]')} Version: ${c.bright(packageJson.version)}`);
|
||||
|
||||
// Installation location
|
||||
const installDir = getInstallDir();
|
||||
console.log(`\n${c.info('[INFO]')} Installation Directory:`);
|
||||
console.log(` ${c.dim(installDir)}`);
|
||||
|
||||
// Database location
|
||||
const dbPath = getDatabasePath();
|
||||
const dbExists = fs.existsSync(dbPath);
|
||||
console.log(`\n${c.info('[INFO]')} Database Location:`);
|
||||
console.log(` ${c.dim(dbPath)}`);
|
||||
console.log(` Status: ${dbExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not created yet (will be created on first run)')}`);
|
||||
|
||||
if (dbExists) {
|
||||
const stats = fs.statSync(dbPath);
|
||||
console.log(` Size: ${c.dim((stats.size / 1024).toFixed(2) + ' KB')}`);
|
||||
console.log(` Modified: ${c.dim(stats.mtime.toLocaleString())}`);
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
console.log(`\n${c.info('[INFO]')} Configuration:`);
|
||||
console.log(` PORT: ${c.bright(process.env.PORT || '3001')} ${c.dim(process.env.PORT ? '' : '(default)')}`);
|
||||
console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);
|
||||
console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);
|
||||
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
|
||||
|
||||
// Claude projects folder
|
||||
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
|
||||
const projectsExists = fs.existsSync(claudeProjectsPath);
|
||||
console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`);
|
||||
console.log(` ${c.dim(claudeProjectsPath)}`);
|
||||
console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`);
|
||||
|
||||
// Config file location
|
||||
const envFilePath = path.join(__dirname, '../.env');
|
||||
const envExists = fs.existsSync(envFilePath);
|
||||
console.log(`\n${c.info('[INFO]')} Configuration File:`);
|
||||
console.log(` ${c.dim(envFilePath)}`);
|
||||
console.log(` Status: ${envExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found (using defaults)')}`);
|
||||
|
||||
console.log('\n' + c.dim('═'.repeat(60)));
|
||||
console.log(`\n${c.tip('[TIP]')} Hints:`);
|
||||
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
|
||||
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
|
||||
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
|
||||
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
|
||||
}
|
||||
|
||||
// Show help
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ Claude Code UI - Command Line Tool ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Usage:
|
||||
claude-code-ui [command] [options]
|
||||
cloudcli [command] [options]
|
||||
|
||||
Commands:
|
||||
start Start the Claude Code UI server (default)
|
||||
status Show configuration and data locations
|
||||
update Update to the latest version
|
||||
help Show this help information
|
||||
version Show version information
|
||||
|
||||
Options:
|
||||
-p, --port <port> Set server port (default: 3001)
|
||||
--database-path <path> Set custom database location
|
||||
-h, --help Show this help information
|
||||
-v, --version Show version information
|
||||
|
||||
Examples:
|
||||
$ cloudcli # Start with defaults
|
||||
$ cloudcli --port 8080 # Start on port 8080
|
||||
$ cloudcli -p 3000 # Short form for port
|
||||
$ cloudcli start --port 4000 # Explicit start command
|
||||
$ cloudcli status # Show configuration
|
||||
|
||||
Environment Variables:
|
||||
PORT Set server port (default: 3001)
|
||||
DATABASE_PATH Set custom database location
|
||||
CLAUDE_CLI_PATH Set custom Claude CLI path
|
||||
CONTEXT_WINDOW Set context window size (default: 160000)
|
||||
|
||||
Documentation:
|
||||
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
|
||||
|
||||
Report Issues:
|
||||
${packageJson.bugs?.url || 'https://github.com/siteboon/claudecodeui/issues'}
|
||||
`);
|
||||
}
|
||||
|
||||
// Show version
|
||||
function showVersion() {
|
||||
console.log(`${packageJson.version}`);
|
||||
}
|
||||
|
||||
// Compare semver versions, returns true if v1 > v2
|
||||
function isNewerVersion(v1, v2) {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (parts1[i] > parts2[i]) return true;
|
||||
if (parts1[i] < parts2[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
async function checkForUpdates(silent = false) {
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
const latestVersion = execSync('npm show @siteboon/claude-code-ui version', { encoding: 'utf8' }).trim();
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
if (isNewerVersion(latestVersion, currentVersion)) {
|
||||
console.log(`\n${c.warn('[UPDATE]')} New version available: ${c.bright(latestVersion)} (current: ${currentVersion})`);
|
||||
console.log(` Run ${c.bright('cloudcli update')} to update\n`);
|
||||
return { hasUpdate: true, latestVersion, currentVersion };
|
||||
} else if (!silent) {
|
||||
console.log(`${c.ok('[OK]')} You are on the latest version (${currentVersion})`);
|
||||
}
|
||||
return { hasUpdate: false, latestVersion, currentVersion };
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
console.log(`${c.warn('[WARN]')} Could not check for updates`);
|
||||
}
|
||||
return { hasUpdate: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Update the package
|
||||
async function updatePackage() {
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
console.log(`${c.info('[INFO]')} Checking for updates...`);
|
||||
|
||||
const { hasUpdate, latestVersion, currentVersion } = await checkForUpdates(true);
|
||||
|
||||
if (!hasUpdate) {
|
||||
console.log(`${c.ok('[OK]')} Already on the latest version (${currentVersion})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`);
|
||||
execSync('npm update -g @siteboon/claude-code-ui', { stdio: 'inherit' });
|
||||
console.log(`${c.ok('[OK]')} Update complete! Restart cloudcli to use the new version.`);
|
||||
} catch (e) {
|
||||
console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`);
|
||||
console.log(`${c.tip('[TIP]')} Try running manually: npm update -g @siteboon/claude-code-ui`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server
|
||||
async function startServer() {
|
||||
// Check for updates silently on startup
|
||||
checkForUpdates(true);
|
||||
|
||||
// Import and run the server
|
||||
await import('./index.js');
|
||||
}
|
||||
|
||||
// Parse CLI arguments
|
||||
function parseArgs(args) {
|
||||
const parsed = { command: 'start', options: {} };
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--port' || arg === '-p') {
|
||||
parsed.options.port = args[++i];
|
||||
} else if (arg.startsWith('--port=')) {
|
||||
parsed.options.port = arg.split('=')[1];
|
||||
} else if (arg === '--database-path') {
|
||||
parsed.options.databasePath = args[++i];
|
||||
} else if (arg.startsWith('--database-path=')) {
|
||||
parsed.options.databasePath = arg.split('=')[1];
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.command = 'help';
|
||||
} else if (arg === '--version' || arg === '-v') {
|
||||
parsed.command = 'version';
|
||||
} else if (!arg.startsWith('-')) {
|
||||
parsed.command = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Main CLI handler
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const { command, options } = parseArgs(args);
|
||||
|
||||
// Apply CLI options to environment variables
|
||||
if (options.port) {
|
||||
process.env.PORT = options.port;
|
||||
}
|
||||
if (options.databasePath) {
|
||||
process.env.DATABASE_PATH = options.databasePath;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'start':
|
||||
await startServer();
|
||||
break;
|
||||
case 'status':
|
||||
case 'info':
|
||||
showStatus();
|
||||
break;
|
||||
case 'help':
|
||||
case '-h':
|
||||
case '--help':
|
||||
showHelp();
|
||||
break;
|
||||
case 'version':
|
||||
case '-v':
|
||||
case '--version':
|
||||
showVersion();
|
||||
break;
|
||||
case 'update':
|
||||
await updatePackage();
|
||||
break;
|
||||
default:
|
||||
console.error(`\n❌ Unknown command: ${command}`);
|
||||
console.log(' Run "cloudcli help" for usage information.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the CLI
|
||||
main().catch(error => {
|
||||
console.error('\n❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
5
server/constants/config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Environment Flag: Is Platform
|
||||
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
|
||||
*/
|
||||
export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||
@@ -102,29 +102,31 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Send session-created event only once for new sessions
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId,
|
||||
model: response.model,
|
||||
cwd: response.cwd
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send system info to frontend
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-system',
|
||||
data: response
|
||||
}));
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
// Forward user message
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-user',
|
||||
data: response
|
||||
}));
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
break;
|
||||
|
||||
case 'assistant':
|
||||
@@ -134,7 +136,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
messageBuffer += textContent;
|
||||
|
||||
// Send as Claude-compatible format for frontend
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
@@ -142,8 +144,9 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
type: 'text_delta',
|
||||
text: textContent
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -153,37 +156,40 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
|
||||
// Send final message if we have buffered content
|
||||
if (messageBuffer) {
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: {
|
||||
type: 'content_block_stop'
|
||||
}
|
||||
}));
|
||||
},
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-result',
|
||||
sessionId: capturedSessionId || sessionId,
|
||||
data: response,
|
||||
success: response.subtype === 'success'
|
||||
}));
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Forward any other message types
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-response',
|
||||
data: response
|
||||
}));
|
||||
data: response,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log('📄 Non-JSON response:', line);
|
||||
// If not JSON, send as raw text
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-output',
|
||||
data: line
|
||||
}));
|
||||
data: line,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -191,10 +197,11 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Handle stderr
|
||||
cursorProcess.stderr.on('data', (data) => {
|
||||
console.error('Cursor CLI stderr:', data.toString());
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: data.toString()
|
||||
}));
|
||||
error: data.toString(),
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
@@ -205,12 +212,12 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'claude-complete',
|
||||
sessionId: finalSessionId,
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
}));
|
||||
});
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
@@ -226,12 +233,13 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: error.message
|
||||
}));
|
||||
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
@@ -264,4 +272,4 @@ export {
|
||||
abortCursorSession,
|
||||
isCursorSessionActive,
|
||||
getActiveCursorSessions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,20 @@ import { dirname } from 'path';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
cyan: '\x1b[36m',
|
||||
dim: '\x1b[2m',
|
||||
};
|
||||
|
||||
const c = {
|
||||
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
||||
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||
};
|
||||
|
||||
// Use DATABASE_PATH environment variable if set, otherwise use default location
|
||||
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
|
||||
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
|
||||
@@ -28,7 +42,45 @@ if (process.env.DATABASE_PATH) {
|
||||
|
||||
// Create database connection
|
||||
const db = new Database(DB_PATH);
|
||||
console.log(`Connected to SQLite database at: ${DB_PATH}`);
|
||||
|
||||
// Show app installation path prominently
|
||||
const appInstallPath = path.join(__dirname, '../..');
|
||||
console.log('');
|
||||
console.log(c.dim('═'.repeat(60)));
|
||||
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
|
||||
console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
|
||||
if (process.env.DATABASE_PATH) {
|
||||
console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
|
||||
}
|
||||
console.log(c.dim('═'.repeat(60)));
|
||||
console.log('');
|
||||
|
||||
const runMigrations = () => {
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
|
||||
const columnNames = tableInfo.map(col => col.name);
|
||||
|
||||
if (!columnNames.includes('git_name')) {
|
||||
console.log('Running migration: Adding git_name column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');
|
||||
}
|
||||
|
||||
if (!columnNames.includes('git_email')) {
|
||||
console.log('Running migration: Adding git_email column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');
|
||||
}
|
||||
|
||||
if (!columnNames.includes('has_completed_onboarding')) {
|
||||
console.log('Running migration: Adding has_completed_onboarding column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||
}
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database with schema
|
||||
const initializeDatabase = async () => {
|
||||
@@ -36,6 +88,7 @@ const initializeDatabase = async () => {
|
||||
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
|
||||
db.exec(initSQL);
|
||||
console.log('Database initialized successfully');
|
||||
runMigrations();
|
||||
} catch (error) {
|
||||
console.error('Error initializing database:', error.message);
|
||||
throw error;
|
||||
@@ -92,6 +145,51 @@ const userDb = {
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getFirstUser: () => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
updateGitConfig: (userId, gitName, gitEmail) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');
|
||||
stmt.run(gitName, gitEmail, userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getGitConfig: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
completeOnboarding: (userId) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');
|
||||
stmt.run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
hasCompletedOnboarding: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);
|
||||
return row?.has_completed_onboarding === 1;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
git_name TEXT,
|
||||
git_email TEXT,
|
||||
has_completed_onboarding BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
|
||||
604
server/index.js
24
server/load-env.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Load environment variables from .env before other imports execute.
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
try {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmedLine.split('=');
|
||||
if (key && valueParts.length > 0 && !process.env[key]) {
|
||||
process.env[key] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
// Get JWT secret from environment or use default (for development)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
||||
@@ -20,8 +21,29 @@ const validateApiKey = (req, res, next) => {
|
||||
|
||||
// JWT authentication middleware
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
// Platform mode: use single database user
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (!user) {
|
||||
return res.status(500).json({ error: 'Platform mode: No user found in database' });
|
||||
}
|
||||
req.user = user;
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('Platform mode error:', error);
|
||||
return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
|
||||
}
|
||||
}
|
||||
|
||||
// Normal OSS JWT validation
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
// Also check query param for SSE endpoints (EventSource can't set headers)
|
||||
if (!token && req.query.token) {
|
||||
token = req.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access denied. No token provided.' });
|
||||
@@ -29,13 +51,13 @@ const authenticateToken = async (req, res, next) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
|
||||
// Verify user still exists and is active
|
||||
const user = userDb.getUserById(decoded.userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
||||
}
|
||||
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -58,10 +80,25 @@ const generateToken = (user) => {
|
||||
|
||||
// WebSocket authentication function
|
||||
const authenticateWebSocket = (token) => {
|
||||
// Platform mode: bypass token validation, return first user
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (user) {
|
||||
return { userId: user.id, username: user.username };
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Platform mode WebSocket error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal OSS JWT validation
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return decoded;
|
||||
|
||||
390
server/openai-codex.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* OpenAI Codex SDK Integration
|
||||
* =============================
|
||||
*
|
||||
* This module provides integration with the OpenAI Codex SDK for non-interactive
|
||||
* chat sessions. It mirrors the pattern used in claude-sdk.js for consistency.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* - queryCodex(command, options, ws) - Execute a prompt with streaming via WebSocket
|
||||
* - abortCodexSession(sessionId) - Cancel an active session
|
||||
* - isCodexSessionActive(sessionId) - Check if a session is running
|
||||
* - getActiveCodexSessions() - List all active sessions
|
||||
*/
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
// Track active sessions
|
||||
const activeCodexSessions = new Map();
|
||||
|
||||
/**
|
||||
* Transform Codex SDK event to WebSocket message format
|
||||
* @param {object} event - SDK event
|
||||
* @returns {object} - Transformed event for WebSocket
|
||||
*/
|
||||
function transformCodexEvent(event) {
|
||||
// Map SDK event types to a consistent format
|
||||
switch (event.type) {
|
||||
case 'item.started':
|
||||
case 'item.updated':
|
||||
case 'item.completed':
|
||||
const item = event.item;
|
||||
if (!item) {
|
||||
return { type: event.type, item: null };
|
||||
}
|
||||
|
||||
// Transform based on item type
|
||||
switch (item.type) {
|
||||
case 'agent_message':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'agent_message',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: item.text
|
||||
}
|
||||
};
|
||||
|
||||
case 'reasoning':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'reasoning',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: item.text,
|
||||
isReasoning: true
|
||||
}
|
||||
};
|
||||
|
||||
case 'command_execution':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'command_execution',
|
||||
command: item.command,
|
||||
output: item.aggregated_output,
|
||||
exitCode: item.exit_code,
|
||||
status: item.status
|
||||
};
|
||||
|
||||
case 'file_change':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'file_change',
|
||||
changes: item.changes,
|
||||
status: item.status
|
||||
};
|
||||
|
||||
case 'mcp_tool_call':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'mcp_tool_call',
|
||||
server: item.server,
|
||||
tool: item.tool,
|
||||
arguments: item.arguments,
|
||||
result: item.result,
|
||||
error: item.error,
|
||||
status: item.status
|
||||
};
|
||||
|
||||
case 'web_search':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'web_search',
|
||||
query: item.query
|
||||
};
|
||||
|
||||
case 'todo_list':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'todo_list',
|
||||
items: item.items
|
||||
};
|
||||
|
||||
case 'error':
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: 'error',
|
||||
message: {
|
||||
role: 'error',
|
||||
content: item.message
|
||||
}
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'item',
|
||||
itemType: item.type,
|
||||
item: item
|
||||
};
|
||||
}
|
||||
|
||||
case 'turn.started':
|
||||
return {
|
||||
type: 'turn_started'
|
||||
};
|
||||
|
||||
case 'turn.completed':
|
||||
return {
|
||||
type: 'turn_complete',
|
||||
usage: event.usage
|
||||
};
|
||||
|
||||
case 'turn.failed':
|
||||
return {
|
||||
type: 'turn_failed',
|
||||
error: event.error
|
||||
};
|
||||
|
||||
case 'thread.started':
|
||||
return {
|
||||
type: 'thread_started',
|
||||
threadId: event.id
|
||||
};
|
||||
|
||||
case 'error':
|
||||
return {
|
||||
type: 'error',
|
||||
message: event.message
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: event.type,
|
||||
data: event
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map permission mode to Codex SDK options
|
||||
* @param {string} permissionMode - 'default', 'acceptEdits', or 'bypassPermissions'
|
||||
* @returns {object} - { sandboxMode, approvalPolicy }
|
||||
*/
|
||||
function mapPermissionModeToCodexOptions(permissionMode) {
|
||||
switch (permissionMode) {
|
||||
case 'acceptEdits':
|
||||
return {
|
||||
sandboxMode: 'workspace-write',
|
||||
approvalPolicy: 'never'
|
||||
};
|
||||
case 'bypassPermissions':
|
||||
return {
|
||||
sandboxMode: 'danger-full-access',
|
||||
approvalPolicy: 'never'
|
||||
};
|
||||
case 'default':
|
||||
default:
|
||||
return {
|
||||
sandboxMode: 'workspace-write',
|
||||
approvalPolicy: 'untrusted'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Codex query with streaming
|
||||
* @param {string} command - The prompt to send
|
||||
* @param {object} options - Options including cwd, sessionId, model, permissionMode
|
||||
* @param {WebSocket|object} ws - WebSocket connection or response writer
|
||||
*/
|
||||
export async function queryCodex(command, options = {}, ws) {
|
||||
const {
|
||||
sessionId,
|
||||
cwd,
|
||||
projectPath,
|
||||
model,
|
||||
permissionMode = 'default'
|
||||
} = options;
|
||||
|
||||
const workingDirectory = cwd || projectPath || process.cwd();
|
||||
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
|
||||
|
||||
let codex;
|
||||
let thread;
|
||||
let currentSessionId = sessionId;
|
||||
|
||||
try {
|
||||
// Initialize Codex SDK
|
||||
codex = new Codex();
|
||||
|
||||
// Thread options with sandbox and approval settings
|
||||
const threadOptions = {
|
||||
workingDirectory,
|
||||
skipGitRepoCheck: true,
|
||||
sandboxMode,
|
||||
approvalPolicy,
|
||||
model
|
||||
};
|
||||
|
||||
// Start or resume thread
|
||||
if (sessionId) {
|
||||
thread = codex.resumeThread(sessionId, threadOptions);
|
||||
} else {
|
||||
thread = codex.startThread(threadOptions);
|
||||
}
|
||||
|
||||
// Get the thread ID
|
||||
currentSessionId = thread.id || sessionId || `codex-${Date.now()}`;
|
||||
|
||||
// Track the session
|
||||
activeCodexSessions.set(currentSessionId, {
|
||||
thread,
|
||||
codex,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Send session created event
|
||||
sendMessage(ws, {
|
||||
type: 'session-created',
|
||||
sessionId: currentSessionId,
|
||||
provider: 'codex'
|
||||
});
|
||||
|
||||
// Execute with streaming
|
||||
const streamedTurn = await thread.runStreamed(command);
|
||||
|
||||
for await (const event of streamedTurn.events) {
|
||||
// Check if session was aborted
|
||||
const session = activeCodexSessions.get(currentSessionId);
|
||||
if (!session || session.status === 'aborted') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.type === 'item.started' || event.type === 'item.updated') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transformed = transformCodexEvent(event);
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'codex-response',
|
||||
data: transformed,
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
|
||||
// Extract and send token usage if available (normalized to match Claude format)
|
||||
if (event.type === 'turn.completed' && event.usage) {
|
||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
||||
sendMessage(ws, {
|
||||
type: 'token-budget',
|
||||
data: {
|
||||
used: totalTokens,
|
||||
total: 200000 // Default context window for Codex models
|
||||
},
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
sendMessage(ws, {
|
||||
type: 'codex-complete',
|
||||
sessionId: currentSessionId,
|
||||
actualSessionId: thread.id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Codex] Error:', error);
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'codex-error',
|
||||
error: error.message,
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
|
||||
} finally {
|
||||
// Update session status
|
||||
if (currentSessionId) {
|
||||
const session = activeCodexSessions.get(currentSessionId);
|
||||
if (session) {
|
||||
session.status = 'completed';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an active Codex session
|
||||
* @param {string} sessionId - Session ID to abort
|
||||
* @returns {boolean} - Whether abort was successful
|
||||
*/
|
||||
export function abortCodexSession(sessionId) {
|
||||
const session = activeCodexSessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.status = 'aborted';
|
||||
|
||||
// The SDK doesn't have a direct abort method, but marking status
|
||||
// will cause the streaming loop to exit
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is active
|
||||
* @param {string} sessionId - Session ID to check
|
||||
* @returns {boolean} - Whether session is active
|
||||
*/
|
||||
export function isCodexSessionActive(sessionId) {
|
||||
const session = activeCodexSessions.get(sessionId);
|
||||
return session?.status === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
* @returns {Array} - Array of active session info
|
||||
*/
|
||||
export function getActiveCodexSessions() {
|
||||
const sessions = [];
|
||||
|
||||
for (const [id, session] of activeCodexSessions.entries()) {
|
||||
if (session.status === 'running') {
|
||||
sessions.push({
|
||||
id,
|
||||
status: session.status,
|
||||
startedAt: session.startedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to send message via WebSocket or writer
|
||||
* @param {WebSocket|object} ws - WebSocket or response writer
|
||||
* @param {object} data - Data to send
|
||||
*/
|
||||
function sendMessage(ws, data) {
|
||||
try {
|
||||
if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
|
||||
// Writer handles stringification (SSEStreamWriter or WebSocketWriter)
|
||||
ws.send(data);
|
||||
} else if (typeof ws.send === 'function') {
|
||||
// Raw WebSocket - stringify here
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Codex] Error sending message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old completed sessions periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const maxAge = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
for (const [id, session] of activeCodexSessions.entries()) {
|
||||
if (session.status !== 'running') {
|
||||
const startedAt = new Date(session.startedAt).getTime();
|
||||
if (now - startedAt > maxAge) {
|
||||
activeCodexSessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000); // Every 5 minutes
|
||||
@@ -204,7 +204,7 @@ function clearProjectDirectoryCache() {
|
||||
|
||||
// Load project configuration file
|
||||
async function loadProjectConfig() {
|
||||
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
|
||||
const configPath = path.join(os.homedir(), '.claude', 'project-config.json');
|
||||
try {
|
||||
const configData = await fs.readFile(configPath, 'utf8');
|
||||
return JSON.parse(configData);
|
||||
@@ -216,7 +216,7 @@ async function loadProjectConfig() {
|
||||
|
||||
// Save project configuration file
|
||||
async function saveProjectConfig(config) {
|
||||
const claudeDir = path.join(process.env.HOME, '.claude');
|
||||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
const configPath = path.join(claudeDir, 'project-config.json');
|
||||
|
||||
// Ensure the .claude directory exists
|
||||
@@ -266,9 +266,17 @@ async function extractProjectDirectory(projectName) {
|
||||
if (projectDirectoryCache.has(projectName)) {
|
||||
return projectDirectoryCache.get(projectName);
|
||||
}
|
||||
|
||||
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
// Check project config for originalPath (manually added projects via UI or platform)
|
||||
// This handles projects with dashes in their directory names correctly
|
||||
const config = await loadProjectConfig();
|
||||
if (config[projectName]?.originalPath) {
|
||||
const originalPath = config[projectName].originalPath;
|
||||
projectDirectoryCache.set(projectName, originalPath);
|
||||
return originalPath;
|
||||
}
|
||||
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
const cwdCounts = new Map();
|
||||
let latestTimestamp = 0;
|
||||
let latestCwd = null;
|
||||
@@ -371,22 +379,46 @@ async function extractProjectDirectory(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjects() {
|
||||
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
|
||||
async function getProjects(progressCallback = null) {
|
||||
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
||||
const config = await loadProjectConfig();
|
||||
const projects = [];
|
||||
const existingProjects = new Set();
|
||||
|
||||
let totalProjects = 0;
|
||||
let processedProjects = 0;
|
||||
let directories = [];
|
||||
|
||||
try {
|
||||
// Check if the .claude/projects directory exists
|
||||
await fs.access(claudeDir);
|
||||
|
||||
|
||||
// First, get existing Claude projects from the file system
|
||||
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
existingProjects.add(entry.name);
|
||||
directories = entries.filter(e => e.isDirectory());
|
||||
|
||||
// Build set of existing project names for later
|
||||
directories.forEach(e => existingProjects.add(e.name));
|
||||
|
||||
// Count manual projects not already in directories
|
||||
const manualProjectsCount = Object.entries(config)
|
||||
.filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name))
|
||||
.length;
|
||||
|
||||
totalProjects = directories.length + manualProjectsCount;
|
||||
|
||||
for (const entry of directories) {
|
||||
processedProjects++;
|
||||
|
||||
// Emit progress
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'loading',
|
||||
current: processedProjects,
|
||||
total: totalProjects,
|
||||
currentProject: entry.name
|
||||
});
|
||||
}
|
||||
|
||||
const projectPath = path.join(claudeDir, entry.name);
|
||||
|
||||
// Extract actual project directory from JSONL sessions
|
||||
@@ -425,7 +457,15 @@ async function getProjects() {
|
||||
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
|
||||
|
||||
// Also fetch Codex sessions for this project
|
||||
try {
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir);
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
||||
project.codexSessions = [];
|
||||
}
|
||||
|
||||
// Add TaskMaster detection
|
||||
try {
|
||||
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
||||
@@ -444,20 +484,35 @@ async function getProjects() {
|
||||
status: 'error'
|
||||
};
|
||||
}
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
} catch (error) {
|
||||
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('Error reading projects directory:', error);
|
||||
}
|
||||
// Calculate total for manual projects only (no directories exist)
|
||||
totalProjects = Object.entries(config)
|
||||
.filter(([name, cfg]) => cfg.manuallyAdded)
|
||||
.length;
|
||||
}
|
||||
|
||||
// Add manually configured projects that don't exist as folders yet
|
||||
for (const [projectName, projectConfig] of Object.entries(config)) {
|
||||
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
|
||||
processedProjects++;
|
||||
|
||||
// Emit progress for manual projects
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'loading',
|
||||
current: processedProjects,
|
||||
total: totalProjects,
|
||||
currentProject: projectName
|
||||
});
|
||||
}
|
||||
|
||||
// Use the original path if available, otherwise extract from potential sessions
|
||||
let actualProjectDir = projectConfig.originalPath;
|
||||
|
||||
@@ -478,16 +533,24 @@ async function getProjects() {
|
||||
isCustomName: !!projectConfig.displayName,
|
||||
isManuallyAdded: true,
|
||||
sessions: [],
|
||||
cursorSessions: []
|
||||
cursorSessions: [],
|
||||
codexSessions: []
|
||||
};
|
||||
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
// Try to fetch Codex sessions for manual projects too
|
||||
try {
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir);
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
|
||||
// Add TaskMaster detection for manual projects
|
||||
try {
|
||||
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
||||
@@ -517,12 +580,21 @@ async function getProjects() {
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Emit completion after all projects (including manual) are processed
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'complete',
|
||||
current: totalProjects,
|
||||
total: totalProjects
|
||||
});
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
async function getSessions(projectName, limit = 5, offset = 0) {
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
@@ -804,7 +876,7 @@ async function parseJsonlSessions(filePath) {
|
||||
|
||||
// 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);
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
@@ -893,7 +965,7 @@ async function renameProject(projectName, newDisplayName) {
|
||||
|
||||
// Delete a session from a project
|
||||
async function deleteSession(projectName, sessionId) {
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
@@ -954,25 +1026,56 @@ async function isProjectEmpty(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an empty project
|
||||
async function deleteProject(projectName) {
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
// Delete a project (force=true to delete even with sessions)
|
||||
async function deleteProject(projectName, force = false) {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
// First check if the project is empty
|
||||
const isEmpty = await isProjectEmpty(projectName);
|
||||
if (!isEmpty) {
|
||||
if (!isEmpty && !force) {
|
||||
throw new Error('Cannot delete project with existing sessions');
|
||||
}
|
||||
|
||||
// Remove the project directory
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
|
||||
// Remove from project config
|
||||
|
||||
const config = await loadProjectConfig();
|
||||
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
|
||||
|
||||
// Fallback to extractProjectDirectory if projectPath is not in config
|
||||
if (!projectPath) {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
}
|
||||
|
||||
// Remove the project directory (includes all Claude sessions)
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
|
||||
// Delete all Codex sessions associated with this project
|
||||
if (projectPath) {
|
||||
try {
|
||||
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
|
||||
for (const session of codexSessions) {
|
||||
try {
|
||||
await deleteCodexSession(session.id);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete Codex sessions:', err.message);
|
||||
}
|
||||
|
||||
// Delete Cursor sessions directory if it exists
|
||||
try {
|
||||
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
|
||||
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
|
||||
await fs.rm(cursorProjectDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Cursor dir may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from project config
|
||||
delete config[projectName];
|
||||
await saveProjectConfig(config);
|
||||
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting project ${projectName}:`, error);
|
||||
@@ -983,20 +1086,20 @@ async function deleteProject(projectName) {
|
||||
// Add a project manually to the config (without creating folders)
|
||||
async function addProjectManually(projectPath, displayName = null) {
|
||||
const absolutePath = path.resolve(projectPath);
|
||||
|
||||
|
||||
try {
|
||||
// Check if the path exists
|
||||
await fs.access(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(`Path does not exist: ${absolutePath}`);
|
||||
}
|
||||
|
||||
|
||||
// Generate project name (encode path for use as directory name)
|
||||
const projectName = absolutePath.replace(/\//g, '-');
|
||||
|
||||
const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
|
||||
|
||||
// Check if project already exists in config
|
||||
const config = await loadProjectConfig();
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
if (config[projectName]) {
|
||||
throw new Error(`Project already configured for path: ${absolutePath}`);
|
||||
@@ -1004,13 +1107,13 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
|
||||
// Allow adding projects even if the directory exists - this enables tracking
|
||||
// existing Claude Code or Cursor projects in the UI
|
||||
|
||||
|
||||
// Add to config as manually added project
|
||||
config[projectName] = {
|
||||
manuallyAdded: true,
|
||||
originalPath: absolutePath
|
||||
};
|
||||
|
||||
|
||||
if (displayName) {
|
||||
config[projectName].displayName = displayName;
|
||||
}
|
||||
@@ -1141,6 +1244,426 @@ async function getCursorSessions(projectPath) {
|
||||
}
|
||||
|
||||
|
||||
// Fetch Codex sessions for a given project path
|
||||
async function getCodexSessions(projectPath, options = {}) {
|
||||
const { limit = 5 } = options;
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
const sessions = [];
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
await fs.access(codexSessionsDir);
|
||||
} catch (error) {
|
||||
// No Codex sessions directory
|
||||
return [];
|
||||
}
|
||||
|
||||
// Recursively find all .jsonl files in the sessions directory
|
||||
const findJsonlFiles = async (dir) => {
|
||||
const files = [];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await findJsonlFiles(fullPath));
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const jsonlFiles = await findJsonlFiles(codexSessionsDir);
|
||||
|
||||
// Process each file to find sessions matching the project path
|
||||
for (const filePath of jsonlFiles) {
|
||||
try {
|
||||
const sessionData = await parseCodexSessionFile(filePath);
|
||||
|
||||
// Check if this session matches the project path
|
||||
// Handle Windows long paths with \\?\ prefix
|
||||
const sessionCwd = sessionData?.cwd || '';
|
||||
const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
|
||||
const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
|
||||
|
||||
if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
|
||||
sessions.push({
|
||||
id: sessionData.id,
|
||||
summary: sessionData.summary || 'Codex Session',
|
||||
messageCount: sessionData.messageCount || 0,
|
||||
lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
|
||||
cwd: sessionData.cwd,
|
||||
model: sessionData.model,
|
||||
filePath: filePath,
|
||||
provider: 'codex'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort sessions by last activity (newest first)
|
||||
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||
|
||||
// Return limited sessions for performance (0 = unlimited for deletion)
|
||||
return limit > 0 ? sessions.slice(0, limit) : sessions;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a Codex session JSONL file to extract metadata
|
||||
async function parseCodexSessionFile(filePath) {
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
let sessionMeta = null;
|
||||
let lastTimestamp = null;
|
||||
let lastUserMessage = null;
|
||||
let messageCount = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Track timestamp
|
||||
if (entry.timestamp) {
|
||||
lastTimestamp = entry.timestamp;
|
||||
}
|
||||
|
||||
// Extract session metadata
|
||||
if (entry.type === 'session_meta' && entry.payload) {
|
||||
sessionMeta = {
|
||||
id: entry.payload.id,
|
||||
cwd: entry.payload.cwd,
|
||||
model: entry.payload.model || entry.payload.model_provider,
|
||||
timestamp: entry.timestamp,
|
||||
git: entry.payload.git
|
||||
};
|
||||
}
|
||||
|
||||
// Count messages and extract user messages for summary
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||||
messageCount++;
|
||||
if (entry.payload.message) {
|
||||
lastUserMessage = entry.payload.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
|
||||
messageCount++;
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionMeta) {
|
||||
return {
|
||||
...sessionMeta,
|
||||
timestamp: lastTimestamp || sessionMeta.timestamp,
|
||||
summary: lastUserMessage ?
|
||||
(lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) :
|
||||
'Codex Session',
|
||||
messageCount
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing Codex session file:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get messages for a specific Codex session
|
||||
async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
|
||||
// Find the session file by searching for the session ID
|
||||
const findSessionFile = async (dir) => {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const found = await findSessionFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
||||
|
||||
if (!sessionFilePath) {
|
||||
console.warn(`Codex session file not found for session ${sessionId}`);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
let tokenUsage = null;
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
// Helper to extract text from Codex content array
|
||||
const extractText = (content) => {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return content
|
||||
.map(item => {
|
||||
if (item.type === 'input_text' || item.type === 'output_text') {
|
||||
return item.text;
|
||||
}
|
||||
if (item.type === 'text') {
|
||||
return item.text;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Extract token usage from token_count events (keep latest)
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const info = entry.payload.info;
|
||||
if (info.total_token_usage) {
|
||||
tokenUsage = {
|
||||
used: info.total_token_usage.total_tokens || 0,
|
||||
total: info.model_context_window || 200000
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract messages from response_item
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
|
||||
const content = entry.payload.content;
|
||||
const role = entry.payload.role || 'assistant';
|
||||
const textContent = extractText(content);
|
||||
|
||||
// Skip system context messages (environment_context)
|
||||
if (textContent?.includes('<environment_context>')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only add if there's actual content
|
||||
if (textContent?.trim()) {
|
||||
messages.push({
|
||||
type: role === 'user' ? 'user' : 'assistant',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: role,
|
||||
content: textContent
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
|
||||
const summaryText = entry.payload.summary
|
||||
?.map(s => s.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (summaryText?.trim()) {
|
||||
messages.push({
|
||||
type: 'thinking',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: summaryText
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
||||
let toolName = entry.payload.name;
|
||||
let toolInput = entry.payload.arguments;
|
||||
|
||||
// Map Codex tool names to Claude equivalents
|
||||
if (toolName === 'shell_command') {
|
||||
toolName = 'Bash';
|
||||
try {
|
||||
const args = JSON.parse(entry.payload.arguments);
|
||||
toolInput = JSON.stringify({ command: args.command });
|
||||
} catch (e) {
|
||||
// Keep original if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: toolName,
|
||||
toolInput: toolInput,
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
|
||||
const toolName = entry.payload.name || 'custom_tool';
|
||||
const input = entry.payload.input || '';
|
||||
|
||||
if (toolName === 'apply_patch') {
|
||||
// Parse Codex patch format and convert to Claude Edit format
|
||||
const fileMatch = input.match(/\*\*\* Update File: (.+)/);
|
||||
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
|
||||
|
||||
// Extract old and new content from patch
|
||||
const lines = input.split('\n');
|
||||
const oldLines = [];
|
||||
const newLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
oldLines.push(line.substring(1));
|
||||
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
newLines.push(line.substring(1));
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: 'Edit',
|
||||
toolInput: JSON.stringify({
|
||||
file_path: filePath,
|
||||
old_string: oldLines.join('\n'),
|
||||
new_string: newLines.join('\n')
|
||||
}),
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: toolName,
|
||||
toolInput: input,
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output || ''
|
||||
});
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
||||
|
||||
const total = messages.length;
|
||||
|
||||
// Apply pagination if limit is specified
|
||||
if (limit !== null) {
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = messages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage
|
||||
};
|
||||
}
|
||||
|
||||
return { messages, tokenUsage };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCodexSession(sessionId) {
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
|
||||
const findJsonlFiles = async (dir) => {
|
||||
const files = [];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await findJsonlFiles(fullPath));
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
return files;
|
||||
};
|
||||
|
||||
const jsonlFiles = await findJsonlFiles(codexSessionsDir);
|
||||
|
||||
for (const filePath of jsonlFiles) {
|
||||
const sessionData = await parseCodexSessionFile(filePath);
|
||||
if (sessionData && sessionData.id === sessionId) {
|
||||
await fs.unlink(filePath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Codex session file not found for session ${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Codex session ${sessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getProjects,
|
||||
getSessions,
|
||||
@@ -1154,5 +1677,8 @@ export {
|
||||
loadProjectConfig,
|
||||
saveProjectConfig,
|
||||
extractProjectDirectory,
|
||||
clearProjectDirectoryCache
|
||||
clearProjectDirectoryCache,
|
||||
getCodexSessions,
|
||||
getCodexSessionMessages,
|
||||
deleteCodexSession
|
||||
};
|
||||
|
||||
@@ -4,16 +4,46 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import { promises as fs } from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { apiKeysDb, githubTokensDb } from '../database/db.js';
|
||||
import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
|
||||
import { addProjectManually } from '../projects.js';
|
||||
import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
import { queryCodex } from '../openai-codex.js';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Middleware to validate API key for external requests
|
||||
/**
|
||||
* Middleware to authenticate agent API requests.
|
||||
*
|
||||
* Supports two authentication modes:
|
||||
* 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
|
||||
* authentication is handled by an external proxy. Requests are trusted and
|
||||
* the default user context is used.
|
||||
*
|
||||
* 2. API key mode (default): For self-hosted deployments where users authenticate
|
||||
* via API keys created in the UI. Keys are validated against the local database.
|
||||
*/
|
||||
const validateExternalApiKey = (req, res, next) => {
|
||||
// Platform mode: Authentication is handled externally (e.g., by a proxy layer).
|
||||
// Trust the request and use the default user context.
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (!user) {
|
||||
return res.status(500).json({ error: 'Platform mode: No user found in database' });
|
||||
}
|
||||
req.user = user;
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('Platform mode error:', error);
|
||||
return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
|
||||
}
|
||||
}
|
||||
|
||||
// Self-hosted mode: Validate API key from header or query parameter
|
||||
const apiKey = req.headers['x-api-key'] || req.query.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -422,6 +452,7 @@ class SSEStreamWriter {
|
||||
constructor(res) {
|
||||
this.res = res;
|
||||
this.sessionId = null;
|
||||
this.isSSEStreamWriter = true; // Marker for transport detection
|
||||
}
|
||||
|
||||
send(data) {
|
||||
@@ -429,7 +460,7 @@ class SSEStreamWriter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format as SSE
|
||||
// Format as SSE - providers send raw objects, we stringify
|
||||
this.res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
@@ -606,9 +637,14 @@ class ResponseCollector {
|
||||
* - true: Returns text/event-stream with incremental updates
|
||||
* - false: Returns complete JSON response after completion
|
||||
*
|
||||
* @param {string} model - (Optional) Model identifier for Cursor provider.
|
||||
* Only applicable when provider='cursor'.
|
||||
* Examples: 'gpt-4', 'claude-3-opus', etc.
|
||||
* @param {string} model - (Optional) Model identifier for providers.
|
||||
*
|
||||
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
|
||||
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
||||
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
|
||||
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
||||
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
|
||||
* Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
|
||||
*
|
||||
* @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
|
||||
* Default: true
|
||||
@@ -819,8 +855,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
}
|
||||
|
||||
if (!['claude', 'cursor'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
|
||||
if (!['claude', 'cursor', 'codex'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
|
||||
}
|
||||
|
||||
// Validate GitHub branch/PR creation requirements
|
||||
@@ -911,6 +947,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: null, // New session
|
||||
model: model,
|
||||
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
|
||||
}, writer);
|
||||
|
||||
@@ -924,6 +961,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
model: model || undefined,
|
||||
skipPermissions: true // Bypass permissions for Cursor
|
||||
}, writer);
|
||||
} else if (provider === 'codex') {
|
||||
console.log('🤖 Starting Codex SDK session');
|
||||
|
||||
await queryCodex(message.trim(), {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: null,
|
||||
model: model || CODEX_MODELS.DEFAULT,
|
||||
permissionMode: 'bypassPermissions'
|
||||
}, writer);
|
||||
}
|
||||
|
||||
// Handle GitHub branch and PR creation after successful agent completion
|
||||
|
||||
263
server/routes/cli-auth.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/claude/status', async (req, res) => {
|
||||
try {
|
||||
const credentialsResult = await checkClaudeCredentials();
|
||||
|
||||
if (credentialsResult.authenticated) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: 'credentials_file'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Claude auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/cursor/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCursorStatus();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Cursor auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/codex/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCodexCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function checkClaudeCredentials() {
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await fs.readFile(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth && oauth.accessToken) {
|
||||
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
|
||||
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkCursorStatus() {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
if (childProcess) {
|
||||
childProcess.kill();
|
||||
}
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Command timeout'
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
let childProcess;
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (emailMatch) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: emailMatch[1],
|
||||
output: stdout
|
||||
});
|
||||
} else if (stdout.includes('Logged in')) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: 'Logged in',
|
||||
output: stdout
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Not logged in'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: stderr || 'Not logged in'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCodexCredentials() {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await fs.readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content);
|
||||
|
||||
// Tokens are nested under 'tokens' key
|
||||
const tokens = auth.tokens || {};
|
||||
|
||||
// Check for valid tokens (id_token or access_token)
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
// Try to extract email from id_token JWT payload
|
||||
let email = 'Authenticated';
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
// JWT is base64url encoded: header.payload.signature
|
||||
const parts = tokens.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
email = payload.email || payload.user || 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
// If JWT decoding fails, use fallback
|
||||
email = 'Authenticated';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email
|
||||
};
|
||||
}
|
||||
|
||||
// Also check for OPENAI_API_KEY as fallback auth method
|
||||
if (auth.OPENAI_API_KEY) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found'
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Codex not configured'
|
||||
};
|
||||
}
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
344
server/routes/codex.js
Normal file
@@ -0,0 +1,344 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import TOML from '@iarna/toml';
|
||||
import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function createCliResponder(res) {
|
||||
let responded = false;
|
||||
return (status, payload) => {
|
||||
if (responded || res.headersSent) {
|
||||
return;
|
||||
}
|
||||
responded = true;
|
||||
res.status(status).json(payload);
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
const content = await fs.readFile(configPath, 'utf8');
|
||||
const config = TOML.parse(content);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: config.model || null,
|
||||
mcpServers: config.mcp_servers || {},
|
||||
approvalMode: config.approval_mode || 'suggest'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: null,
|
||||
mcpServers: {},
|
||||
approvalMode: 'suggest'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Error reading Codex config:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.query;
|
||||
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
|
||||
}
|
||||
|
||||
const sessions = await getCodexSessions(projectPath);
|
||||
res.json({ success: true, sessions });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions/:sessionId/messages', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const { limit, offset } = req.query;
|
||||
|
||||
const result = await getCodexSessionMessages(
|
||||
sessionId,
|
||||
limit ? parseInt(limit, 10) : null,
|
||||
offset ? parseInt(offset, 10) : 0
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex session messages:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
await deleteCodexSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// MCP Server Management Routes
|
||||
|
||||
router.get('/mcp/cli/list', async (req, res) => {
|
||||
try {
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
|
||||
} else {
|
||||
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/mcp/cli/add', async (req, res) => {
|
||||
try {
|
||||
const { name, command, args = [], env = {} } = req.body;
|
||||
|
||||
if (!name || !command) {
|
||||
return res.status(400).json({ error: 'name and command are required' });
|
||||
}
|
||||
|
||||
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
|
||||
let cliArgs = ['mcp', 'add', name];
|
||||
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
cliArgs.push('-e', `${key}=${value}`);
|
||||
});
|
||||
|
||||
cliArgs.push('--', command);
|
||||
|
||||
if (args && args.length > 0) {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
||||
} else {
|
||||
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/mcp/cli/remove/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
||||
} else {
|
||||
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/cli/get/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
|
||||
} else {
|
||||
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/config/read', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
|
||||
let configData = null;
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(configPath, 'utf8');
|
||||
configData = TOML.parse(fileContent);
|
||||
} catch (error) {
|
||||
// Config file doesn't exist
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return res.json({ success: true, configPath, servers: [] }); }
|
||||
|
||||
const servers = [];
|
||||
|
||||
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
|
||||
for (const [name, config] of Object.entries(configData.mcp_servers)) {
|
||||
servers.push({
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio',
|
||||
scope: 'user',
|
||||
config: {
|
||||
command: config.command || '',
|
||||
args: config.args || [],
|
||||
env: config.env || {}
|
||||
},
|
||||
raw: config
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, configPath, servers });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
function parseCodexListOutput(output) {
|
||||
const servers = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes(':')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
let description = rest;
|
||||
let status = 'unknown';
|
||||
|
||||
if (rest.includes('✓') || rest.includes('✗')) {
|
||||
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
|
||||
if (statusMatch) {
|
||||
description = statusMatch[1].trim();
|
||||
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
servers.push({ name, type: 'stdio', status, description });
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
function parseCodexGetOutput(output) {
|
||||
try {
|
||||
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
const server = { raw_output: output };
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
|
||||
}
|
||||
|
||||
return server;
|
||||
} catch (error) {
|
||||
return { raw_output: output, parse_error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
import matter from 'gray-matter';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -182,23 +183,15 @@ Custom commands can be created in:
|
||||
},
|
||||
|
||||
'/model': async (args, context) => {
|
||||
// Read available models from config or defaults
|
||||
// Read available models from centralized constants
|
||||
const availableModels = {
|
||||
claude: [
|
||||
'claude-sonnet-4.5',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4',
|
||||
'claude-sonnet-3.5'
|
||||
],
|
||||
cursor: [
|
||||
'gpt-5',
|
||||
'sonnet-4',
|
||||
'opus-4.1'
|
||||
]
|
||||
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
|
||||
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
|
||||
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
|
||||
};
|
||||
|
||||
const currentProvider = context?.provider || 'claude';
|
||||
const currentModel = context?.model || 'claude-sonnet-4.5';
|
||||
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
@@ -216,50 +209,6 @@ Custom commands can be created in:
|
||||
};
|
||||
},
|
||||
|
||||
'/cost': async (args, context) => {
|
||||
// Calculate token usage and cost
|
||||
const sessionId = context?.sessionId;
|
||||
const tokenUsage = context?.tokenUsage || { used: 0, total: 200000 };
|
||||
|
||||
const costPerMillion = {
|
||||
'claude-sonnet-4.5': { input: 3, output: 15 },
|
||||
'claude-sonnet-4': { input: 3, output: 15 },
|
||||
'claude-opus-4': { input: 15, output: 75 },
|
||||
'gpt-5': { input: 5, output: 15 }
|
||||
};
|
||||
|
||||
const model = context?.model || 'claude-sonnet-4.5';
|
||||
const rates = costPerMillion[model] || costPerMillion['claude-sonnet-4.5'];
|
||||
|
||||
// Estimate 70% input, 30% output
|
||||
const estimatedInputTokens = Math.floor(tokenUsage.used * 0.7);
|
||||
const estimatedOutputTokens = Math.floor(tokenUsage.used * 0.3);
|
||||
|
||||
const inputCost = (estimatedInputTokens / 1000000) * rates.input;
|
||||
const outputCost = (estimatedOutputTokens / 1000000) * rates.output;
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'cost',
|
||||
data: {
|
||||
tokenUsage: {
|
||||
used: tokenUsage.used,
|
||||
total: tokenUsage.total,
|
||||
percentage: ((tokenUsage.used / tokenUsage.total) * 100).toFixed(1)
|
||||
},
|
||||
cost: {
|
||||
input: inputCost.toFixed(4),
|
||||
output: outputCost.toFixed(4),
|
||||
total: totalCost.toFixed(4),
|
||||
currency: 'USD'
|
||||
},
|
||||
model,
|
||||
rates
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
'/status': async (args, context) => {
|
||||
// Read version from package.json
|
||||
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
|
||||
|
||||
@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import crypto from 'crypto';
|
||||
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -33,7 +34,7 @@ router.get('/config', async (req, res) => {
|
||||
config: {
|
||||
version: 1,
|
||||
model: {
|
||||
modelId: "gpt-5",
|
||||
modelId: CURSOR_MODELS.DEFAULT,
|
||||
displayName: "GPT-5"
|
||||
},
|
||||
permissions: {
|
||||
|
||||
@@ -80,34 +80,47 @@ async function validateGitRepository(projectPath) {
|
||||
// Get git status for a project
|
||||
router.get('/status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
|
||||
// Get current branch - handle case where there are no commits yet
|
||||
let branch = 'main';
|
||||
let hasCommits = true;
|
||||
try {
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
branch = branchOutput.trim();
|
||||
} catch (error) {
|
||||
// No HEAD exists - repository has no commits yet
|
||||
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
|
||||
hasCommits = false;
|
||||
branch = 'main';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get git status
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
|
||||
|
||||
|
||||
const modified = [];
|
||||
const added = [];
|
||||
const deleted = [];
|
||||
const untracked = [];
|
||||
|
||||
|
||||
statusOutput.split('\n').forEach(line => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
|
||||
const status = line.substring(0, 2);
|
||||
const file = line.substring(3);
|
||||
|
||||
|
||||
if (status === 'M ' || status === ' M' || status === 'MM') {
|
||||
modified.push(file);
|
||||
} else if (status === 'A ' || status === 'AM') {
|
||||
@@ -118,9 +131,10 @@ router.get('/status', async (req, res) => {
|
||||
untracked.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
branch: branch.trim(),
|
||||
branch,
|
||||
hasCommits,
|
||||
modified,
|
||||
added,
|
||||
deleted,
|
||||
@@ -128,9 +142,9 @@ router.get('/status', async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git status error:', error);
|
||||
res.json({
|
||||
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
res.json({
|
||||
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
: 'Git operation failed',
|
||||
details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
@@ -161,10 +175,18 @@ router.get('/diff', async (req, res) => {
|
||||
let diff;
|
||||
if (isUntracked) {
|
||||
// For untracked files, show the entire file content as additions
|
||||
const fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
||||
lines.map(line => `+${line}`).join('\n');
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// For directories, show a simple message
|
||||
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
|
||||
} else {
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
||||
lines.map(line => `+${line}`).join('\n');
|
||||
}
|
||||
} else if (isDeleted) {
|
||||
// For deleted files, show the entire file content from HEAD as deletions
|
||||
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
@@ -222,7 +244,15 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
currentContent = headContent; // Show the deleted content in editor
|
||||
} else {
|
||||
// Get current file content
|
||||
currentContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Cannot show content for directories
|
||||
return res.status(400).json({ error: 'Cannot show diff for directories' });
|
||||
}
|
||||
|
||||
currentContent = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
if (!isUntracked) {
|
||||
// Get the old content from HEAD for tracked files
|
||||
@@ -248,6 +278,50 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Create initial commit
|
||||
router.post('/initial-commit', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check if there are already commits
|
||||
try {
|
||||
await execAsync('git rev-parse HEAD', { cwd: projectPath });
|
||||
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
|
||||
} catch (error) {
|
||||
// No HEAD - this is good, we can create initial commit
|
||||
}
|
||||
|
||||
// Add all files
|
||||
await execAsync('git add .', { cwd: projectPath });
|
||||
|
||||
// Create initial commit
|
||||
const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Git initial commit error:', error);
|
||||
|
||||
// Handle the case where there's nothing to commit
|
||||
if (error.message.includes('nothing to commit')) {
|
||||
return res.status(400).json({
|
||||
error: 'Nothing to commit',
|
||||
details: 'No files found in the repository. Add some files first.'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Commit changes
|
||||
router.post('/commit', async (req, res) => {
|
||||
const { project, message, files } = req.body;
|
||||
@@ -474,8 +548,14 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(projectPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||
} else {
|
||||
diffContext += `\n--- ${file} (new directory) ---\n`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error);
|
||||
}
|
||||
@@ -502,16 +582,16 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
*/
|
||||
async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
|
||||
// Create the prompt
|
||||
const prompt = `You are a git commit message generator. Based on the following file changes and diffs, generate a commit message in conventional commit format.
|
||||
const prompt = `Generate a conventional commit message for these changes.
|
||||
|
||||
REQUIREMENTS:
|
||||
- Use conventional commit format: type(scope): subject
|
||||
- Include a body that explains what changed and why
|
||||
- Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
||||
- Keep subject line under 50 characters
|
||||
- Wrap body at 72 characters
|
||||
- Be specific and descriptive
|
||||
- Return ONLY the commit message, nothing else - no markdown, no explanations, no code blocks
|
||||
- Format: type(scope): subject
|
||||
- Include body explaining what changed and why
|
||||
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
||||
- Subject under 50 chars, body wrapped at 72 chars
|
||||
- Focus on user-facing changes, not implementation details
|
||||
- Consider what's being added AND removed
|
||||
- Return ONLY the commit message (no markdown, explanations, or code blocks)
|
||||
|
||||
FILES CHANGED:
|
||||
${files.map(f => `- ${f}`).join('\n')}
|
||||
@@ -519,7 +599,7 @@ ${files.map(f => `- ${f}`).join('\n')}
|
||||
DIFFS:
|
||||
${diffContext.substring(0, 4000)}
|
||||
|
||||
Generate the commit message now:`;
|
||||
Generate the commit message:`;
|
||||
|
||||
try {
|
||||
// Create a simple writer that collects the response
|
||||
@@ -976,10 +1056,17 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
|
||||
const status = statusOutput.substring(0, 2);
|
||||
|
||||
|
||||
if (status === '??') {
|
||||
// Untracked file - delete it
|
||||
await fs.unlink(path.join(projectPath, file));
|
||||
// Untracked file or directory - delete it
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(filePath, { recursive: true, force: true });
|
||||
} else {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} else if (status.includes('M') || status.includes('D')) {
|
||||
// Modified or deleted file - restore from HEAD
|
||||
await execAsync(`git restore "${file}"`, { cwd: projectPath });
|
||||
@@ -1020,10 +1107,18 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
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` });
|
||||
// Delete the untracked file or directory
|
||||
const filePath = path.join(projectPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Use rm with recursive option for directories
|
||||
await fs.rm(filePath, { recursive: true, force: true });
|
||||
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
|
||||
} else {
|
||||
await fs.unlink(filePath);
|
||||
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 });
|
||||
|
||||
550
server/routes/projects.js
Normal file
@@ -0,0 +1,550 @@
|
||||
import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import os from 'os';
|
||||
import { addProjectManually } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sanitizeGitError(message, token) {
|
||||
if (!message || !token) return message;
|
||||
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
|
||||
}
|
||||
|
||||
// Configure allowed workspace root (defaults to user's home directory)
|
||||
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||
|
||||
// System-critical paths that should never be used as workspace directories
|
||||
export const FORBIDDEN_PATHS = [
|
||||
// Unix
|
||||
'/',
|
||||
'/etc',
|
||||
'/bin',
|
||||
'/sbin',
|
||||
'/usr',
|
||||
'/dev',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/var',
|
||||
'/boot',
|
||||
'/root',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/tmp',
|
||||
'/run',
|
||||
// Windows
|
||||
'C:\\Windows',
|
||||
'C:\\Program Files',
|
||||
'C:\\Program Files (x86)',
|
||||
'C:\\ProgramData',
|
||||
'C:\\System Volume Information',
|
||||
'C:\\$Recycle.Bin'
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates that a path is safe for workspace operations
|
||||
* @param {string} requestedPath - The path to validate
|
||||
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
||||
*/
|
||||
export async function validateWorkspacePath(requestedPath) {
|
||||
try {
|
||||
// Resolve to absolute path
|
||||
let absolutePath = path.resolve(requestedPath);
|
||||
|
||||
// Check if path is a forbidden system directory
|
||||
const normalizedPath = path.normalize(absolutePath);
|
||||
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot use system-critical directories as workspace locations'
|
||||
};
|
||||
}
|
||||
|
||||
// Additional check for paths starting with forbidden directories
|
||||
for (const forbidden of FORBIDDEN_PATHS) {
|
||||
if (normalizedPath === forbidden ||
|
||||
normalizedPath.startsWith(forbidden + path.sep)) {
|
||||
// Exception: /var/tmp and similar user-accessible paths might be allowed
|
||||
// but /var itself and most /var subdirectories should be blocked
|
||||
if (forbidden === '/var' &&
|
||||
(normalizedPath.startsWith('/var/tmp') ||
|
||||
normalizedPath.startsWith('/var/folders'))) {
|
||||
continue; // Allow these specific cases
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot create workspace in system directory: ${forbidden}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try to resolve the real path (following symlinks)
|
||||
let realPath;
|
||||
try {
|
||||
// Check if path exists to resolve real path
|
||||
await fs.access(absolutePath);
|
||||
realPath = await fs.realpath(absolutePath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Path doesn't exist yet - check parent directory
|
||||
let parentPath = path.dirname(absolutePath);
|
||||
try {
|
||||
const parentRealPath = await fs.realpath(parentPath);
|
||||
|
||||
// Reconstruct the full path with real parent
|
||||
realPath = path.join(parentRealPath, path.basename(absolutePath));
|
||||
} catch (parentError) {
|
||||
if (parentError.code === 'ENOENT') {
|
||||
// Parent doesn't exist either - use the absolute path as-is
|
||||
// We'll validate it's within allowed root
|
||||
realPath = absolutePath;
|
||||
} else {
|
||||
throw parentError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the workspace root to its real path
|
||||
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
|
||||
|
||||
// Ensure the resolved path is contained within the allowed workspace root
|
||||
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
||||
realPath !== resolvedWorkspaceRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`
|
||||
};
|
||||
}
|
||||
|
||||
// Additional symlink check for existing paths
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
const stats = await fs.lstat(absolutePath);
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
// Verify symlink target is also within allowed root
|
||||
const linkTarget = await fs.readlink(absolutePath);
|
||||
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
|
||||
const realTarget = await fs.realpath(resolvedTarget);
|
||||
|
||||
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
||||
realTarget !== resolvedWorkspaceRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Symlink target is outside the allowed workspace root'
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// Path doesn't exist - that's fine for new workspace creation
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
resolvedPath: realPath
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Path validation failed: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new workspace
|
||||
* POST /api/projects/create-workspace
|
||||
*
|
||||
* Body:
|
||||
* - workspaceType: 'existing' | 'new'
|
||||
* - path: string (workspace path)
|
||||
* - githubUrl?: string (optional, for new workspaces)
|
||||
* - githubTokenId?: number (optional, ID of stored token)
|
||||
* - newGithubToken?: string (optional, one-time token)
|
||||
*/
|
||||
router.post('/create-workspace', async (req, res) => {
|
||||
try {
|
||||
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!workspaceType || !workspacePath) {
|
||||
return res.status(400).json({ error: 'workspaceType and path are required' });
|
||||
}
|
||||
|
||||
if (!['existing', 'new'].includes(workspaceType)) {
|
||||
return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
|
||||
}
|
||||
|
||||
// Validate path safety before any operations
|
||||
const validation = await validateWorkspacePath(workspacePath);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid workspace path',
|
||||
details: validation.error
|
||||
});
|
||||
}
|
||||
|
||||
const absolutePath = validation.resolvedPath;
|
||||
|
||||
// Handle existing workspace
|
||||
if (workspaceType === 'existing') {
|
||||
// Check if the path exists
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path exists but is not a directory' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Workspace path does not exist' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Add the existing workspace to the project list
|
||||
const project = await addProjectManually(absolutePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'Existing workspace added successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle new workspace creation
|
||||
if (workspaceType === 'new') {
|
||||
// Create the directory if it doesn't exist
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
|
||||
// If GitHub URL is provided, clone the repository
|
||||
if (githubUrl) {
|
||||
let githubToken = null;
|
||||
|
||||
// Get GitHub token if needed
|
||||
if (githubTokenId) {
|
||||
// Fetch token from database
|
||||
const token = await getGithubTokenById(githubTokenId, req.user.id);
|
||||
if (!token) {
|
||||
// Clean up created directory
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
return res.status(404).json({ error: 'GitHub token not found' });
|
||||
}
|
||||
githubToken = token.github_token;
|
||||
} else if (newGithubToken) {
|
||||
githubToken = newGithubToken;
|
||||
}
|
||||
|
||||
// Extract repo name from URL for the clone destination
|
||||
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||
const clonePath = path.join(absolutePath, repoName);
|
||||
|
||||
// Check if clone destination already exists to prevent data loss
|
||||
try {
|
||||
await fs.access(clonePath);
|
||||
return res.status(409).json({
|
||||
error: 'Directory already exists',
|
||||
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
|
||||
});
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
// Clone the repository into a subfolder
|
||||
try {
|
||||
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
|
||||
} catch (error) {
|
||||
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
|
||||
try {
|
||||
const stats = await fs.stat(clonePath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(clonePath, { recursive: true, force: true });
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
// Directory doesn't exist or cleanup failed - ignore
|
||||
}
|
||||
throw new Error(`Failed to clone repository: ${error.message}`);
|
||||
}
|
||||
|
||||
// Add the cloned repo path to the project list
|
||||
const project = await addProjectManually(clonePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'New workspace created and repository cloned successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// Add the new workspace to the project list (no clone)
|
||||
const project = await addProjectManually(absolutePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'New workspace created successfully'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace:', error);
|
||||
res.status(500).json({
|
||||
error: error.message || 'Failed to create workspace',
|
||||
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to get GitHub token from database
|
||||
*/
|
||||
async function getGithubTokenById(tokenId, userId) {
|
||||
const { getDatabase } = await import('../database/db.js');
|
||||
const db = await getDatabase();
|
||||
|
||||
const credential = await db.get(
|
||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
|
||||
[tokenId, userId, 'github_token']
|
||||
);
|
||||
|
||||
// Return in the expected format (github_token field for compatibility)
|
||||
if (credential) {
|
||||
return {
|
||||
...credential,
|
||||
github_token: credential.credential_value
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone repository with progress streaming (SSE)
|
||||
* GET /api/projects/clone-progress
|
||||
*/
|
||||
router.get('/clone-progress', async (req, res) => {
|
||||
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendEvent = (type, data) => {
|
||||
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
if (!workspacePath || !githubUrl) {
|
||||
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = await validateWorkspacePath(workspacePath);
|
||||
if (!validation.valid) {
|
||||
sendEvent('error', { message: validation.error });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = validation.resolvedPath;
|
||||
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
|
||||
let githubToken = null;
|
||||
if (githubTokenId) {
|
||||
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
|
||||
if (!token) {
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
sendEvent('error', { message: 'GitHub token not found' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
githubToken = token.github_token;
|
||||
} else if (newGithubToken) {
|
||||
githubToken = newGithubToken;
|
||||
}
|
||||
|
||||
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||
const clonePath = path.join(absolutePath, repoName);
|
||||
|
||||
// Check if clone destination already exists to prevent data loss
|
||||
try {
|
||||
await fs.access(clonePath);
|
||||
sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
|
||||
res.end();
|
||||
return;
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
let cloneUrl = githubUrl;
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch (error) {
|
||||
// SSH URL or invalid - use as-is
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent('progress', { message: `Cloning into '${repoName}'...` });
|
||||
|
||||
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
});
|
||||
|
||||
let lastError = '';
|
||||
|
||||
gitProcess.stdout.on('data', (data) => {
|
||||
const message = data.toString().trim();
|
||||
if (message) {
|
||||
sendEvent('progress', { message });
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.stderr.on('data', (data) => {
|
||||
const message = data.toString().trim();
|
||||
lastError = message;
|
||||
if (message) {
|
||||
sendEvent('progress', { message });
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on('close', async (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const project = await addProjectManually(clonePath);
|
||||
sendEvent('complete', { project, message: 'Repository cloned successfully' });
|
||||
} catch (error) {
|
||||
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
|
||||
}
|
||||
} else {
|
||||
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
||||
let errorMessage = 'Git clone failed';
|
||||
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
|
||||
errorMessage = 'Authentication failed. Please check your credentials.';
|
||||
} else if (lastError.includes('Repository not found')) {
|
||||
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
||||
} else if (lastError.includes('already exists')) {
|
||||
errorMessage = 'Directory already exists';
|
||||
} else if (sanitizedError) {
|
||||
errorMessage = sanitizedError;
|
||||
}
|
||||
try {
|
||||
await fs.rm(clonePath, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
|
||||
}
|
||||
sendEvent('error', { message: errorMessage });
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
gitProcess.on('error', (error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
sendEvent('error', { message: 'Git is not installed or not in PATH' });
|
||||
} else {
|
||||
sendEvent('error', { message: error.message });
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
gitProcess.kill();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
sendEvent('error', { message: error.message });
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to clone a GitHub repository
|
||||
*/
|
||||
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cloneUrl = githubUrl;
|
||||
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch (error) {
|
||||
// SSH URL - use as-is
|
||||
}
|
||||
}
|
||||
|
||||
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
gitProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
gitProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
gitProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
let errorMessage = 'Git clone failed';
|
||||
|
||||
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
|
||||
errorMessage = 'Authentication failed. Please check your GitHub token.';
|
||||
} else if (stderr.includes('Repository not found')) {
|
||||
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
||||
} else if (stderr.includes('already exists')) {
|
||||
errorMessage = 'Directory already exists';
|
||||
} else if (stderr) {
|
||||
errorMessage = stderr;
|
||||
}
|
||||
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on('error', (error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
reject(new Error('Git is not installed or not in PATH'));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -331,15 +331,6 @@ router.get('/detect/:projectName', async (req, res) => {
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Broadcast TaskMaster project update via WebSocket
|
||||
if (req.app.locals.wss) {
|
||||
broadcastTaskMasterProjectUpdate(
|
||||
req.app.locals.wss,
|
||||
projectName,
|
||||
taskMasterResult
|
||||
);
|
||||
}
|
||||
|
||||
res.json(responseData);
|
||||
|
||||
} catch (error) {
|
||||
@@ -537,7 +528,8 @@ router.get('/next/:projectName', async (req, res) => {
|
||||
console.warn('Failed to execute task-master CLI:', cliError.message);
|
||||
|
||||
// Fallback to loading tasks and finding next one locally
|
||||
const tasksResponse = await fetch(`${req.protocol}://${req.get('host')}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
|
||||
// Use localhost to bypass proxy for internal server-to-server calls
|
||||
const tasksResponse = await fetch(`http://localhost:${process.env.PORT || 3001}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
|
||||
headers: {
|
||||
'Authorization': req.headers.authorization
|
||||
}
|
||||
|
||||
106
server/routes/user.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import express from 'express';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/git-config', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
let gitConfig = userDb.getGitConfig(userId);
|
||||
|
||||
// If database is empty, try to get from system git config
|
||||
if (!gitConfig || (!gitConfig.git_name && !gitConfig.git_email)) {
|
||||
const systemConfig = await getSystemGitConfig();
|
||||
|
||||
// If system has values, save them to database for this user
|
||||
if (systemConfig.git_name || systemConfig.git_email) {
|
||||
userDb.updateGitConfig(userId, systemConfig.git_name, systemConfig.git_email);
|
||||
gitConfig = systemConfig;
|
||||
console.log(`Auto-populated git config from system for user ${userId}: ${systemConfig.git_name} <${systemConfig.git_email}>`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
gitName: gitConfig?.git_name || null,
|
||||
gitEmail: gitConfig?.git_email || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting git config:', error);
|
||||
res.status(500).json({ error: 'Failed to get git configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply git config globally via git config --global
|
||||
router.post('/git-config', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { gitName, gitEmail } = req.body;
|
||||
|
||||
if (!gitName || !gitEmail) {
|
||||
return res.status(400).json({ error: 'Git name and email are required' });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(gitEmail)) {
|
||||
return res.status(400).json({ error: 'Invalid email format' });
|
||||
}
|
||||
|
||||
userDb.updateGitConfig(userId, gitName, gitEmail);
|
||||
|
||||
try {
|
||||
await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
|
||||
await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
|
||||
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
|
||||
} catch (gitError) {
|
||||
console.error('Error applying git config:', gitError);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
gitName,
|
||||
gitEmail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating git config:', error);
|
||||
res.status(500).json({ error: 'Failed to update git configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/complete-onboarding', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
userDb.completeOnboarding(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Onboarding completed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error completing onboarding:', error);
|
||||
res.status(500).json({ error: 'Failed to complete onboarding' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/onboarding-status', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const hasCompleted = userDb.hasCompletedOnboarding(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
hasCompletedOnboarding: hasCompleted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking onboarding status:', error);
|
||||
res.status(500).json({ error: 'Failed to check onboarding status' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
24
server/utils/gitConfig.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Read git configuration from system's global git config
|
||||
* @returns {Promise<{git_name: string|null, git_email: string|null}>}
|
||||
*/
|
||||
export async function getSystemGitConfig() {
|
||||
try {
|
||||
const [nameResult, emailResult] = await Promise.all([
|
||||
execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
|
||||
execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
|
||||
]);
|
||||
|
||||
return {
|
||||
git_name: nameResult.stdout.trim() || null,
|
||||
git_email: emailResult.stdout.trim() || null
|
||||
};
|
||||
} catch (error) {
|
||||
return { git_name: null, git_email: null };
|
||||
}
|
||||
}
|
||||
65
shared/modelConstants.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Centralized Model Definitions
|
||||
* Single source of truth for all supported AI models
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude (Anthropic) Models
|
||||
*
|
||||
* Note: Claude uses two different formats:
|
||||
* - SDK format ('sonnet', 'opus') - used by the UI and claude-sdk.js
|
||||
* - API format ('claude-sonnet-4.5') - used by slash commands for display
|
||||
*/
|
||||
export const CLAUDE_MODELS = {
|
||||
// Models in SDK format (what the actual SDK accepts)
|
||||
OPTIONS: [
|
||||
{ value: 'sonnet', label: 'Sonnet' },
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'haiku', label: 'Haiku' },
|
||||
{ value: 'opusplan', label: 'Opus Plan' },
|
||||
{ value: 'sonnet[1m]', label: 'Sonnet [1M]' }
|
||||
],
|
||||
|
||||
DEFAULT: 'sonnet'
|
||||
};
|
||||
|
||||
/**
|
||||
* Cursor Models
|
||||
*/
|
||||
export const CURSOR_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
|
||||
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
|
||||
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
|
||||
{ value: 'composer-1', label: 'Composer 1' },
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
|
||||
{ value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
|
||||
{ value: 'opus-4.5', label: 'Claude 4.5 Opus' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
|
||||
{ value: 'opus-4.1', label: 'Claude 4.1 Opus' },
|
||||
{ value: 'grok', label: 'Grok' }
|
||||
],
|
||||
|
||||
DEFAULT: 'gpt-5'
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Models
|
||||
*/
|
||||
export const CODEX_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'o3', label: 'O3' },
|
||||
{ value: 'o4-mini', label: 'O4-mini' }
|
||||
],
|
||||
|
||||
DEFAULT: 'gpt-5.2'
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
# Slash Command Execution Fix - Progress Report
|
||||
|
||||
## Issue
|
||||
Slash commands weren't executing when selected from the command menu. After typing a command like `/tm:list` and selecting it from the menu, nothing would happen - the page would stay on "Choose Your AI Assistant" screen.
|
||||
|
||||
## Root Cause
|
||||
The `handleCustomCommand` function was trying to call `handleSubmit` via a ref, but the ref wasn't being set properly. Originally attempted to set the ref inside `handleSubmit` itself, which meant it was only set AFTER the first submit - too late for command execution.
|
||||
|
||||
## Solution Implemented
|
||||
1. Converted `handleSubmit` to use `useCallback` with proper dependencies
|
||||
2. Added a `useEffect` hook that runs after `handleSubmit` is defined to store it in `handleSubmitRef`
|
||||
3. Now `handleCustomCommand` can access `handleSubmit` via the ref and call it with a fake event
|
||||
|
||||
## Code Changes
|
||||
|
||||
### File: src/components/ChatInterface.jsx
|
||||
|
||||
**Added ref declaration (around line 1534):**
|
||||
```javascript
|
||||
// Ref to store handleSubmit so we can call it from handleCustomCommand
|
||||
const handleSubmitRef = useRef(null);
|
||||
```
|
||||
|
||||
**Modified handleCustomCommand (around line 1555):**
|
||||
```javascript
|
||||
// Set the input to the command content
|
||||
setInput(content);
|
||||
|
||||
// Wait for state to update, then directly call handleSubmit
|
||||
setTimeout(() => {
|
||||
if (handleSubmitRef.current) {
|
||||
// Create a fake event to pass to handleSubmit
|
||||
const fakeEvent = { preventDefault: () => {} };
|
||||
handleSubmitRef.current(fakeEvent);
|
||||
}
|
||||
}, 50);
|
||||
```
|
||||
|
||||
**Converted handleSubmit to useCallback (line 3292):**
|
||||
```javascript
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading || !selectedProject) return;
|
||||
// ... rest of function
|
||||
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
|
||||
```
|
||||
|
||||
**Added useEffect to store ref (line 3437):**
|
||||
```javascript
|
||||
// Store handleSubmit in ref so handleCustomCommand can access it
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
```
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### 1. Commands Button Visibility ✅ FIXED
|
||||
- **Problem**: Button was not showing in active chat sessions with provider selected
|
||||
- **Root Cause**: Button was positioned at `right-14 sm:right-16` which overlapped with the clear button at `sm:right-28`
|
||||
- **Solution**: Changed button position to `right-14 sm:right-36` to place it left of the clear button
|
||||
- **File**: src/components/ChatInterface.jsx:4255
|
||||
- **Status**: Fixed in build dist/assets/index-CWRjcZ7A.js
|
||||
|
||||
### 2. Slash Command Menu Positioning ✅ FIXED
|
||||
- **Problem**: Mobile positioning was inconsistent - used wrong ref for bottom calculation
|
||||
- **Root Cause**: Position calculation used `inputContainerRef` (permission mode selector) instead of `textareaRef` (actual input)
|
||||
- **Solution**:
|
||||
- Changed bottom calculation to use `textareaRef` instead of `inputContainerRef`
|
||||
- Updated formula: `window.innerHeight - textareaRef.getBoundingClientRect().top + 8`
|
||||
- Removed extra `+ 8` in CommandMenu.jsx since spacing is already in the calculation
|
||||
- Added explicit `maxHeight: '300px'` to desktop positioning for consistency
|
||||
- Mobile maxHeight now uses `min(50vh, 300px)` for better consistency
|
||||
- **Files Modified**:
|
||||
- src/components/ChatInterface.jsx:4132-4134 - Fixed bottom position calculation
|
||||
- src/components/CommandMenu.jsx:30-46 - Improved positioning logic and max heights
|
||||
|
||||
## Related Issues Found (Not Fixed Yet)
|
||||
|
||||
### 3. Service Worker Caching Issue
|
||||
- After building, the service worker caches old build files
|
||||
- Requires manual unregistration of service worker on first load after build
|
||||
- Causes 404 errors for old asset filenames (e.g., index-n_2V3_vw.js when new build has index-Wp3pq386.js)
|
||||
- Need to implement proper cache busting or service worker update strategy
|
||||
|
||||
### 4. Chat Screen Jumping
|
||||
- Screen jumps/scrolls when Task Master widget appears/disappears
|
||||
- Likely due to layout shifts from the task widget
|
||||
|
||||
## Testing Status
|
||||
- ✅ Slash command execution fix implemented and built
|
||||
- ✅ Commands button visibility fix implemented and built
|
||||
- ⏳ Not yet tested end-to-end due to service worker caching issues requiring manual cache clearing
|
||||
- Need to test:
|
||||
1. Verify commands button is now visible to the left of clear button
|
||||
2. Click commands button to open menu
|
||||
3. Type `/tm:list` in chat input
|
||||
4. Select command from menu
|
||||
5. Verify command content loads and sends to Claude
|
||||
6. Verify session is created if none exists
|
||||
|
||||
## Next Steps
|
||||
1. Test the slash command button visibility fix
|
||||
2. Test the slash command execution fix end-to-end
|
||||
3. Fix service worker caching to enable easier testing
|
||||
4. Fix chat screen jumping issue
|
||||
|
||||
## Build Info
|
||||
- Latest build: dist/assets/index-C5zDTo8x.js (657.55 kB)
|
||||
- Commands button positioned at `right-14 sm:right-36` (mobile/desktop)
|
||||
- Menu positioning uses `textareaRef` for accurate placement
|
||||
- Mobile menu: `bottom` calculated from textarea top + 8px spacing
|
||||
- Desktop menu: `top` calculated with 316px offset, max 300px height
|
||||
- Server running on port 3001
|
||||
- Using Claude Agents SDK for Claude integration
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Mobile Positioning
|
||||
```javascript
|
||||
// ChatInterface.jsx - Position calculation
|
||||
bottom: textareaRef.current
|
||||
? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8
|
||||
: 90
|
||||
|
||||
// CommandMenu.jsx - Mobile layout
|
||||
{
|
||||
position: 'fixed',
|
||||
bottom: `${inputBottom}px`,
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
maxHeight: 'min(50vh, 300px)'
|
||||
}
|
||||
```
|
||||
|
||||
### Desktop Positioning
|
||||
```javascript
|
||||
// ChatInterface.jsx - Position calculation
|
||||
top: textareaRef.current
|
||||
? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316)
|
||||
: 0
|
||||
|
||||
// CommandMenu.jsx - Desktop layout
|
||||
{
|
||||
position: 'fixed',
|
||||
top: `${calculatedTop}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxHeight: '300px'
|
||||
}
|
||||
```
|
||||
250
src/App.jsx
@@ -18,8 +18,9 @@
|
||||
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import MainContent from './components/MainContent';
|
||||
import MobileNav from './components/MobileNav';
|
||||
@@ -30,17 +31,24 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
|
||||
import { WebSocketProvider, useWebSocket } from './contexts/WebSocketContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||
import useLocalStorage from './hooks/useLocalStorage';
|
||||
import { api, authenticatedFetch } from './utils/api';
|
||||
import { I18nextProvider, useTranslation } from 'react-i18next';
|
||||
import i18n from './i18n/config.js';
|
||||
|
||||
|
||||
// ! Move to a separate file called AppContent.ts
|
||||
// Main App component with routing
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const { sessionId } = useParams();
|
||||
const { t } = useTranslation('common');
|
||||
// * This is a tracker for avoiding excessive re-renders during development
|
||||
const renderCountRef = useRef(0);
|
||||
// console.log(`AppContent render count: ${renderCountRef.current++}`);
|
||||
|
||||
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
@@ -52,14 +60,17 @@ function AppContent() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject }
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
|
||||
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
||||
const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false);
|
||||
const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false);
|
||||
const [showThinking, setShowThinking] = useLocalStorage('showThinking', true);
|
||||
const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true);
|
||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false);
|
||||
const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true);
|
||||
// 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
|
||||
@@ -74,8 +85,11 @@ function AppContent() {
|
||||
// Triggers ChatInterface to reload messages without switching sessions
|
||||
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
|
||||
|
||||
const { ws, sendMessage, messages } = useWebSocketContext();
|
||||
|
||||
const { ws, sendMessage, latestMessage } = useWebSocket();
|
||||
|
||||
// Ref to track loading progress timeout for cleanup
|
||||
const loadingProgressTimeoutRef = useRef(null);
|
||||
|
||||
// Detect if running as PWA
|
||||
const [isPWA, setIsPWA] = useState(false);
|
||||
|
||||
@@ -86,7 +100,8 @@ function AppContent() {
|
||||
window.navigator.standalone ||
|
||||
document.referrer.includes('android-app://');
|
||||
setIsPWA(isStandalone);
|
||||
|
||||
document.addEventListener('touchstart', {});
|
||||
|
||||
// Add class to html and body for CSS targeting
|
||||
if (isStandalone) {
|
||||
document.documentElement.classList.add('pwa-mode');
|
||||
@@ -164,16 +179,32 @@ function AppContent() {
|
||||
|
||||
// Handle WebSocket messages for real-time project updates
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
|
||||
if (latestMessage) {
|
||||
// Handle loading progress updates
|
||||
if (latestMessage.type === 'loading_progress') {
|
||||
if (loadingProgressTimeoutRef.current) {
|
||||
clearTimeout(loadingProgressTimeoutRef.current);
|
||||
loadingProgressTimeoutRef.current = null;
|
||||
}
|
||||
setLoadingProgress(latestMessage);
|
||||
if (latestMessage.phase === 'complete') {
|
||||
loadingProgressTimeoutRef.current = setTimeout(() => {
|
||||
setLoadingProgress(null);
|
||||
loadingProgressTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestMessage.type === 'projects_updated') {
|
||||
|
||||
// External Session Update Detection: Check if the changed file is the current session's JSONL
|
||||
// If so, and the session is not active, trigger a message reload in ChatInterface
|
||||
if (latestMessage.changedFile && selectedSession && selectedProject) {
|
||||
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
|
||||
const changedFileParts = latestMessage.changedFile.split('/');
|
||||
const normalized = latestMessage.changedFile.replace(/\\/g, '/');
|
||||
const changedFileParts = normalized.split('/');
|
||||
|
||||
if (changedFileParts.length >= 2) {
|
||||
const filename = changedFileParts[changedFileParts.length - 1];
|
||||
const changedSessionId = filename.replace('.jsonl', '');
|
||||
@@ -226,20 +257,29 @@ function AppContent() {
|
||||
setSelectedProject(updatedSelectedProject);
|
||||
}
|
||||
|
||||
// Update selected session only if it was deleted - avoid unnecessary reloads
|
||||
if (selectedSession) {
|
||||
const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id);
|
||||
const allSessions = [
|
||||
...(updatedSelectedProject.sessions || []),
|
||||
...(updatedSelectedProject.codexSessions || []),
|
||||
...(updatedSelectedProject.cursorSessions || [])
|
||||
];
|
||||
const updatedSelectedSession = allSessions.find(s => s.id === selectedSession.id);
|
||||
if (!updatedSelectedSession) {
|
||||
// Session was deleted
|
||||
setSelectedSession(null);
|
||||
}
|
||||
// Don't update if session still exists with same ID - prevents reload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [messages, selectedProject, selectedSession, activeSessions]);
|
||||
|
||||
return () => {
|
||||
if (loadingProgressTimeoutRef.current) {
|
||||
clearTimeout(loadingProgressTimeoutRef.current);
|
||||
loadingProgressTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [latestMessage, selectedProject, selectedSession, activeSessions]);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
@@ -306,6 +346,12 @@ function AppContent() {
|
||||
// Expose fetchProjects globally for component access
|
||||
window.refreshProjects = fetchProjects;
|
||||
|
||||
// Expose openSettings function globally for component access
|
||||
window.openSettings = useCallback((tab = 'tools') => {
|
||||
setSettingsInitialTab(tab);
|
||||
setShowSettings(true);
|
||||
}, []);
|
||||
|
||||
// Handle URL-based session loading
|
||||
useEffect(() => {
|
||||
if (sessionId && projects.length > 0) {
|
||||
@@ -536,6 +582,7 @@ function AppContent() {
|
||||
|
||||
// Version Upgrade Modal Component
|
||||
const VersionUpgradeModal = () => {
|
||||
const { t } = useTranslation('common');
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateOutput, setUpdateOutput] = useState('');
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
@@ -596,7 +643,7 @@ function AppContent() {
|
||||
<button
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
aria-label="Close version upgrade modal"
|
||||
aria-label={t('versionUpdate.ariaLabels.closeModal')}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
@@ -610,9 +657,9 @@ function AppContent() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('versionUpdate.title')}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{releaseInfo?.title || 'A new version is ready'}
|
||||
{releaseInfo?.title || t('versionUpdate.newVersionReady')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -629,11 +676,11 @@ function AppContent() {
|
||||
{/* Version Info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('versionUpdate.currentVersion')}</span>
|
||||
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('versionUpdate.latestVersion')}</span>
|
||||
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,7 +689,7 @@ function AppContent() {
|
||||
{releaseInfo?.body && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.whatsNew')}</h3>
|
||||
{releaseInfo?.htmlUrl && (
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
@@ -650,7 +697,7 @@ function AppContent() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
|
||||
>
|
||||
View full release
|
||||
{t('versionUpdate.viewFullRelease')}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
@@ -668,7 +715,7 @@ function AppContent() {
|
||||
{/* Update Output */}
|
||||
{updateOutput && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.updateProgress')}</h3>
|
||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
|
||||
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
|
||||
</div>
|
||||
@@ -678,14 +725,14 @@ function AppContent() {
|
||||
{/* Upgrade Instructions */}
|
||||
{!isUpdating && !updateOutput && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.manualUpgrade')}</h3>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
|
||||
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
||||
git checkout main && git pull && npm install
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Or click "Update Now" to run the update automatically.
|
||||
{t('versionUpdate.manualUpgradeHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -696,7 +743,7 @@ function AppContent() {
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
{updateOutput ? 'Close' : 'Later'}
|
||||
{updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}
|
||||
</button>
|
||||
{!updateOutput && (
|
||||
<>
|
||||
@@ -706,7 +753,7 @@ function AppContent() {
|
||||
}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
Copy Command
|
||||
{t('versionUpdate.buttons.copyCommand')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateNow}
|
||||
@@ -716,10 +763,10 @@ function AppContent() {
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Updating...
|
||||
{t('versionUpdate.buttons.updating')}
|
||||
</>
|
||||
) : (
|
||||
'Update Now'
|
||||
t('versionUpdate.buttons.updateNow')
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
@@ -734,28 +781,79 @@ function AppContent() {
|
||||
<div className="fixed inset-0 flex bg-background">
|
||||
{/* Fixed Desktop Sidebar */}
|
||||
{!isMobile && (
|
||||
<div className="w-80 flex-shrink-0 border-r border-border bg-card">
|
||||
<div
|
||||
className={`h-full flex-shrink-0 border-r border-border bg-card transition-all duration-300 ${
|
||||
sidebarVisible ? 'w-80' : 'w-14'
|
||||
}`}
|
||||
>
|
||||
<div className="h-full overflow-hidden">
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
onProjectSelect={handleProjectSelect}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewSession={handleNewSession}
|
||||
onSessionDelete={handleSessionDelete}
|
||||
onProjectDelete={handleProjectDelete}
|
||||
isLoading={isLoadingProjects}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
releaseInfo={releaseInfo}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{sidebarVisible ? (
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
onProjectSelect={handleProjectSelect}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewSession={handleNewSession}
|
||||
onSessionDelete={handleSessionDelete}
|
||||
onProjectDelete={handleProjectDelete}
|
||||
isLoading={isLoadingProjects}
|
||||
loadingProgress={loadingProgress}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
releaseInfo={releaseInfo}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
onToggleSidebar={() => setSidebarVisible(false)}
|
||||
/>
|
||||
) : (
|
||||
/* Collapsed Sidebar */
|
||||
<div className="h-full flex flex-col items-center py-4 gap-4">
|
||||
{/* Expand Button */}
|
||||
<button
|
||||
onClick={() => setSidebarVisible(true)}
|
||||
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
|
||||
aria-label={t('versionUpdate.ariaLabels.showSidebar')}
|
||||
title={t('versionUpdate.ariaLabels.showSidebar')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Settings Icon */}
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||
aria-label={t('versionUpdate.ariaLabels.settings')}
|
||||
title={t('versionUpdate.ariaLabels.settings')}
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
|
||||
{/* Update Indicator */}
|
||||
{updateAvailable && (
|
||||
<button
|
||||
onClick={() => setShowVersionModal(true)}
|
||||
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
|
||||
aria-label={t('versionUpdate.ariaLabels.updateAvailable')}
|
||||
title={t('versionUpdate.ariaLabels.updateAvailable')}
|
||||
>
|
||||
<Sparkles className="w-5 h-5 text-blue-500" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -776,13 +874,12 @@ function AppContent() {
|
||||
e.stopPropagation();
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
aria-label="Close sidebar"
|
||||
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
|
||||
/>
|
||||
<div
|
||||
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
||||
<div
|
||||
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
style={{ height: 'calc(100vh - 80px)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -796,6 +893,7 @@ function AppContent() {
|
||||
onSessionDelete={handleSessionDelete}
|
||||
onProjectDelete={handleProjectDelete}
|
||||
isLoading={isLoadingProjects}
|
||||
loadingProgress={loadingProgress}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
@@ -805,13 +903,14 @@ function AppContent() {
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
onToggleSidebar={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area - Flexible */}
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-16' : ''}`}>
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
|
||||
<MainContent
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
@@ -819,7 +918,7 @@ function AppContent() {
|
||||
setActiveTab={setActiveTab}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
latestMessage={latestMessage}
|
||||
isMobile={isMobile}
|
||||
isPWA={isPWA}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
@@ -874,6 +973,7 @@ function AppContent() {
|
||||
isOpen={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
projects={projects}
|
||||
initialTab={settingsInitialTab}
|
||||
/>
|
||||
|
||||
{/* Version Upgrade Modal */}
|
||||
@@ -885,25 +985,27 @@ function AppContent() {
|
||||
// Root App component with router
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<WebSocketProvider>
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</TaskMasterProvider>
|
||||
</TasksSettingsProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<WebSocketProvider>
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</TaskMasterProvider>
|
||||
</TasksSettingsProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ApiKeysSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [githubTokens, setGithubTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -23,21 +26,16 @@ function ApiKeysSettings() {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
// Fetch API keys
|
||||
const apiKeysRes = await fetch('/api/settings/api-keys', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
|
||||
const apiKeysData = await apiKeysRes.json();
|
||||
setApiKeys(apiKeysData.apiKeys || []);
|
||||
|
||||
// Fetch GitHub tokens
|
||||
const githubRes = await fetch('/api/settings/github-tokens', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const githubRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
|
||||
const githubData = await githubRes.json();
|
||||
setGithubTokens(githubData.tokens || []);
|
||||
setGithubTokens(githubData.credentials || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
} finally {
|
||||
@@ -49,13 +47,8 @@ function ApiKeysSettings() {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const res = await fetch('/api/settings/api-keys', {
|
||||
const res = await authenticatedFetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ keyName: newKeyName })
|
||||
});
|
||||
|
||||
@@ -72,13 +65,11 @@ function ApiKeysSettings() {
|
||||
};
|
||||
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm('Are you sure you want to delete this API key?')) return;
|
||||
if (!confirm(t('apiKeys.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/api-keys/${keyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
@@ -88,13 +79,8 @@ function ApiKeysSettings() {
|
||||
|
||||
const toggleApiKey = async (keyId, isActive) => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/api-keys/${keyId}/toggle`, {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
@@ -107,16 +93,12 @@ function ApiKeysSettings() {
|
||||
if (!newTokenName.trim() || !newGithubToken.trim()) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const res = await fetch('/api/settings/github-tokens', {
|
||||
const res = await authenticatedFetch('/api/settings/credentials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tokenName: newTokenName,
|
||||
githubToken: newGithubToken
|
||||
credentialName: newTokenName,
|
||||
credentialType: 'github_token',
|
||||
credentialValue: newGithubToken
|
||||
})
|
||||
});
|
||||
|
||||
@@ -133,13 +115,11 @@ function ApiKeysSettings() {
|
||||
};
|
||||
|
||||
const deleteGithubToken = async (tokenId) => {
|
||||
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
|
||||
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/github-tokens/${tokenId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
await authenticatedFetch(`/api/settings/credentials/${tokenId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
@@ -149,13 +129,8 @@ function ApiKeysSettings() {
|
||||
|
||||
const toggleGithubToken = async (tokenId, isActive) => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, {
|
||||
await authenticatedFetch(`/api/settings/credentials/${tokenId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
@@ -171,7 +146,7 @@ function ApiKeysSettings() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-muted-foreground">Loading...</div>;
|
||||
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -179,9 +154,9 @@ function ApiKeysSettings() {
|
||||
{/* New API Key Alert */}
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<h4 className="font-semibold text-yellow-500 mb-2">⚠️ Save Your API Key</h4>
|
||||
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
This is the only time you'll see this key. Store it securely.
|
||||
{t('apiKeys.newKey.alertMessage')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
|
||||
@@ -201,7 +176,7 @@ function ApiKeysSettings() {
|
||||
className="mt-3"
|
||||
onClick={() => setNewlyCreatedKey(null)}
|
||||
>
|
||||
I've saved it
|
||||
{t('apiKeys.newKey.iveSavedIt')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -211,33 +186,33 @@ function ApiKeysSettings() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">API Keys</h3>
|
||||
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New API Key
|
||||
{t('apiKeys.newButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Generate API keys to access the external API from other applications.
|
||||
{t('apiKeys.description')}
|
||||
</p>
|
||||
|
||||
{showNewKeyForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder="API Key Name (e.g., Production Server)"
|
||||
placeholder={t('apiKeys.form.placeholder')}
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createApiKey}>Create</Button>
|
||||
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
|
||||
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
|
||||
Cancel
|
||||
{t('apiKeys.form.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,7 +220,7 @@ function ApiKeysSettings() {
|
||||
|
||||
<div className="space-y-2">
|
||||
{apiKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No API keys created yet.</p>
|
||||
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
|
||||
) : (
|
||||
apiKeys.map((key) => (
|
||||
<div
|
||||
@@ -256,8 +231,8 @@ function ApiKeysSettings() {
|
||||
<div className="font-medium">{key.key_name}</div>
|
||||
<code className="text-xs text-muted-foreground">{key.api_key}</code>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||
{key.last_used && ` • Last used: ${new Date(key.last_used).toLocaleDateString()}`}
|
||||
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
|
||||
{key.last_used && ` • ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -266,7 +241,7 @@ function ApiKeysSettings() {
|
||||
variant={key.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleApiKey(key.id, key.is_active)}
|
||||
>
|
||||
{key.is_active ? 'Active' : 'Inactive'}
|
||||
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -287,25 +262,25 @@ function ApiKeysSettings() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">GitHub Tokens</h3>
|
||||
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewTokenForm(!showNewTokenForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Token
|
||||
{t('apiKeys.github.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add GitHub Personal Access Tokens to clone private repositories via the external API.
|
||||
{t('apiKeys.github.description')}
|
||||
</p>
|
||||
|
||||
{showNewTokenForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder="Token Name (e.g., Personal Repos)"
|
||||
placeholder={t('apiKeys.github.form.namePlaceholder')}
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
className="mb-2"
|
||||
@@ -313,7 +288,7 @@ function ApiKeysSettings() {
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showToken['new'] ? 'text' : 'password'}
|
||||
placeholder="GitHub Personal Access Token (ghp_...)"
|
||||
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
className="mb-2 pr-10"
|
||||
@@ -327,13 +302,13 @@ function ApiKeysSettings() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createGithubToken}>Add Token</Button>
|
||||
<Button onClick={createGithubToken}>{t('apiKeys.github.form.addButton')}</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowNewTokenForm(false);
|
||||
setNewTokenName('');
|
||||
setNewGithubToken('');
|
||||
}}>
|
||||
Cancel
|
||||
{t('apiKeys.github.form.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,7 +316,7 @@ function ApiKeysSettings() {
|
||||
|
||||
<div className="space-y-2">
|
||||
{githubTokens.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No GitHub tokens added yet.</p>
|
||||
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
|
||||
) : (
|
||||
githubTokens.map((token) => (
|
||||
<div
|
||||
@@ -349,9 +324,9 @@ function ApiKeysSettings() {
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{token.token_name}</div>
|
||||
<div className="font-medium">{token.credential_name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Added: {new Date(token.created_at).toLocaleDateString()}
|
||||
{t('apiKeys.github.added')} {new Date(token.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -360,7 +335,7 @@ function ApiKeysSettings() {
|
||||
variant={token.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleGithubToken(token.id, token.is_active)}
|
||||
>
|
||||
{token.is_active ? 'Active' : 'Inactive'}
|
||||
{token.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -378,9 +353,9 @@ function ApiKeysSettings() {
|
||||
|
||||
{/* Documentation Link */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">External API Documentation</h4>
|
||||
<h4 className="font-semibold mb-2">{t('apiKeys.documentation.title')}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Learn how to use the external API to trigger Claude/Cursor sessions from your applications.
|
||||
{t('apiKeys.documentation.description')}
|
||||
</p>
|
||||
<a
|
||||
href="/EXTERNAL_API.md"
|
||||
@@ -388,7 +363,7 @@ function ApiKeysSettings() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
View API Documentation →
|
||||
{t('apiKeys.documentation.viewLink')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,46 +57,41 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
const currentSpinner = spinners[animationPhase];
|
||||
|
||||
return (
|
||||
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-900 dark:bg-gray-950 text-white rounded-lg shadow-lg px-4 py-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-800 dark:bg-gray-900 text-white rounded-lg shadow-lg px-2.5 py-2 sm:px-4 sm:py-3 border border-gray-700 dark:border-gray-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Animated spinner */}
|
||||
<span className={cn(
|
||||
"text-xl transition-all duration-500",
|
||||
"text-base sm:text-xl transition-all duration-500 flex-shrink-0",
|
||||
animationPhase % 2 === 0 ? "text-blue-400 scale-110" : "text-blue-300"
|
||||
)}>
|
||||
{currentSpinner}
|
||||
</span>
|
||||
|
||||
{/* Status text - first line */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{statusText}...</span>
|
||||
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
|
||||
|
||||
{/* Status text - compact for mobile */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<span className="font-medium text-xs sm:text-sm truncate">{statusText}...</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({elapsedTime}s)</span>
|
||||
{tokens > 0 && (
|
||||
<>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-gray-300 text-sm hidden sm:inline">⚒ {tokens.toLocaleString()} tokens</span>
|
||||
<span className="text-gray-300 text-sm sm:hidden">⚒ {tokens.toLocaleString()}</span>
|
||||
<span className="text-gray-500 hidden sm:inline">·</span>
|
||||
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">⚒ {tokens.toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-400 hidden sm:inline">·</span>
|
||||
<span className="text-gray-300 text-sm hidden sm:inline">esc to interrupt</span>
|
||||
</div>
|
||||
{/* Second line for mobile */}
|
||||
<div className="text-xs text-gray-400 sm:hidden mt-1">
|
||||
esc to interrupt
|
||||
<span className="text-gray-500 hidden sm:inline">·</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Interrupt button */}
|
||||
{canInterrupt && onAbort && (
|
||||
<button
|
||||
onClick={onAbort}
|
||||
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
|
||||
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
|
||||
@@ -10,23 +10,39 @@ import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
|
||||
import { unifiedMergeView, getChunks } from '@codemirror/merge';
|
||||
import { showMinimap } from '@replit/codemirror-minimap';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
|
||||
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath }) {
|
||||
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
|
||||
const { t } = useTranslation('codeEditor');
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const savedTheme = localStorage.getItem('codeEditorTheme');
|
||||
return savedTheme ? savedTheme === 'dark' : true;
|
||||
});
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
const [wordWrap, setWordWrap] = useState(() => {
|
||||
return localStorage.getItem('codeEditorWordWrap') === 'true';
|
||||
});
|
||||
const [minimapEnabled, setMinimapEnabled] = useState(() => {
|
||||
return localStorage.getItem('codeEditorShowMinimap') !== 'false';
|
||||
});
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(() => {
|
||||
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
|
||||
});
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
return localStorage.getItem('codeEditorFontSize') || '14';
|
||||
});
|
||||
const editorRef = useRef(null);
|
||||
|
||||
// Create minimap extension with chunk-based gutters
|
||||
const minimapExtension = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff) return [];
|
||||
if (!file.diffInfo || !showDiff || !minimapEnabled) return [];
|
||||
|
||||
const gutters = {};
|
||||
|
||||
@@ -58,7 +74,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
};
|
||||
})
|
||||
];
|
||||
}, [file.diffInfo, showDiff, isDarkMode]);
|
||||
}, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]);
|
||||
|
||||
// Create extension to scroll to first chunk on mount
|
||||
const scrollToFirstChunkExtension = useMemo(() => {
|
||||
@@ -89,70 +105,143 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
];
|
||||
}, [file.diffInfo, showDiff]);
|
||||
|
||||
// Create diff navigation panel extension
|
||||
const diffNavigationPanel = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff) return [];
|
||||
|
||||
// Create editor toolbar panel - always visible
|
||||
const editorToolbarPanel = useMemo(() => {
|
||||
const createPanel = (view) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-diff-navigation-panel';
|
||||
dom.className = 'cm-editor-toolbar-panel';
|
||||
|
||||
let currentIndex = 0;
|
||||
|
||||
const updatePanel = () => {
|
||||
// Use getChunks API to get ALL chunks regardless of viewport
|
||||
const chunksData = getChunks(view.state);
|
||||
// Check if we have diff info and it's enabled
|
||||
const hasDiff = file.diffInfo && showDiff;
|
||||
const chunksData = hasDiff ? getChunks(view.state) : null;
|
||||
const chunks = chunksData?.chunks || [];
|
||||
const chunkCount = chunks.length;
|
||||
|
||||
dom.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
// Build the toolbar HTML
|
||||
let toolbarHTML = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
|
||||
|
||||
// Left side - diff navigation (if applicable)
|
||||
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
|
||||
if (hasDiff) {
|
||||
toolbarHTML += `
|
||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')}</span>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${t('toolbar.nextChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
toolbarHTML += '</div>';
|
||||
|
||||
// Right side - action buttons
|
||||
toolbarHTML += '<div style="display: flex; align-items: center; gap: 4px;">';
|
||||
|
||||
// Show/hide diff button (only if there's diff info)
|
||||
if (file.diffInfo) {
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? t('toolbar.hideDiff') : t('toolbar.showDiff')}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${showDiff ?
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />'
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Settings button
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-settings-btn" title="${t('toolbar.settings')}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
|
||||
const nextBtn = dom.querySelector('.cm-diff-nav-next');
|
||||
// Expand button (only in sidebar mode)
|
||||
if (isSidebar && onToggleExpand) {
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? t('toolbar.collapse') : t('toolbar.expand')}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${isExpanded ?
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />'
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
|
||||
toolbarHTML += '</div>';
|
||||
toolbarHTML += '</div>';
|
||||
|
||||
// Navigate to the chunk - use fromB which is the position in the current document
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
// Scroll to the start of the chunk in the B side (current document)
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
dom.innerHTML = toolbarHTML;
|
||||
|
||||
// Attach event listeners for diff navigation
|
||||
if (hasDiff) {
|
||||
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
|
||||
const nextBtn = dom.querySelector('.cm-diff-nav-next');
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
|
||||
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
|
||||
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listener for toggle diff button
|
||||
if (file.diffInfo) {
|
||||
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
|
||||
toggleDiffBtn?.addEventListener('click', () => {
|
||||
setShowDiff(!showDiff);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listener for settings button
|
||||
const settingsBtn = dom.querySelector('.cm-settings-btn');
|
||||
settingsBtn?.addEventListener('click', () => {
|
||||
if (window.openSettings) {
|
||||
window.openSettings('appearance');
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
|
||||
|
||||
// Navigate to the chunk - use fromB which is the position in the current document
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
// Scroll to the start of the chunk in the B side (current document)
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
// Attach event listener for expand button
|
||||
if (isSidebar && onToggleExpand) {
|
||||
const expandBtn = dom.querySelector('.cm-expand-btn');
|
||||
expandBtn?.addEventListener('click', () => {
|
||||
onToggleExpand();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updatePanel();
|
||||
@@ -165,7 +254,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
};
|
||||
|
||||
return [showPanel.of(createPanel)];
|
||||
}, [file.diffInfo, showDiff]);
|
||||
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand]);
|
||||
|
||||
// Get language extension based on file extension
|
||||
const getLanguageExtension = (filename) => {
|
||||
@@ -290,6 +379,57 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// Save theme preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light');
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Save word wrap preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('codeEditorWordWrap', wordWrap.toString());
|
||||
}, [wordWrap]);
|
||||
|
||||
// Listen for settings changes from the Settings modal
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
const newTheme = localStorage.getItem('codeEditorTheme');
|
||||
if (newTheme) {
|
||||
setIsDarkMode(newTheme === 'dark');
|
||||
}
|
||||
|
||||
const newWordWrap = localStorage.getItem('codeEditorWordWrap');
|
||||
if (newWordWrap !== null) {
|
||||
setWordWrap(newWordWrap === 'true');
|
||||
}
|
||||
|
||||
const newShowMinimap = localStorage.getItem('codeEditorShowMinimap');
|
||||
if (newShowMinimap !== null) {
|
||||
setMinimapEnabled(newShowMinimap !== 'false');
|
||||
}
|
||||
|
||||
const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers');
|
||||
if (newShowLineNumbers !== null) {
|
||||
setShowLineNumbers(newShowLineNumbers !== 'false');
|
||||
}
|
||||
|
||||
const newFontSize = localStorage.getItem('codeEditorFontSize');
|
||||
if (newFontSize) {
|
||||
setFontSize(newFontSize);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for storage events (changes from other tabs/windows)
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Custom event for same-window updates
|
||||
window.addEventListener('codeEditorSettingsChanged', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
window.removeEventListener('codeEditorSettingsChanged', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
@@ -321,14 +461,23 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
{isSidebar ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
|
||||
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-40 md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -372,8 +521,8 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
|
||||
}
|
||||
|
||||
/* Diff navigation panel styling */
|
||||
.cm-diff-navigation-panel {
|
||||
/* Editor toolbar panel styling */
|
||||
.cm-editor-toolbar-panel {
|
||||
padding: 8px 12px;
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
|
||||
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
|
||||
@@ -381,7 +530,8 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn {
|
||||
.cm-diff-nav-btn,
|
||||
.cm-toolbar-btn {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -391,9 +541,11 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:hover {
|
||||
.cm-diff-nav-btn:hover,
|
||||
.cm-toolbar-btn:hover {
|
||||
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
|
||||
}
|
||||
|
||||
@@ -403,71 +555,40 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className={`fixed inset-0 z-50 ${
|
||||
<div className={isSidebar ?
|
||||
'w-full h-full flex flex-col' :
|
||||
`fixed inset-0 z-40 ${
|
||||
// Mobile: native fullscreen, Desktop: modal with backdrop
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={`bg-white shadow-2xl flex flex-col ${
|
||||
// Mobile: always fullscreen, Desktop: modal sizing
|
||||
'w-full h-full md:rounded-lg md:shadow-2xl' +
|
||||
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
|
||||
}`}>
|
||||
<div className={isSidebar ?
|
||||
'bg-background flex flex-col w-full h-full' :
|
||||
`bg-background shadow-2xl flex flex-col ${
|
||||
// Mobile: always fullscreen, Desktop: modal sizing
|
||||
'w-full h-full md:rounded-lg md:shadow-2xl' +
|
||||
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-sm font-mono">
|
||||
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
📝 Has changes
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
|
||||
{t('header.showingChanges')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate">{file.path}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
|
||||
{file.diffInfo && (
|
||||
<button
|
||||
onClick={() => setShowDiff(!showDiff)}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={showDiff ? "Hide diff highlighting" : "Show diff highlighting"}
|
||||
>
|
||||
{showDiff ? <EyeOff className="w-5 h-5 md:w-4 md:h-4" /> : <Eye className="w-5 h-5 md:w-4 md:h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
|
||||
wordWrap
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
>
|
||||
<span className="text-sm md:text-xs font-mono font-bold">↵</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title="Toggle theme"
|
||||
>
|
||||
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title="Download file"
|
||||
title={t('actions.download')}
|
||||
>
|
||||
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
@@ -486,28 +607,30 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Saved!</span>
|
||||
<span className="hidden sm:inline">{t('actions.saved')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-5 h-5 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
|
||||
<span className="hidden sm:inline">{saving ? t('actions.saving') : t('actions.save')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
{!isSidebar && (
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title="Close"
|
||||
title={t('actions.close')}
|
||||
>
|
||||
<X className="w-6 h-6 md:w-4 md:h-4" />
|
||||
</button>
|
||||
@@ -522,6 +645,9 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
...getLanguageExtension(file.name),
|
||||
// Always show the toolbar
|
||||
...editorToolbarPanel,
|
||||
// Only show diff-related extensions when diff is enabled
|
||||
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
|
||||
? [
|
||||
unifiedMergeView({
|
||||
@@ -533,8 +659,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
// NOTE: NO collapseUnchanged - this shows the full file!
|
||||
}),
|
||||
...minimapExtension,
|
||||
...scrollToFirstChunkExtension,
|
||||
...diffNavigationPanel
|
||||
...scrollToFirstChunkExtension
|
||||
]
|
||||
: []),
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
@@ -542,11 +667,11 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontSize: `${fontSize}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
lineNumbers: showLineNumbers,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
@@ -561,15 +686,14 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
|
||||
<div className="flex items-center justify-between p-3 border-t border-border bg-muted flex-shrink-0">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>Lines: {content.split('\n').length}</span>
|
||||
<span>Characters: {content.length}</span>
|
||||
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
|
||||
<span>{t('footer.lines')} {content.split('\n').length}</span>
|
||||
<span>{t('footer.characters')} {content.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Press Ctrl+S to save • Esc to close
|
||||
{t('footer.shortcuts')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
src/components/CodexLogo.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const CodexLogo = ({ className = 'w-5 h-5' }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<img
|
||||
src={isDarkMode ? "/icons/codex-white.svg" : "/icons/codex.svg"}
|
||||
alt="Codex"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexLogo;
|
||||
@@ -2,8 +2,13 @@ import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github, ExternalLink } from 'lucide-react';
|
||||
import { useVersionCheck } from '../hooks/useVersionCheck';
|
||||
import { version } from '../../package.json';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function CredentialsSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [githubCredentials, setGithubCredentials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -17,6 +22,9 @@ function CredentialsSettings() {
|
||||
const [copiedKey, setCopiedKey] = useState(null);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
|
||||
|
||||
// Version check hook
|
||||
const { updateAvailable, latestVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
@@ -24,19 +32,14 @@ function CredentialsSettings() {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
// Fetch API keys
|
||||
const apiKeysRes = await fetch('/api/settings/api-keys', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
|
||||
const apiKeysData = await apiKeysRes.json();
|
||||
setApiKeys(apiKeysData.apiKeys || []);
|
||||
|
||||
// Fetch GitHub credentials only
|
||||
const credentialsRes = await fetch('/api/settings/credentials?type=github_token', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const credentialsRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
|
||||
const credentialsData = await credentialsRes.json();
|
||||
setGithubCredentials(credentialsData.credentials || []);
|
||||
} catch (error) {
|
||||
@@ -50,13 +53,8 @@ function CredentialsSettings() {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const res = await fetch('/api/settings/api-keys', {
|
||||
const res = await authenticatedFetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ keyName: newKeyName })
|
||||
});
|
||||
|
||||
@@ -73,13 +71,11 @@ function CredentialsSettings() {
|
||||
};
|
||||
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm('Are you sure you want to delete this API key?')) return;
|
||||
if (!confirm(t('apiKeys.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/api-keys/${keyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
@@ -89,13 +85,8 @@ function CredentialsSettings() {
|
||||
|
||||
const toggleApiKey = async (keyId, isActive) => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/api-keys/${keyId}/toggle`, {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
@@ -108,13 +99,8 @@ function CredentialsSettings() {
|
||||
if (!newGithubName.trim() || !newGithubToken.trim()) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const res = await fetch('/api/settings/credentials', {
|
||||
const res = await authenticatedFetch('/api/settings/credentials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialName: newGithubName,
|
||||
credentialType: 'github_token',
|
||||
@@ -137,13 +123,11 @@ function CredentialsSettings() {
|
||||
};
|
||||
|
||||
const deleteGithubCredential = async (credentialId) => {
|
||||
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
|
||||
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/credentials/${credentialId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
@@ -153,13 +137,8 @@ function CredentialsSettings() {
|
||||
|
||||
const toggleGithubCredential = async (credentialId, isActive) => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/credentials/${credentialId}/toggle`, {
|
||||
await authenticatedFetch(`/api/settings/credentials/${credentialId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
@@ -175,7 +154,7 @@ function CredentialsSettings() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-muted-foreground">Loading...</div>;
|
||||
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -183,9 +162,9 @@ function CredentialsSettings() {
|
||||
{/* New API Key Alert */}
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<h4 className="font-semibold text-yellow-500 mb-2">⚠️ Save Your API Key</h4>
|
||||
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
This is the only time you'll see this key. Store it securely.
|
||||
{t('apiKeys.newKey.alertMessage')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
|
||||
@@ -205,7 +184,7 @@ function CredentialsSettings() {
|
||||
className="mt-3"
|
||||
onClick={() => setNewlyCreatedKey(null)}
|
||||
>
|
||||
I've saved it
|
||||
{t('apiKeys.newKey.iveSavedIt')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -215,20 +194,20 @@ function CredentialsSettings() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">API Keys</h3>
|
||||
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New API Key
|
||||
{t('apiKeys.newButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Generate API keys to access the external API from other applications.
|
||||
{t('apiKeys.description')}
|
||||
</p>
|
||||
<a
|
||||
href="/api-docs.html"
|
||||
@@ -236,7 +215,7 @@ function CredentialsSettings() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
API Documentation
|
||||
{t('apiKeys.apiDocsLink')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -244,15 +223,15 @@ function CredentialsSettings() {
|
||||
{showNewKeyForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder="API Key Name (e.g., Production Server)"
|
||||
placeholder={t('apiKeys.form.placeholder')}
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createApiKey}>Create</Button>
|
||||
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
|
||||
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
|
||||
Cancel
|
||||
{t('apiKeys.form.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,7 +239,7 @@ function CredentialsSettings() {
|
||||
|
||||
<div className="space-y-2">
|
||||
{apiKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No API keys created yet.</p>
|
||||
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
|
||||
) : (
|
||||
apiKeys.map((key) => (
|
||||
<div
|
||||
@@ -271,8 +250,8 @@ function CredentialsSettings() {
|
||||
<div className="font-medium">{key.key_name}</div>
|
||||
<code className="text-xs text-muted-foreground">{key.api_key}</code>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||
{key.last_used && ` • Last used: ${new Date(key.last_used).toLocaleDateString()}`}
|
||||
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
|
||||
{key.last_used && ` • ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -281,7 +260,7 @@ function CredentialsSettings() {
|
||||
variant={key.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleApiKey(key.id, key.is_active)}
|
||||
>
|
||||
{key.is_active ? 'Active' : 'Inactive'}
|
||||
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -302,25 +281,25 @@ function CredentialsSettings() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">GitHub Credentials</h3>
|
||||
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewGithubForm(!showNewGithubForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Token
|
||||
{t('apiKeys.github.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add GitHub Personal Access Tokens to clone private repositories. You can also pass tokens directly in API requests without storing them.
|
||||
{t('apiKeys.github.descriptionAlt')}
|
||||
</p>
|
||||
|
||||
{showNewGithubForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
|
||||
<Input
|
||||
placeholder="Token Name (e.g., Personal Repos)"
|
||||
placeholder={t('apiKeys.github.form.namePlaceholder')}
|
||||
value={newGithubName}
|
||||
onChange={(e) => setNewGithubName(e.target.value)}
|
||||
/>
|
||||
@@ -328,7 +307,7 @@ function CredentialsSettings() {
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showToken['new'] ? 'text' : 'password'}
|
||||
placeholder="GitHub Personal Access Token (ghp_...)"
|
||||
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
className="pr-10"
|
||||
@@ -343,20 +322,20 @@ function CredentialsSettings() {
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="Description (optional)"
|
||||
placeholder={t('apiKeys.github.form.descriptionPlaceholder')}
|
||||
value={newGithubDescription}
|
||||
onChange={(e) => setNewGithubDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createGithubCredential}>Add Token</Button>
|
||||
<Button onClick={createGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowNewGithubForm(false);
|
||||
setNewGithubName('');
|
||||
setNewGithubToken('');
|
||||
setNewGithubDescription('');
|
||||
}}>
|
||||
Cancel
|
||||
{t('apiKeys.github.form.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -366,14 +345,14 @@ function CredentialsSettings() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline block"
|
||||
>
|
||||
How to create a GitHub Personal Access Token →
|
||||
{t('apiKeys.github.form.howToCreate')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{githubCredentials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No GitHub tokens added yet.</p>
|
||||
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
|
||||
) : (
|
||||
githubCredentials.map((credential) => (
|
||||
<div
|
||||
@@ -386,7 +365,7 @@ function CredentialsSettings() {
|
||||
<div className="text-xs text-muted-foreground">{credential.description}</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Added: {new Date(credential.created_at).toLocaleDateString()}
|
||||
{t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -395,7 +374,7 @@ function CredentialsSettings() {
|
||||
variant={credential.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleGithubCredential(credential.id, credential.is_active)}
|
||||
>
|
||||
{credential.is_active ? 'Active' : 'Inactive'}
|
||||
{credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -410,6 +389,31 @@ function CredentialsSettings() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Information */}
|
||||
<div className="pt-6 border-t border-border/50">
|
||||
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
|
||||
<a
|
||||
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
v{version}
|
||||
</a>
|
||||
{updateAvailable && latestVersion && (
|
||||
<a
|
||||
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors not-italic font-medium"
|
||||
>
|
||||
<span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const CursorLogo = ({ className = 'w-5 h-5' }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<img src="/icons/cursor.svg" alt="Cursor" className={className} />
|
||||
<img
|
||||
src={isDarkMode ? "/icons/cursor-white.svg" : "/icons/cursor.svg"}
|
||||
alt="Cursor"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
@@ -9,6 +10,7 @@ import ImageViewer from './ImageViewer';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function FileTree({ selectedProject }) {
|
||||
const { t } = useTranslation();
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
||||
@@ -130,11 +132,11 @@ function FileTree({ selectedProject }) {
|
||||
const now = new Date();
|
||||
const past = new Date(date);
|
||||
const diffInSeconds = Math.floor((now - past) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
|
||||
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`;
|
||||
|
||||
if (diffInSeconds < 60) return t('fileTree.justNow');
|
||||
if (diffInSeconds < 3600) return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
|
||||
if (diffInSeconds < 86400) return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
|
||||
if (diffInSeconds < 2592000) return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
|
||||
return past.toLocaleDateString();
|
||||
};
|
||||
|
||||
@@ -348,7 +350,7 @@ function FileTree({ selectedProject }) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
Loading files...
|
||||
{t('fileTree.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -359,14 +361,14 @@ function FileTree({ selectedProject }) {
|
||||
{/* Header with Search and View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title="Simple view"
|
||||
title={t('fileTree.simpleView')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -375,7 +377,7 @@ function FileTree({ selectedProject }) {
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title="Compact view"
|
||||
title={t('fileTree.compactView')}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -384,7 +386,7 @@ function FileTree({ selectedProject }) {
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title="Detailed view"
|
||||
title={t('fileTree.detailedView')}
|
||||
>
|
||||
<TableProperties className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -396,7 +398,7 @@ function FileTree({ selectedProject }) {
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search files and folders..."
|
||||
placeholder={t('fileTree.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
@@ -407,7 +409,7 @@ function FileTree({ selectedProject }) {
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
title={t('fileTree.clearSearch')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -419,23 +421,23 @@ function FileTree({ selectedProject }) {
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && (
|
||||
<div className="px-4 pt-2 pb-1 border-b border-border">
|
||||
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-5">Name</div>
|
||||
<div className="col-span-2">Size</div>
|
||||
<div className="col-span-3">Modified</div>
|
||||
<div className="col-span-2">Permissions</div>
|
||||
<div className="col-span-5">{t('fileTree.name')}</div>
|
||||
<div className="col-span-2">{t('fileTree.size')}</div>
|
||||
<div className="col-span-3">{t('fileTree.modified')}</div>
|
||||
<div className="col-span-2">{t('fileTree.permissions')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Folder className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">No files found</h4>
|
||||
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noFilesFound')}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check if the project path is accessible
|
||||
{t('fileTree.checkProjectPath')}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredFiles.length === 0 && searchQuery ? (
|
||||
@@ -443,9 +445,9 @@ function FileTree({ selectedProject }) {
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">No matches found</h4>
|
||||
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noMatchesFound')}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Try a different search term or clear the search
|
||||
{t('fileTree.tryDifferentSearch')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -32,6 +32,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
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 [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
@@ -547,7 +548,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!commitMessage.trim() || selectedFiles.size === 0) return;
|
||||
|
||||
|
||||
setIsCommitting(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/commit', {
|
||||
@@ -559,7 +560,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
files: Array.from(selectedFiles)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Reset state after successful commit
|
||||
@@ -577,6 +578,32 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
}
|
||||
};
|
||||
|
||||
const createInitialCommit = async () => {
|
||||
setIsCreatingInitialCommit(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/initial-commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Initial commit failed:', data.error);
|
||||
alert(data.error || 'Failed to create initial commit');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating initial commit:', error);
|
||||
alert('Failed to create initial commit');
|
||||
} finally {
|
||||
setIsCreatingInitialCommit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
switch (status) {
|
||||
@@ -769,7 +796,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className={`flex items-center justify-between border-b border-gray-200 dark:border-gray-700 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
@@ -933,6 +960,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
{gitStatus.details && (
|
||||
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
|
||||
)}
|
||||
{/* // ! This can be a custom component that can be reused for " Tip: Create a new project..." as well */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
|
||||
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
|
||||
@@ -1156,11 +1184,36 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
|
||||
{/* File List - Changes View - Only show when git is available */}
|
||||
{activeView === 'changes' && !gitStatus?.error && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : gitStatus?.hasCommits === false ? (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<GitBranch className="w-16 h-16 mb-4 opacity-30 text-gray-400 dark:text-gray-500" />
|
||||
<h3 className="text-lg font-medium mb-2 text-gray-900 dark:text-white">No commits yet</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-md">
|
||||
This repository doesn't have any commits yet. Create your first commit to start tracking changes.
|
||||
</p>
|
||||
<button
|
||||
onClick={createInitialCommit}
|
||||
disabled={isCreatingInitialCommit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isCreatingInitialCommit ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<span>Creating Initial Commit...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitCommit className="w-4 h-4" />
|
||||
<span>Create Initial Commit</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||
<GitCommit className="w-12 h-12 mb-2 opacity-50" />
|
||||
@@ -1179,7 +1232,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
|
||||
{/* History View - Only show when git is available */}
|
||||
{activeView === 'history' && !gitStatus?.error && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
|
||||
131
src/components/GitSettings.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { GitBranch, Check } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function GitSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [gitName, setGitName] = useState('');
|
||||
const [gitEmail, setGitEmail] = useState('');
|
||||
const [gitConfigLoading, setGitConfigLoading] = useState(false);
|
||||
const [gitConfigSaving, setGitConfigSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadGitConfig();
|
||||
}, []);
|
||||
|
||||
const loadGitConfig = async () => {
|
||||
try {
|
||||
setGitConfigLoading(true);
|
||||
const response = await authenticatedFetch('/api/user/git-config');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGitName(data.gitName || '');
|
||||
setGitEmail(data.gitEmail || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading git config:', error);
|
||||
} finally {
|
||||
setGitConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveGitConfig = async () => {
|
||||
try {
|
||||
setGitConfigSaving(true);
|
||||
const response = await authenticatedFetch('/api/user/git-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gitName, gitEmail })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSaveStatus('success');
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setSaveStatus('error');
|
||||
console.error('Failed to save git config:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving git config:', error);
|
||||
setSaveStatus('error');
|
||||
} finally {
|
||||
setGitConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('git.description')}
|
||||
</p>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-card space-y-3">
|
||||
<div>
|
||||
<label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('git.name.label')}
|
||||
</label>
|
||||
<Input
|
||||
id="settings-git-name"
|
||||
type="text"
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
disabled={gitConfigLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('git.name.help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('git.email.label')}
|
||||
</label>
|
||||
<Input
|
||||
id="settings-git-email"
|
||||
type="email"
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
disabled={gitConfigLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('git.email.help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={saveGitConfig}
|
||||
disabled={gitConfigSaving || !gitName || !gitEmail}
|
||||
>
|
||||
{gitConfigSaving ? t('git.actions.saving') : t('git.actions.save')}
|
||||
</Button>
|
||||
|
||||
{saveStatus === 'success' && (
|
||||
<div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
|
||||
<Check className="w-4 h-4" />
|
||||
{t('git.status.success')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GitSettings;
|
||||
@@ -1,9 +1,55 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { X } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
function ImageViewer({ file, onClose }) {
|
||||
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl;
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setImageUrl(null);
|
||||
|
||||
const response = await authenticatedFetch(imagePath, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('Error loading image:', err);
|
||||
setError('Unable to load image');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [imagePath]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
@@ -23,22 +69,24 @@ function ImageViewer({ file, onClose }) {
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
|
||||
<img
|
||||
src={imagePath}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'block';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="text-center text-gray-500 dark:text-gray-400"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<p>Unable to load image</p>
|
||||
<p className="text-sm mt-2">{file.path}</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<p>Loading image…</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
|
||||
/>
|
||||
)}
|
||||
{!loading && !imageUrl && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<p>{error || 'Unable to load image'}</p>
|
||||
<p className="text-sm mt-2 break-all">{file.path}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
|
||||
@@ -51,4 +99,4 @@ function ImageViewer({ file, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageViewer;
|
||||
export default ImageViewer;
|
||||
|
||||
74
src/components/LanguageSelector.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Language Selector Component
|
||||
*
|
||||
* A dropdown component for selecting the application language.
|
||||
* Automatically updates the i18n language and persists to localStorage.
|
||||
*
|
||||
* Props:
|
||||
* @param {boolean} compact - If true, uses compact style (default: false)
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Languages } from 'lucide-react';
|
||||
import { languages } from '../i18n/languages';
|
||||
|
||||
function LanguageSelector({ compact = false }) {
|
||||
const { i18n, t } = useTranslation('settings');
|
||||
|
||||
const handleLanguageChange = (event) => {
|
||||
const newLanguage = event.target.value;
|
||||
i18n.changeLanguage(newLanguage);
|
||||
};
|
||||
|
||||
// Compact style for QuickSettingsPanel
|
||||
if (compact) {
|
||||
return (
|
||||
<div 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 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" />
|
||||
{t('account.language')}
|
||||
</span>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
className="w-[100px] text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full style for Settings page
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
{t('account.languageLabel')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('account.languageDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-36"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -1,32 +1,34 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { t } = useTranslation('auth');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
|
||||
if (!username || !password) {
|
||||
setError('Please enter both username and password');
|
||||
setError(t('errors.requiredFields'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
const result = await login(username, password);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -41,9 +43,9 @@ const LoginForm = () => {
|
||||
<MessageSquare className="w-8 h-8 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('login.title')}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Sign in to your Claude Code UI account
|
||||
{t('login.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +53,7 @@ const LoginForm = () => {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
|
||||
Username
|
||||
{t('login.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -59,7 +61,7 @@ const LoginForm = () => {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
placeholder={t('login.placeholders.username')}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -67,7 +69,7 @@ const LoginForm = () => {
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Password
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -75,7 +77,7 @@ const LoginForm = () => {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('login.placeholders.password')}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -92,7 +94,7 @@ const LoginForm = () => {
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
{isLoading ? t('login.loading') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
93
src/components/LoginModal.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { X } from 'lucide-react';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
/**
|
||||
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - Whether the modal is visible
|
||||
* @param {Function} props.onClose - Callback when modal is closed
|
||||
* @param {'claude'|'cursor'|'codex'} props.provider - Which CLI provider to authenticate with
|
||||
* @param {Object} props.project - Project object containing name and path information
|
||||
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
||||
* @param {string} props.customCommand - Optional custom command to override defaults
|
||||
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
|
||||
*/
|
||||
function LoginModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
provider = 'claude',
|
||||
project,
|
||||
onComplete,
|
||||
customCommand,
|
||||
isAuthenticated = false
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getCommand = () => {
|
||||
if (customCommand) return customCommand;
|
||||
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
|
||||
case 'cursor':
|
||||
return 'cursor-agent login';
|
||||
case 'codex':
|
||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
||||
default:
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return 'Claude CLI Login';
|
||||
case 'cursor':
|
||||
return 'Cursor CLI Login';
|
||||
case 'codex':
|
||||
return 'Codex CLI Login';
|
||||
default:
|
||||
return 'CLI Login';
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = (exitCode) => {
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] max-md:items-stretch max-md:justify-stretch">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{getTitle()}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close login modal"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={project}
|
||||
command={getCommand()}
|
||||
onComplete={handleComplete}
|
||||
minimal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginModal;
|
||||
@@ -11,7 +11,8 @@
|
||||
* No session protection logic is implemented here - it's purely a props bridge.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ChatInterface from './ChatInterface';
|
||||
import FileTree from './FileTree';
|
||||
import CodeEditor from './CodeEditor';
|
||||
@@ -28,16 +29,16 @@ import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function MainContent({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
ws,
|
||||
sendMessage,
|
||||
messages,
|
||||
function MainContent({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
ws,
|
||||
sendMessage,
|
||||
latestMessage,
|
||||
isMobile,
|
||||
isPWA,
|
||||
isPWA, // ! Unused
|
||||
onMenuClick,
|
||||
isLoading,
|
||||
onInputFocusChange,
|
||||
@@ -58,9 +59,14 @@ function MainContent({
|
||||
sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
|
||||
externalMessageUpdate // Trigger for external CLI updates to current session
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
const [editorWidth, setEditorWidth] = useState(600);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [editorExpanded, setEditorExpanded] = useState(false);
|
||||
const resizeRef = useRef(null);
|
||||
|
||||
// PRD Editor state
|
||||
const [showPRDEditor, setShowPRDEditor] = useState(false);
|
||||
@@ -127,6 +133,11 @@ function MainContent({
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setEditingFile(null);
|
||||
setEditorExpanded(false);
|
||||
};
|
||||
|
||||
const handleToggleEditorExpand = () => {
|
||||
setEditorExpanded(!editorExpanded);
|
||||
};
|
||||
|
||||
const handleTaskClick = (task) => {
|
||||
@@ -153,17 +164,63 @@ function MainContent({
|
||||
console.log('Update task status:', taskId, newStatus);
|
||||
refreshTasks?.();
|
||||
};
|
||||
|
||||
// Handle resize functionality
|
||||
const handleMouseDown = (e) => {
|
||||
if (isMobile) return; // Disable resize on mobile
|
||||
setIsResizing(true);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const container = resizeRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const newWidth = containerRect.right - e.clientX;
|
||||
|
||||
// Min width: 300px, Max width: 80% of container
|
||||
const minWidth = 300;
|
||||
const maxWidth = containerRect.width * 0.8;
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
setEditorWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -183,8 +240,8 @@ function MainContent({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Loading Claude Code UI</h2>
|
||||
<p>Setting up your workspace...</p>
|
||||
<h2 className="text-xl font-semibold mb-2">{t('mainContent.loading')}</h2>
|
||||
<p>{t('mainContent.settingUpWorkspace')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,12 +253,12 @@ function MainContent({
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -216,13 +273,13 @@ function MainContent({
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">Choose Your Project</h2>
|
||||
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">{t('mainContent.chooseProject')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
||||
Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.
|
||||
{t('mainContent.selectProjectDescription')}
|
||||
</p>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
💡 <strong>Tip:</strong> {isMobile ? 'Tap the menu button above to access projects' : 'Create a new project by clicking the folder icon in the sidebar'}
|
||||
💡 <strong>{t('mainContent.tip')}:</strong> {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,11 +291,11 @@ function MainContent({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with tabs */}
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
<div className="flex items-center justify-between relative">
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
@@ -246,48 +303,48 @@ function MainContent({
|
||||
e.preventDefault();
|
||||
onMenuClick();
|
||||
}}
|
||||
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button"
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-x-auto scrollbar-hide">
|
||||
{activeTab === 'chat' && selectedSession && (
|
||||
<div className="w-6 h-6 flex-shrink-0 flex items-center justify-center">
|
||||
<div className="w-5 h-5 flex-shrink-0 flex items-center justify-center">
|
||||
{selectedSession.__provider === 'cursor' ? (
|
||||
<CursorLogo className="w-5 h-5" />
|
||||
<CursorLogo className="w-4 h-4" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-5 h-5" />
|
||||
<ClaudeLogo className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{activeTab === 'chat' && selectedSession ? (
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white whitespace-nowrap overflow-x-auto scrollbar-hide">
|
||||
{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>
|
||||
{selectedProject.displayName}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'chat' && !selectedSession ? (
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
New Session
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
|
||||
{t('mainContent.newSession')}
|
||||
</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' :
|
||||
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
|
||||
{activeTab === 'files' ? t('mainContent.projectFiles') :
|
||||
activeTab === 'git' ? t('tabs.git') :
|
||||
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
|
||||
'Project'}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
@@ -302,7 +359,7 @@ function MainContent({
|
||||
{/* Modern Tab Navigation - Right Side */}
|
||||
<div className="flex-shrink-0 hidden sm:block">
|
||||
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
<Tooltip content="Chat" position="bottom">
|
||||
<Tooltip content={t('tabs.chat')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
|
||||
@@ -315,11 +372,11 @@ function MainContent({
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Chat</span>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.chat')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Shell" position="bottom">
|
||||
<Tooltip content={t('tabs.shell')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('shell')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
@@ -332,11 +389,11 @@ function MainContent({
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Shell</span>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.shell')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Files" position="bottom">
|
||||
<Tooltip content={t('tabs.files')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
@@ -349,11 +406,11 @@ function MainContent({
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Files</span>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.files')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Source Control" position="bottom">
|
||||
<Tooltip content={t('tabs.git')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('git')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
@@ -366,12 +423,12 @@ function MainContent({
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Source Control</span>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.git')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{shouldShowTasksTab && (
|
||||
<Tooltip content="Tasks" position="bottom">
|
||||
<Tooltip content={t('tabs.tasks')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
@@ -384,7 +441,7 @@ function MainContent({
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Tasks</span>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.tasks')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -409,16 +466,18 @@ function MainContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ChatInterface
|
||||
{/* Content Area with Right Sidebar */}
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* Main Content */}
|
||||
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editingFile ? 'mr-0' : ''} ${editorExpanded ? 'hidden' : ''}`}>
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ChatInterface
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
latestMessage={latestMessage}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
@@ -439,20 +498,25 @@ function MainContent({
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
isActive={activeTab === 'shell'}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||
</div>
|
||||
{activeTab === 'files' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'shell' && (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'git' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||
</div>
|
||||
)}
|
||||
{shouldShowTasksTab && (
|
||||
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
@@ -513,14 +577,49 @@ function MainContent({
|
||||
onClearLogs={() => setServerLogs([])}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
|
||||
{editingFile && !isMobile && (
|
||||
<>
|
||||
{/* Resize Handle - Hidden when expanded */}
|
||||
{!editorExpanded && (
|
||||
<div
|
||||
ref={resizeRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
|
||||
title="Drag to resize"
|
||||
>
|
||||
{/* Visual indicator on hover */}
|
||||
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Sidebar */}
|
||||
<div
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${editorExpanded ? 'flex-1' : ''}`}
|
||||
style={editorExpanded ? {} : { width: `${editorWidth}px` }}
|
||||
>
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={handleCloseEditor}
|
||||
projectPath={selectedProject?.path}
|
||||
isSidebar={true}
|
||||
isExpanded={editorExpanded}
|
||||
onToggleExpand={handleToggleEditorExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code Editor Modal */}
|
||||
{editingFile && (
|
||||
{/* Code Editor Modal for Mobile */}
|
||||
{editingFile && isMobile && (
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={handleCloseEditor}
|
||||
projectPath={selectedProject?.path}
|
||||
isSidebar={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
// Detect dark mode
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const navItems = [
|
||||
{
|
||||
id: 'chat',
|
||||
@@ -36,22 +34,11 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.mobile-nav-container {
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'} !important;
|
||||
}
|
||||
.mobile-nav-container:hover {
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'} !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
className={`mobile-nav-container fixed bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 z-50 ios-bottom-safe transform transition-transform duration-300 ease-in-out shadow-lg ${
|
||||
isInputFocused ? 'translate-y-full' : 'translate-y-0'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`fixed bottom-0 left-0 right-0 bg-background border-t border-border z-50 ios-bottom-safe transform transition-transform duration-300 ease-in-out shadow-lg ${
|
||||
isInputFocused ? 'translate-y-full' : 'translate-y-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-around py-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
@@ -81,7 +68,6 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
586
src/components/Onboarding.jsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import CodexLogo from './CodexLogo';
|
||||
import LoginModal from './LoginModal';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const Onboarding = ({ onComplete }) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [gitName, setGitName] = useState('');
|
||||
const [gitEmail, setGitEmail] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
|
||||
const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' });
|
||||
|
||||
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
const [cursorAuthStatus, setCursorAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
const [codexAuthStatus, setCodexAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const prevActiveLoginProviderRef = useRef(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadGitConfig();
|
||||
}, []);
|
||||
|
||||
const loadGitConfig = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/user/git-config');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.gitName) setGitName(data.gitName);
|
||||
if (data.gitEmail) setGitEmail(data.gitEmail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading git config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const prevProvider = prevActiveLoginProviderRef.current;
|
||||
prevActiveLoginProviderRef.current = activeLoginProvider;
|
||||
|
||||
const isInitialMount = prevProvider === undefined;
|
||||
const isModalClosing = prevProvider !== null && activeLoginProvider === null;
|
||||
|
||||
if (isInitialMount || isModalClosing) {
|
||||
checkClaudeAuthStatus();
|
||||
checkCursorAuthStatus();
|
||||
checkCodexAuthStatus();
|
||||
}
|
||||
}, [activeLoginProvider]);
|
||||
|
||||
const checkClaudeAuthStatus = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/claude/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setClaudeAuthStatus({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setClaudeAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: 'Failed to check authentication status'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Claude auth status:', error);
|
||||
setClaudeAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkCursorAuthStatus = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/cursor/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCursorAuthStatus({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setCursorAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: 'Failed to check authentication status'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Cursor auth status:', error);
|
||||
setCursorAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkCodexAuthStatus = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/codex/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCodexAuthStatus({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setCodexAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: 'Failed to check authentication status'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
setCodexAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClaudeLogin = () => setActiveLoginProvider('claude');
|
||||
const handleCursorLogin = () => setActiveLoginProvider('cursor');
|
||||
const handleCodexLogin = () => setActiveLoginProvider('codex');
|
||||
|
||||
const handleLoginComplete = (exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
if (activeLoginProvider === 'claude') {
|
||||
checkClaudeAuthStatus();
|
||||
} else if (activeLoginProvider === 'cursor') {
|
||||
checkCursorAuthStatus();
|
||||
} else if (activeLoginProvider === 'codex') {
|
||||
checkCodexAuthStatus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = async () => {
|
||||
setError('');
|
||||
|
||||
// Step 0: Git config validation and submission
|
||||
if (currentStep === 0) {
|
||||
if (!gitName.trim() || !gitEmail.trim()) {
|
||||
setError('Both git name and email are required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(gitEmail)) {
|
||||
setError('Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Save git config to backend (which will also apply git config --global)
|
||||
const response = await authenticatedFetch('/api/user/git-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gitName, gitEmail })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to save git configuration');
|
||||
}
|
||||
|
||||
setCurrentStep(currentStep + 1);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const handlePrevStep = () => {
|
||||
setError('');
|
||||
setCurrentStep(currentStep - 1);
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/user/complete-onboarding', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to complete onboarding');
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: 'Git Configuration',
|
||||
description: 'Set up your git identity for commits',
|
||||
icon: GitBranch,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
title: 'Connect Agents',
|
||||
description: 'Connect your AI coding assistants',
|
||||
icon: LogIn,
|
||||
required: false
|
||||
}
|
||||
];
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<GitBranch className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">Git Configuration</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Configure your git identity to ensure proper attribution for your commits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="gitName" className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||
<User className="w-4 h-4" />
|
||||
Git Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="gitName"
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This will be used as: git config --global user.name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="gitEmail" className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
Git Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="gitEmail"
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This will be used as: git config --global user.email
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">Connect Your AI Agents</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Login to one or more AI coding assistants. All are optional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Cards Grid */}
|
||||
<div className="space-y-3">
|
||||
{/* Claude */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
claudeAuthStatus.authenticated
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
|
||||
<ClaudeLogo size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
Claude Code
|
||||
{claudeAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{claudeAuthStatus.loading ? 'Checking...' :
|
||||
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!claudeAuthStatus.authenticated && !claudeAuthStatus.loading && (
|
||||
<button
|
||||
onClick={handleClaudeLogin}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cursor */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
cursorAuthStatus.authenticated
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||
<CursorLogo size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
Cursor
|
||||
{cursorAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{cursorAuthStatus.loading ? 'Checking...' :
|
||||
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!cursorAuthStatus.authenticated && !cursorAuthStatus.loading && (
|
||||
<button
|
||||
onClick={handleCursorLogin}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Codex */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
codexAuthStatus.authenticated
|
||||
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<CodexLogo className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
OpenAI Codex
|
||||
{codexAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{codexAuthStatus.loading ? 'Checking...' :
|
||||
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!codexAuthStatus.authenticated && !codexAuthStatus.loading && (
|
||||
<button
|
||||
onClick={handleCodexLogin}
|
||||
className="bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground pt-2">
|
||||
<p>You can configure these later in Settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isStepValid = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
|
||||
case 1:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${
|
||||
index < currentStep ? 'bg-green-500 border-green-500 text-white' :
|
||||
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
|
||||
'bg-background border-border text-muted-foreground'
|
||||
}`}>
|
||||
{index < currentStep ? (
|
||||
<Check className="w-6 h-6" />
|
||||
) : typeof step.icon === 'function' ? (
|
||||
<step.icon />
|
||||
) : (
|
||||
<step.icon className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p className={`text-sm font-medium ${
|
||||
index === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{step.title}
|
||||
</p>
|
||||
{step.required && (
|
||||
<span className="text-xs text-red-500">Required</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${
|
||||
index < currentStep ? 'bg-green-500' : 'bg-border'
|
||||
}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className="bg-card rounded-lg shadow-lg border border-border p-8">
|
||||
{renderStepContent()}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mt-6 p-4 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between mt-8 pt-6 border-t border-border">
|
||||
<button
|
||||
onClick={handlePrevStep}
|
||||
disabled={currentStep === 0 || isSubmitting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{currentStep < steps.length - 1 ? (
|
||||
<button
|
||||
onClick={handleNextStep}
|
||||
disabled={!isStepValid() || isSubmitting}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Completing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Complete Setup
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeLoginProvider && (
|
||||
<LoginModal
|
||||
isOpen={!!activeLoginProvider}
|
||||
onClose={() => setActiveLoginProvider(null)}
|
||||
provider={activeLoginProvider}
|
||||
project={selectedProject}
|
||||
onComplete={handleLoginComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
||||
875
src/components/ProjectCreationWizard.jsx
Normal file
@@ -0,0 +1,875 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const { t } = useTranslation();
|
||||
// Wizard state
|
||||
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
|
||||
const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing'
|
||||
|
||||
// Form state
|
||||
const [workspacePath, setWorkspacePath] = useState('');
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [selectedGithubToken, setSelectedGithubToken] = useState('');
|
||||
const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none'
|
||||
const [newGithubToken, setNewGithubToken] = useState('');
|
||||
|
||||
// UI state
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [availableTokens, setAvailableTokens] = useState([]);
|
||||
const [loadingTokens, setLoadingTokens] = useState(false);
|
||||
const [pathSuggestions, setPathSuggestions] = useState([]);
|
||||
const [showPathDropdown, setShowPathDropdown] = useState(false);
|
||||
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
|
||||
const [browserCurrentPath, setBrowserCurrentPath] = useState('~');
|
||||
const [browserFolders, setBrowserFolders] = useState([]);
|
||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [cloneProgress, setCloneProgress] = useState('');
|
||||
|
||||
// Load available GitHub tokens when needed
|
||||
useEffect(() => {
|
||||
if (step === 2 && workspaceType === 'new' && githubUrl) {
|
||||
loadGithubTokens();
|
||||
}
|
||||
}, [step, workspaceType, githubUrl]);
|
||||
|
||||
// Load path suggestions
|
||||
useEffect(() => {
|
||||
if (workspacePath.length > 2) {
|
||||
loadPathSuggestions(workspacePath);
|
||||
} else {
|
||||
setPathSuggestions([]);
|
||||
setShowPathDropdown(false);
|
||||
}
|
||||
}, [workspacePath]);
|
||||
|
||||
const loadGithubTokens = async () => {
|
||||
try {
|
||||
setLoadingTokens(true);
|
||||
const response = await api.get('/settings/credentials?type=github_token');
|
||||
const data = await response.json();
|
||||
|
||||
const activeTokens = (data.credentials || []).filter(t => t.is_active);
|
||||
setAvailableTokens(activeTokens);
|
||||
|
||||
// Auto-select first token if available
|
||||
if (activeTokens.length > 0 && !selectedGithubToken) {
|
||||
setSelectedGithubToken(activeTokens[0].id.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading GitHub tokens:', error);
|
||||
} finally {
|
||||
setLoadingTokens(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPathSuggestions = async (inputPath) => {
|
||||
try {
|
||||
// Extract the directory to browse (parent of input)
|
||||
const lastSlash = inputPath.lastIndexOf('/');
|
||||
const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~';
|
||||
|
||||
const response = await api.browseFilesystem(dirPath);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.suggestions) {
|
||||
// Filter suggestions based on the input, excluding exact match
|
||||
const filtered = data.suggestions.filter(s =>
|
||||
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
|
||||
s.path.toLowerCase() !== inputPath.toLowerCase()
|
||||
);
|
||||
setPathSuggestions(filtered.slice(0, 5));
|
||||
setShowPathDropdown(filtered.length > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading path suggestions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setError(null);
|
||||
|
||||
if (step === 1) {
|
||||
if (!workspaceType) {
|
||||
setError(t('projectWizard.errors.selectType'));
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
} else if (step === 2) {
|
||||
if (!workspacePath.trim()) {
|
||||
setError(t('projectWizard.errors.providePath'));
|
||||
return;
|
||||
}
|
||||
|
||||
// No validation for GitHub token - it's optional (only needed for private repos)
|
||||
setStep(3);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
setStep(step - 1);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
setCloneProgress('');
|
||||
|
||||
try {
|
||||
if (workspaceType === 'new' && githubUrl) {
|
||||
const params = new URLSearchParams({
|
||||
path: workspacePath.trim(),
|
||||
githubUrl: githubUrl.trim(),
|
||||
});
|
||||
|
||||
if (tokenMode === 'stored' && selectedGithubToken) {
|
||||
params.append('githubTokenId', selectedGithubToken);
|
||||
} else if (tokenMode === 'new' && newGithubToken) {
|
||||
params.append('newGithubToken', newGithubToken.trim());
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'progress') {
|
||||
setCloneProgress(data.message);
|
||||
} else if (data.type === 'complete') {
|
||||
eventSource.close();
|
||||
if (onProjectCreated) {
|
||||
onProjectCreated(data.project);
|
||||
}
|
||||
onClose();
|
||||
resolve();
|
||||
} else if (data.type === 'error') {
|
||||
eventSource.close();
|
||||
reject(new Error(data.message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
reject(new Error('Connection lost during clone'));
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
workspaceType,
|
||||
path: workspacePath.trim(),
|
||||
};
|
||||
|
||||
const response = await api.createWorkspace(payload);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
|
||||
}
|
||||
|
||||
if (onProjectCreated) {
|
||||
onProjectCreated(data.project);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace:', error);
|
||||
setError(error.message || t('projectWizard.errors.failedToCreate'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectPathSuggestion = (suggestion) => {
|
||||
setWorkspacePath(suggestion.path);
|
||||
setShowPathDropdown(false);
|
||||
};
|
||||
|
||||
const openFolderBrowser = async () => {
|
||||
setShowFolderBrowser(true);
|
||||
await loadBrowserFolders('~');
|
||||
};
|
||||
|
||||
const loadBrowserFolders = async (path) => {
|
||||
try {
|
||||
setLoadingFolders(true);
|
||||
const response = await api.browseFilesystem(path);
|
||||
const data = await response.json();
|
||||
setBrowserCurrentPath(data.path || path);
|
||||
setBrowserFolders(data.suggestions || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading folders:', error);
|
||||
} finally {
|
||||
setLoadingFolders(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectFolder = (folderPath, advanceToConfirm = false) => {
|
||||
setWorkspacePath(folderPath);
|
||||
setShowFolderBrowser(false);
|
||||
if (advanceToConfirm) {
|
||||
setStep(3);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToFolder = async (folderPath) => {
|
||||
await loadBrowserFolders(folderPath);
|
||||
};
|
||||
|
||||
const createNewFolder = async () => {
|
||||
if (!newFolderName.trim()) return;
|
||||
setCreatingFolder(true);
|
||||
setError(null);
|
||||
try {
|
||||
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
|
||||
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
|
||||
const response = await api.createFolder(folderPath);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
|
||||
}
|
||||
setNewFolderName('');
|
||||
setShowNewFolderInput(false);
|
||||
await loadBrowserFolders(data.path || folderPath);
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
|
||||
} finally {
|
||||
setCreatingFolder(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('projectWizard.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="px-6 pt-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm ${
|
||||
s < step
|
||||
? 'bg-green-500 text-white'
|
||||
: s === step
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{s < step ? <Check className="w-4 h-4" /> : s}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
|
||||
{s === 1 ? t('projectWizard.steps.type') : s === 2 ? t('projectWizard.steps.configure') : t('projectWizard.steps.confirm')}
|
||||
</span>
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded ${
|
||||
s < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 min-h-[300px]">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Choose workspace type */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('projectWizard.step1.question')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Existing Workspace */}
|
||||
<button
|
||||
onClick={() => setWorkspaceType('existing')}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||
workspaceType === 'existing'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FolderPlus className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('projectWizard.step1.existing.title')}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step1.existing.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* New Workspace */}
|
||||
<button
|
||||
onClick={() => setWorkspaceType('new')}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||
workspaceType === 'new'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<GitBranch className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('projectWizard.step1.new.title')}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step1.new.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Configure workspace */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* Workspace Path */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{workspaceType === 'existing' ? t('projectWizard.step2.existingPath') : t('projectWizard.step2.newPath')}
|
||||
</label>
|
||||
<div className="relative flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type="text"
|
||||
value={workspacePath}
|
||||
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
|
||||
className="w-full"
|
||||
/>
|
||||
{showPathDropdown && pathSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{pathSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => selectPathSuggestion(suggestion)}
|
||||
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={openFolderBrowser}
|
||||
className="px-3"
|
||||
title="Browse folders"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step2.existingHelp')
|
||||
: t('projectWizard.step2.newHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub URL (only for new workspace) */}
|
||||
{workspaceType === 'new' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('projectWizard.step2.githubUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(e) => setGithubUrl(e.target.value)}
|
||||
placeholder="https://github.com/username/repository"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projectWizard.step2.githubHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
|
||||
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||
{t('projectWizard.step2.githubAuth')}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step2.githubAuthHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingTokens ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('projectWizard.step2.loadingTokens')}
|
||||
</div>
|
||||
) : availableTokens.length > 0 ? (
|
||||
<>
|
||||
{/* Token Selection Tabs */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setTokenMode('stored')}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
tokenMode === 'stored'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('projectWizard.step2.storedToken')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTokenMode('new')}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
tokenMode === 'new'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('projectWizard.step2.newToken')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTokenMode('none');
|
||||
setSelectedGithubToken('');
|
||||
setNewGithubToken('');
|
||||
}}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
tokenMode === 'none'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('projectWizard.step2.nonePublic')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tokenMode === 'stored' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('projectWizard.step2.selectToken')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedGithubToken}
|
||||
onChange={(e) => setSelectedGithubToken(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">{t('projectWizard.step2.selectTokenPlaceholder')}</option>
|
||||
{availableTokens.map((token) => (
|
||||
<option key={token.id} value={token.id}>
|
||||
{token.credential_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : tokenMode === 'new' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('projectWizard.step2.newToken')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projectWizard.step2.tokenHelp')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{t('projectWizard.step2.publicRepoInfo')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('projectWizard.step2.optionalTokenPublic')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
placeholder={t('projectWizard.step2.tokenPublicPlaceholder')}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projectWizard.step2.noTokensHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('projectWizard.step3.reviewConfig')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.workspaceType')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{workspaceType === 'existing' ? t('projectWizard.step3.existingWorkspace') : t('projectWizard.step3.newWorkspace')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.path')}</span>
|
||||
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
|
||||
{workspacePath}
|
||||
</span>
|
||||
</div>
|
||||
{workspaceType === 'new' && githubUrl && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.cloneFrom')}</span>
|
||||
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
|
||||
{githubUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.authentication')}</span>
|
||||
<span className="text-xs text-gray-900 dark:text-white">
|
||||
{tokenMode === 'stored' && selectedGithubToken
|
||||
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||
: tokenMode === 'new' && newGithubToken
|
||||
? t('projectWizard.step3.usingProvidedToken')
|
||||
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
|
||||
? t('projectWizard.step3.sshKey', 'SSH Key')
|
||||
: t('projectWizard.step3.noAuthentication')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
{isCreating && cloneProgress ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
|
||||
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
|
||||
{cloneProgress}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step3.existingInfo')
|
||||
: githubUrl
|
||||
? t('projectWizard.step3.newWithClone')
|
||||
: t('projectWizard.step3.newEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={step === 1 ? onClose : handleBack}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{step === 1 ? (
|
||||
t('projectWizard.buttons.cancel')
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('projectWizard.buttons.back')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={step === 3 ? handleCreate : handleNext}
|
||||
disabled={isCreating || (step === 1 && !workspaceType)}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
|
||||
</>
|
||||
) : step === 3 ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
{t('projectWizard.buttons.createProject')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('projectWizard.buttons.next')}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder Browser Modal */}
|
||||
{showFolderBrowser && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[70] p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] border border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
{/* Browser Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Select Folder
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowHiddenFolders(!showHiddenFolders)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
showHiddenFolders
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={showHiddenFolders ? 'Hide hidden folders' : 'Show hidden folders'}
|
||||
>
|
||||
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
showNewFolderInput
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Create new folder"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowFolderBrowser(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Folder Input */}
|
||||
{showNewFolderInput && (
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="New folder name"
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createNewFolder();
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={createNewFolder}
|
||||
disabled={!newFolderName.trim() || creatingFolder}
|
||||
>
|
||||
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loadingFolders ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
|
||||
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
|
||||
let parentPath;
|
||||
if (lastSlash <= 0) {
|
||||
parentPath = '/';
|
||||
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
|
||||
parentPath = browserCurrentPath.substring(0, 3);
|
||||
} else {
|
||||
parentPath = browserCurrentPath.substring(0, lastSlash);
|
||||
}
|
||||
navigateToFolder(parentPath);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5 text-gray-400" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">..</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Folders */}
|
||||
{browserFolders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No subfolders found
|
||||
</div>
|
||||
) : (
|
||||
browserFolders
|
||||
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
|
||||
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||
.map((folder, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigateToFolder(folder.path)}
|
||||
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
||||
>
|
||||
<FolderPlus className="w-5 h-5 text-blue-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
|
||||
className="text-xs px-3"
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Browser Footer with Current Path */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/50 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Path:</span>
|
||||
<code className="text-sm font-mono text-gray-900 dark:text-white flex-1 truncate">
|
||||
{browserCurrentPath}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowFolderBrowser(false);
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
|
||||
>
|
||||
Use this folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectCreationWizard;
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import Onboarding from './Onboarding';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
@@ -24,7 +26,19 @@ const LoadingScreen = () => (
|
||||
);
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, isLoading, needsSetup } = useAuth();
|
||||
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
|
||||
|
||||
if (IS_PLATFORM) {
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <Onboarding onComplete={refreshOnboardingStatus} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
@@ -38,6 +52,10 @@ const ProtectedRoute = ({ children }) => {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <Onboarding onComplete={refreshOnboardingStatus} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Eye,
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Eye,
|
||||
Settings2,
|
||||
Moon,
|
||||
Sun,
|
||||
@@ -12,10 +12,13 @@ import {
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Languages
|
||||
Languages,
|
||||
GripVertical
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
|
||||
const QuickSettingsPanel = ({
|
||||
isOpen,
|
||||
@@ -32,17 +35,177 @@ const QuickSettingsPanel = ({
|
||||
onSendByCtrlEnterChange,
|
||||
isMobile
|
||||
}) => {
|
||||
const { t } = useTranslation('settings');
|
||||
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
|
||||
const [whisperMode, setWhisperMode] = useState(() => {
|
||||
return localStorage.getItem('whisperMode') || 'default';
|
||||
});
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// Draggable handle state
|
||||
const [handlePosition, setHandlePosition] = useState(() => {
|
||||
const saved = localStorage.getItem('quickSettingsHandlePosition');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.y ?? 50;
|
||||
} catch {
|
||||
// Remove corrupted data
|
||||
localStorage.removeItem('quickSettingsHandlePosition');
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
return 50; // Default to 50% (middle of screen)
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartY, setDragStartY] = useState(0);
|
||||
const [dragStartPosition, setDragStartPosition] = useState(0);
|
||||
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
|
||||
const handleRef = useRef(null);
|
||||
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
|
||||
const dragThreshold = 5; // Pixels to move before it's considered a drag
|
||||
|
||||
useEffect(() => {
|
||||
setLocalIsOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = () => {
|
||||
// Save handle position to localStorage when it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
|
||||
}, [handlePosition]);
|
||||
|
||||
// Calculate position from percentage
|
||||
const getPositionStyle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
// On mobile, convert percentage to pixels from bottom
|
||||
const bottomPixels = (window.innerHeight * handlePosition) / 100;
|
||||
return { bottom: `${bottomPixels}px` };
|
||||
} else {
|
||||
// On desktop, use top with percentage
|
||||
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
|
||||
}
|
||||
}, [handlePosition, isMobile]);
|
||||
|
||||
// Handle mouse/touch start
|
||||
const handleDragStart = useCallback((e) => {
|
||||
// Don't prevent default yet - we want to allow click if no drag happens
|
||||
e.stopPropagation();
|
||||
|
||||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||||
setDragStartY(clientY);
|
||||
setDragStartPosition(handlePosition);
|
||||
setHasMoved(false);
|
||||
setIsDragging(false); // Don't set dragging until threshold is passed
|
||||
}, [handlePosition]);
|
||||
|
||||
// Handle mouse/touch move
|
||||
const handleDragMove = useCallback((e) => {
|
||||
if (dragStartY === 0) return; // Not in a potential drag
|
||||
|
||||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||||
const deltaY = Math.abs(clientY - dragStartY);
|
||||
|
||||
// Check if we've moved past threshold
|
||||
if (!isDragging && deltaY > dragThreshold) {
|
||||
setIsDragging(true);
|
||||
setHasMoved(true);
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
// Prevent body scroll on mobile during drag
|
||||
if (e.type.includes('touch')) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDragging) return;
|
||||
|
||||
// Prevent scrolling on touch move
|
||||
if (e.type.includes('touch')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const actualDeltaY = clientY - dragStartY;
|
||||
|
||||
// For top-based positioning (desktop), moving down increases top percentage
|
||||
// For bottom-based positioning (mobile), we need to invert
|
||||
let percentageDelta;
|
||||
if (isMobile) {
|
||||
// On mobile, moving down should decrease bottom position (increase percentage from top)
|
||||
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
|
||||
} else {
|
||||
// On desktop, moving down should increase top position
|
||||
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
|
||||
}
|
||||
|
||||
let newPosition = dragStartPosition + percentageDelta;
|
||||
|
||||
// Apply constraints
|
||||
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
|
||||
|
||||
setHandlePosition(newPosition);
|
||||
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
|
||||
|
||||
// Handle mouse/touch end
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setDragStartY(0);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// Restore body scroll on mobile
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}, []);
|
||||
|
||||
// Cleanup body styles on unmount in case component unmounts while dragging
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set up global event listeners for drag
|
||||
useEffect(() => {
|
||||
if (dragStartY !== 0) {
|
||||
// Mouse events
|
||||
const handleMouseMove = (e) => handleDragMove(e);
|
||||
const handleMouseUp = () => handleDragEnd();
|
||||
|
||||
// Touch events
|
||||
const handleTouchMove = (e) => handleDragMove(e);
|
||||
const handleTouchEnd = () => handleDragEnd();
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
}, [dragStartY, handleDragMove, handleDragEnd]);
|
||||
|
||||
const handleToggle = (e) => {
|
||||
// Don't toggle if user was dragging
|
||||
if (hasMoved) {
|
||||
e.preventDefault();
|
||||
setHasMoved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = !localIsOpen;
|
||||
setLocalIsOpen(newState);
|
||||
onToggle(newState);
|
||||
@@ -50,28 +213,41 @@ const QuickSettingsPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull Tab */}
|
||||
<div
|
||||
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
|
||||
{/* Pull Tab - Combined drag handle and toggle button */}
|
||||
<button
|
||||
ref={handleRef}
|
||||
onClick={handleToggle}
|
||||
onMouseDown={(e) => {
|
||||
// Start drag on mousedown
|
||||
handleDragStart(e);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
// Start drag on touchstart
|
||||
handleDragStart(e);
|
||||
}}
|
||||
className={`fixed ${
|
||||
localIsOpen ? 'right-64' : 'right-0'
|
||||
} z-50 transition-all duration-150 ease-out`}
|
||||
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
|
||||
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
|
||||
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
} touch-none`}
|
||||
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
|
||||
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
|
||||
>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg"
|
||||
aria-label={localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
||||
>
|
||||
{localIsOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isDragging ? (
|
||||
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
||||
) : localIsOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 shadow-xl transform transition-transform duration-150 ease-out z-40 ${
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
|
||||
localIsOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
} ${isMobile ? 'h-screen' : ''}`}
|
||||
>
|
||||
@@ -80,110 +256,115 @@ const QuickSettingsPanel = ({
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
Quick Settings
|
||||
{t('quickSettings.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-white dark:bg-gray-900 ${isMobile ? 'pb-20' : ''}`}>
|
||||
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{/* Appearance Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Appearance</h4>
|
||||
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.appearance')}</h4>
|
||||
|
||||
<div 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 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">
|
||||
{isDarkMode ? <Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> : <Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />}
|
||||
Dark Mode
|
||||
{t('quickSettings.darkMode')}
|
||||
</span>
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div>
|
||||
<LanguageSelector compact={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Display Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Tool Display</h4>
|
||||
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.toolDisplay')}</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">
|
||||
<Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Auto-expand tools
|
||||
{t('quickSettings.autoExpandTools')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoExpandTools}
|
||||
onChange={(e) => onAutoExpandChange(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"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Show raw parameters
|
||||
{t('quickSettings.showRawParameters')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRawParameters}
|
||||
onChange={(e) => onShowRawParametersChange(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"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Show thinking
|
||||
{t('quickSettings.showThinking')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showThinking}
|
||||
onChange={(e) => onShowThinkingChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/* View Options */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">View Options</h4>
|
||||
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.viewOptions')}</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">
|
||||
<ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Auto-scroll to bottom
|
||||
{t('quickSettings.autoScrollToBottom')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScrollToBottom}
|
||||
onChange={(e) => onAutoScrollChange(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"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.inputSettings')}</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
|
||||
{t('quickSettings.sendByCtrlEnter')}
|
||||
</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"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 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.
|
||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||
</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>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.whisperDictation')}</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start 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">
|
||||
@@ -202,10 +383,10 @@ const QuickSettingsPanel = ({
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Default Mode
|
||||
{t('quickSettings.whisper.modes.default')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Direct transcription of your speech
|
||||
{t('quickSettings.whisper.modes.defaultDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -226,10 +407,10 @@ const QuickSettingsPanel = ({
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Prompt Enhancement
|
||||
{t('quickSettings.whisper.modes.prompt')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Transform rough ideas into clear, detailed AI prompts
|
||||
{t('quickSettings.whisper.modes.promptDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -250,10 +431,10 @@ const QuickSettingsPanel = ({
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Vibe Mode
|
||||
{t('quickSettings.whisper.modes.vibe')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format ideas as clear agent instructions with details
|
||||
{t('quickSettings.whisper.modes.vibeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
// CSS to remove xterm focus outline
|
||||
const xtermStyles = `
|
||||
.xterm .xterm-screen {
|
||||
outline: none !important;
|
||||
@@ -18,7 +19,6 @@ const xtermStyles = `
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
if (typeof document !== 'undefined') {
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.type = 'text/css';
|
||||
@@ -26,10 +26,8 @@ if (typeof document !== 'undefined') {
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
// Global store for shell sessions to persist across tab switches
|
||||
const shellSessions = new Map();
|
||||
|
||||
function Shell({ selectedProject, selectedSession, isActive, initialCommand, isPlainShell = false, onProcessComplete }) {
|
||||
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const terminalRef = useRef(null);
|
||||
const terminal = useRef(null);
|
||||
const fitAddon = useRef(null);
|
||||
@@ -40,177 +38,210 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
const [lastSessionId, setLastSessionId] = useState(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// Connect to shell function
|
||||
const connectToShell = () => {
|
||||
if (!isInitialized || isConnected || isConnecting) return;
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
// Start the WebSocket connection
|
||||
connectWebSocket();
|
||||
};
|
||||
const selectedProjectRef = useRef(selectedProject);
|
||||
const selectedSessionRef = useRef(selectedSession);
|
||||
const initialCommandRef = useRef(initialCommand);
|
||||
const isPlainShellRef = useRef(isPlainShell);
|
||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||
|
||||
// Disconnect from shell function
|
||||
const disconnectFromShell = () => {
|
||||
|
||||
useEffect(() => {
|
||||
selectedProjectRef.current = selectedProject;
|
||||
selectedSessionRef.current = selectedSession;
|
||||
initialCommandRef.current = initialCommand;
|
||||
isPlainShellRef.current = isPlainShell;
|
||||
onProcessCompleteRef.current = onProcessComplete;
|
||||
});
|
||||
|
||||
const connectWebSocket = useCallback(async () => {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
let wsUrl;
|
||||
|
||||
if (IS_PLATFORM) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell`;
|
||||
} else {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.error('No authentication token found for Shell WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
ws.current = new WebSocket(wsUrl);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
fitAddon.current.fit();
|
||||
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'init',
|
||||
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
|
||||
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
|
||||
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
|
||||
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows,
|
||||
initialCommand: initialCommandRef.current,
|
||||
isPlainShell: isPlainShellRef.current
|
||||
}));
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'output') {
|
||||
let output = data.data;
|
||||
|
||||
if (isPlainShellRef.current && onProcessCompleteRef.current) {
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
if (cleanOutput.includes('Process exited with code 0')) {
|
||||
onProcessCompleteRef.current(0);
|
||||
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
|
||||
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
|
||||
if (exitCode !== 0) {
|
||||
onProcessCompleteRef.current(exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.write(output);
|
||||
}
|
||||
} else if (data.type === 'url_open') {
|
||||
window.open(data.url, '_blank');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Shell] Error handling WebSocket message:', error, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
terminal.current.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
} catch (error) {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [isConnecting, isConnected]);
|
||||
|
||||
const connectToShell = useCallback(() => {
|
||||
if (!isInitialized || isConnected || isConnecting) return;
|
||||
setIsConnecting(true);
|
||||
connectWebSocket();
|
||||
}, [isInitialized, isConnected, isConnecting, connectWebSocket]);
|
||||
|
||||
const disconnectFromShell = useCallback(() => {
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
ws.current = null;
|
||||
}
|
||||
|
||||
// Clear terminal content completely
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
|
||||
terminal.current.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sessionDisplayName = useMemo(() => {
|
||||
if (!selectedSession) return null;
|
||||
return selectedSession.__provider === 'cursor'
|
||||
? (selectedSession.name || 'Untitled Session')
|
||||
: (selectedSession.summary || 'New Session');
|
||||
}, [selectedSession]);
|
||||
|
||||
const sessionDisplayNameShort = useMemo(() => {
|
||||
if (!sessionDisplayName) return null;
|
||||
return sessionDisplayName.slice(0, 30);
|
||||
}, [sessionDisplayName]);
|
||||
|
||||
const sessionDisplayNameLong = useMemo(() => {
|
||||
if (!sessionDisplayName) return null;
|
||||
return sessionDisplayName.slice(0, 50);
|
||||
}, [sessionDisplayName]);
|
||||
|
||||
// Restart shell function
|
||||
const restartShell = () => {
|
||||
setIsRestarting(true);
|
||||
|
||||
// Clear ALL session storage for this project to force fresh start
|
||||
const sessionKeys = Array.from(shellSessions.keys()).filter(key =>
|
||||
key.includes(selectedProject.name)
|
||||
);
|
||||
sessionKeys.forEach(key => shellSessions.delete(key));
|
||||
|
||||
|
||||
// Close existing WebSocket
|
||||
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
ws.current = null;
|
||||
}
|
||||
|
||||
// Clear and dispose existing terminal
|
||||
|
||||
if (terminal.current) {
|
||||
|
||||
// Dispose terminal immediately without writing text
|
||||
terminal.current.dispose();
|
||||
terminal.current = null;
|
||||
fitAddon.current = null;
|
||||
}
|
||||
|
||||
// Reset states
|
||||
|
||||
setIsConnected(false);
|
||||
setIsInitialized(false);
|
||||
|
||||
|
||||
// Force re-initialization after cleanup
|
||||
|
||||
setTimeout(() => {
|
||||
setIsRestarting(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Watch for session changes and restart shell
|
||||
useEffect(() => {
|
||||
const currentSessionId = selectedSession?.id || null;
|
||||
|
||||
|
||||
// Disconnect when session changes (user will need to manually reconnect)
|
||||
|
||||
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
|
||||
|
||||
// Disconnect from current shell
|
||||
disconnectFromShell();
|
||||
|
||||
// Clear stored sessions for this project
|
||||
const allKeys = Array.from(shellSessions.keys());
|
||||
allKeys.forEach(key => {
|
||||
if (key.includes(selectedProject.name)) {
|
||||
shellSessions.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setLastSessionId(currentSessionId);
|
||||
}, [selectedSession?.id, isInitialized]);
|
||||
}, [selectedSession?.id, isInitialized, disconnectFromShell]);
|
||||
|
||||
// Initialize terminal when component mounts
|
||||
useEffect(() => {
|
||||
|
||||
if (!terminalRef.current || !selectedProject || isRestarting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create session key for this project/session combination
|
||||
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
|
||||
|
||||
// Check if we have an existing session
|
||||
const existingSession = shellSessions.get(sessionKey);
|
||||
if (existingSession && !terminal.current) {
|
||||
|
||||
try {
|
||||
// Reuse existing terminal
|
||||
terminal.current = existingSession.terminal;
|
||||
fitAddon.current = existingSession.fitAddon;
|
||||
ws.current = existingSession.ws;
|
||||
setIsConnected(existingSession.isConnected);
|
||||
|
||||
// Reattach to DOM - dispose existing element first if needed
|
||||
if (terminal.current.element && terminal.current.element.parentNode) {
|
||||
terminal.current.element.parentNode.removeChild(terminal.current.element);
|
||||
}
|
||||
|
||||
terminal.current.open(terminalRef.current);
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
// Send terminal size to backend after reattaching
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
} catch (error) {
|
||||
// Clear the broken session and continue to create a new one
|
||||
shellSessions.delete(sessionKey);
|
||||
terminal.current = null;
|
||||
fitAddon.current = null;
|
||||
ws.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (terminal.current) {
|
||||
if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Initialize new terminal
|
||||
terminal.current = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true, // Required for clipboard addon
|
||||
allowProposedApi: true,
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
scrollback: 10000,
|
||||
tabStopWidth: 4,
|
||||
// Enable full color support
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
// Enhanced theme with full 16-color ANSI support + true colors
|
||||
macOptionClickForcesSelection: true,
|
||||
theme: {
|
||||
// Basic colors
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selection: '#264f78',
|
||||
selectionForeground: '#ffffff',
|
||||
|
||||
// Standard ANSI colors (0-7)
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
@@ -219,8 +250,6 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
|
||||
// Bright ANSI colors (8-15)
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
@@ -229,10 +258,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff',
|
||||
|
||||
// Extended colors for better Claude output
|
||||
extendedAnsi: [
|
||||
// 16-color palette extension for 256-color support
|
||||
'#000000', '#800000', '#008000', '#808000',
|
||||
'#000080', '#800080', '#008080', '#c0c0c0',
|
||||
'#808080', '#ff0000', '#00ff00', '#ffff00',
|
||||
@@ -242,35 +268,27 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
});
|
||||
|
||||
fitAddon.current = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const webglAddon = new WebglAddon();
|
||||
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
terminal.current.loadAddon(fitAddon.current);
|
||||
terminal.current.loadAddon(clipboardAddon);
|
||||
|
||||
terminal.current.loadAddon(webLinksAddon);
|
||||
// Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
|
||||
|
||||
try {
|
||||
terminal.current.loadAddon(webglAddon);
|
||||
} catch (error) {
|
||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||
}
|
||||
|
||||
|
||||
terminal.current.open(terminalRef.current);
|
||||
|
||||
// Wait for terminal to be fully rendered, then fit
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Add keyboard shortcuts for copy/paste
|
||||
terminal.current.attachCustomKeyEventHandler((event) => {
|
||||
// Ctrl+C or Cmd+C for copy (when text is selected)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V or Cmd+V for paste
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||
navigator.clipboard.readText().then(text => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
@@ -279,20 +297,16 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
data: text
|
||||
}));
|
||||
}
|
||||
}).catch(err => {
|
||||
// Failed to read clipboard
|
||||
});
|
||||
}).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Ensure terminal takes full space and notify backend of size
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
// Send terminal size to backend after fitting
|
||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
@@ -302,10 +316,8 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setIsInitialized(true);
|
||||
|
||||
// Handle terminal input
|
||||
setIsInitialized(true);
|
||||
terminal.current.onData((data) => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
@@ -315,12 +327,10 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
}
|
||||
});
|
||||
|
||||
// Add resize observer to handle container size changes
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
setTimeout(() => {
|
||||
fitAddon.current.fit();
|
||||
// Send updated terminal size to backend after resize
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
@@ -338,191 +348,23 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
|
||||
// Store session for reuse instead of disposing
|
||||
if (terminal.current && selectedProject) {
|
||||
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
|
||||
|
||||
try {
|
||||
shellSessions.set(sessionKey, {
|
||||
terminal: terminal.current,
|
||||
fitAddon: fitAddon.current,
|
||||
ws: ws.current,
|
||||
isConnected: isConnected
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
|
||||
ws.current.close();
|
||||
}
|
||||
ws.current = null;
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.dispose();
|
||||
terminal.current = null;
|
||||
}
|
||||
};
|
||||
}, [terminalRef.current, selectedProject, selectedSession, isRestarting]);
|
||||
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting]);
|
||||
|
||||
// Fit terminal when tab becomes active
|
||||
useEffect(() => {
|
||||
if (!isActive || !isInitialized) return;
|
||||
|
||||
// Fit terminal when tab becomes active and notify backend
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
// Send terminal size to backend after tab activation
|
||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}, [isActive, isInitialized]);
|
||||
|
||||
// WebSocket connection function (called manually)
|
||||
const connectWebSocket = async () => {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.error('No authentication token found for Shell WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch server configuration to get the correct WebSocket URL
|
||||
let wsBaseUrl;
|
||||
try {
|
||||
const configResponse = await fetch('/api/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const config = await configResponse.json();
|
||||
wsBaseUrl = config.wsUrl;
|
||||
|
||||
// If the config returns localhost but we're not on localhost, use current host but with API server port
|
||||
if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// For development, API server is typically on port 3002 when Vite is on 3001
|
||||
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
|
||||
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
|
||||
}
|
||||
} catch (error) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// For development, API server is typically on port 3002 when Vite is on 3001
|
||||
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
|
||||
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
|
||||
}
|
||||
|
||||
// Include token in WebSocket URL as query parameter
|
||||
const wsUrl = `${wsBaseUrl}/shell?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws.current = new WebSocket(wsUrl);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
|
||||
// Wait for terminal to be ready, then fit and send dimensions
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
// Force a fit to ensure proper dimensions
|
||||
fitAddon.current.fit();
|
||||
|
||||
// Wait a bit more for fit to complete, then send dimensions
|
||||
setTimeout(() => {
|
||||
const initPayload = {
|
||||
type: 'init',
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
sessionId: isPlainShell ? null : selectedSession?.id,
|
||||
hasSession: isPlainShell ? false : !!selectedSession,
|
||||
provider: isPlainShell ? 'plain-shell' : (selectedSession?.__provider || 'claude'),
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows,
|
||||
initialCommand: initialCommand,
|
||||
isPlainShell: isPlainShell
|
||||
};
|
||||
|
||||
console.log('Shell init payload:', initPayload);
|
||||
|
||||
ws.current.send(JSON.stringify(initPayload));
|
||||
|
||||
// Also send resize message immediately after init
|
||||
setTimeout(() => {
|
||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}, 100);
|
||||
}, 50);
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'output') {
|
||||
// Check for URLs in the output and make them clickable
|
||||
const urlRegex = /(https?:\/\/[^\s\x1b\x07]+)/g;
|
||||
let output = data.data;
|
||||
|
||||
// Find URLs in the text (excluding ANSI escape sequences)
|
||||
const urls = [];
|
||||
let match;
|
||||
while ((match = urlRegex.exec(output.replace(/\x1b\[[0-9;]*m/g, ''))) !== null) {
|
||||
urls.push(match[1]);
|
||||
}
|
||||
|
||||
if (isPlainShell && onProcessComplete) {
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI codes
|
||||
if (cleanOutput.includes('Process exited with code 0')) {
|
||||
onProcessComplete(0); // Success
|
||||
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
|
||||
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
|
||||
if (exitCode !== 0) {
|
||||
onProcessComplete(exitCode); // Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If URLs found, log them for potential opening
|
||||
|
||||
terminal.current.write(output);
|
||||
} else if (data.type === 'url_open') {
|
||||
// Handle explicit URL opening requests from server
|
||||
window.open(data.url, '_blank');
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
|
||||
// Clear terminal content when connection closes
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
|
||||
}
|
||||
|
||||
// Don't auto-reconnect anymore - user must manually connect
|
||||
};
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
} catch (error) {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
|
||||
connectToShell();
|
||||
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
@@ -533,38 +375,40 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
|
||||
<p>Choose a project to open an interactive shell in that directory</p>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
|
||||
<p>{t('shell.selectProject.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (minimal) {
|
||||
return (
|
||||
<div className="h-full w-full bg-gray-900">
|
||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-900 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<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 && (() => {
|
||||
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-blue-300">
|
||||
({sessionDisplayNameShort}...)
|
||||
</span>
|
||||
)}
|
||||
{!selectedSession && (
|
||||
<span className="text-xs text-gray-400">(New Session)</span>
|
||||
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
|
||||
)}
|
||||
{!isInitialized && (
|
||||
<span className="text-xs text-yellow-400">(Initializing...)</span>
|
||||
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
|
||||
)}
|
||||
{isRestarting && (
|
||||
<span className="text-xs text-blue-400">(Restarting...)</span>
|
||||
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -572,84 +416,75 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
<button
|
||||
onClick={disconnectFromShell}
|
||||
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
|
||||
title="Disconnect from shell"
|
||||
title={t('shell.actions.disconnectTitle')}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>Disconnect</span>
|
||||
<span>{t('shell.actions.disconnect')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button
|
||||
onClick={restartShell}
|
||||
disabled={isRestarting || isConnected}
|
||||
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
title="Restart Shell (disconnect first)"
|
||||
title={t('shell.actions.restartTitle')}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Restart</span>
|
||||
<span>{t('shell.actions.restart')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 p-2 overflow-hidden relative">
|
||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||
|
||||
{/* Loading state */}
|
||||
|
||||
{!isInitialized && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||
<div className="text-white">Loading terminal...</div>
|
||||
<div className="text-white">{t('shell.loading')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect button when not connected */}
|
||||
|
||||
{isInitialized && !isConnected && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<button
|
||||
onClick={connectToShell}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
|
||||
title="Connect to shell"
|
||||
title={t('shell.actions.connectTitle')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Continue in Shell</span>
|
||||
<span>{t('shell.actions.connect')}</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
selectedSession ?
|
||||
(() => {
|
||||
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'
|
||||
{isPlainShell ?
|
||||
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
||||
selectedSession ?
|
||||
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
|
||||
t('shell.startSession')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connecting state */}
|
||||
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||
<span className="text-base font-medium">Connecting to shell...</span>
|
||||
<span className="text-base font-medium">{t('shell.connecting')}</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
||||
{isPlainShell ?
|
||||
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
||||
t('shell.startCli', { projectName: selectedProject.displayName })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import Shell from './Shell.jsx';
|
||||
|
||||
/**
|
||||
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
|
||||
* Provides a flexible API for both standalone and session-based usage.
|
||||
*
|
||||
*
|
||||
* @param {Object} project - Project object with name, fullPath/path, displayName
|
||||
* @param {Object} session - Session object (optional, for tab usage)
|
||||
* @param {string} command - Initial command to run (optional)
|
||||
* @param {boolean} isActive - Whether the shell is active (for tab usage, default: true)
|
||||
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
|
||||
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
|
||||
* @param {function} onComplete - Callback when process completes (receives exitCode)
|
||||
@@ -17,33 +16,32 @@ import Shell from './Shell.jsx';
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} showHeader - Whether to show custom header (default: true)
|
||||
* @param {boolean} compact - Use compact layout (default: false)
|
||||
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
|
||||
*/
|
||||
function StandaloneShell({
|
||||
project,
|
||||
session = null,
|
||||
command = null,
|
||||
isActive = true,
|
||||
isPlainShell = null, // Auto-detect: true if command provided, false if session provided
|
||||
isPlainShell = null,
|
||||
autoConnect = true,
|
||||
onComplete = null,
|
||||
onClose = null,
|
||||
title = null,
|
||||
className = "",
|
||||
showHeader = true,
|
||||
compact = false
|
||||
compact = false,
|
||||
minimal = false
|
||||
}) {
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
|
||||
// Auto-detect isPlainShell based on props
|
||||
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
|
||||
|
||||
// Handle process completion
|
||||
const handleProcessComplete = (exitCode) => {
|
||||
const handleProcessComplete = useCallback((exitCode) => {
|
||||
setIsCompleted(true);
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
};
|
||||
}, [onComplete]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
@@ -62,9 +60,9 @@ function StandaloneShell({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
<div className={`h-full w-full flex flex-col ${className}`}>
|
||||
{/* Optional custom header */}
|
||||
{showHeader && title && (
|
||||
{!minimal && showHeader && title && (
|
||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -89,14 +87,15 @@ function StandaloneShell({
|
||||
)}
|
||||
|
||||
{/* Shell component wrapper */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 w-full min-h-0">
|
||||
<Shell
|
||||
selectedProject={project}
|
||||
selectedSession={session}
|
||||
isActive={isActive}
|
||||
initialCommand={command}
|
||||
isPlainShell={shouldUsePlainShell}
|
||||
onProcessComplete={handleProcessComplete}
|
||||
minimal={minimal}
|
||||
autoConnect={minimal ? true : autoConnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
109
src/components/TasksSettings.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Zap } from 'lucide-react';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function TasksSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const {
|
||||
tasksEnabled,
|
||||
setTasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
isCheckingInstallation
|
||||
} = useTasksSettings();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Installation Status Check */}
|
||||
{isCheckingInstallation ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TaskMaster Not Installed Warning */}
|
||||
{!isTaskMasterInstalled && (
|
||||
<div className="bg-orange-50 dark:bg-orange-950/50 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
|
||||
{t('tasks.notInstalled.title')}
|
||||
</div>
|
||||
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
|
||||
<p>{t('tasks.notInstalled.description')}</p>
|
||||
|
||||
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
|
||||
<code>{t('tasks.notInstalled.installCommand')}</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('tasks.notInstalled.viewOnGitHub')}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-xs">
|
||||
<li>{t('tasks.notInstalled.steps.restart')}</li>
|
||||
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
|
||||
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TaskMaster Settings */}
|
||||
{isTaskMasterInstalled && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">
|
||||
{t('tasks.settings.enableLabel')}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{t('tasks.settings.enableDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tasksEnabled}
|
||||
onChange={(e) => setTasksEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TasksSettings;
|
||||
183
src/components/ThinkingModeSelector.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Brain, Zap, Sparkles, Atom, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const thinkingModes = [
|
||||
{
|
||||
id: 'none',
|
||||
name: 'Standard',
|
||||
description: 'Regular Claude response',
|
||||
icon: null,
|
||||
prefix: '',
|
||||
color: 'text-gray-600'
|
||||
},
|
||||
{
|
||||
id: 'think',
|
||||
name: 'Think',
|
||||
description: 'Basic extended thinking',
|
||||
icon: Brain,
|
||||
prefix: 'think',
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 'think-hard',
|
||||
name: 'Think Hard',
|
||||
description: 'More thorough evaluation',
|
||||
icon: Zap,
|
||||
prefix: 'think hard',
|
||||
color: 'text-purple-600'
|
||||
},
|
||||
{
|
||||
id: 'think-harder',
|
||||
name: 'Think Harder',
|
||||
description: 'Deep analysis with alternatives',
|
||||
icon: Sparkles,
|
||||
prefix: 'think harder',
|
||||
color: 'text-indigo-600'
|
||||
},
|
||||
{
|
||||
id: 'ultrathink',
|
||||
name: 'Ultrathink',
|
||||
description: 'Maximum thinking budget',
|
||||
icon: Atom,
|
||||
prefix: 'ultrathink',
|
||||
color: 'text-red-600'
|
||||
}
|
||||
];
|
||||
|
||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
// Mapping from mode ID to translation key
|
||||
const modeKeyMap = {
|
||||
'think-hard': 'thinkHard',
|
||||
'think-harder': 'thinkHarder'
|
||||
};
|
||||
// Create translated modes for display
|
||||
const translatedModes = thinkingModes.map(mode => {
|
||||
const modeKey = modeKeyMap[mode.id] || mode.id;
|
||||
return {
|
||||
...mode,
|
||||
name: t(`thinkingMode.modes.${modeKey}.name`),
|
||||
description: t(`thinkingMode.modes.${modeKey}.description`),
|
||||
prefix: t(`thinkingMode.modes.${modeKey}.prefix`)
|
||||
};
|
||||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
||||
const IconComponent = currentMode.icon || Brain;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-10 h-10 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition-all duration-200 ${
|
||||
selectedMode === 'none'
|
||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
||||
}`}
|
||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
||||
>
|
||||
<IconComponent className={`w-5 h-5 ${currentMode.color}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('thinkingMode.selector.title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('thinkingMode.selector.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
{translatedModes.map((mode) => {
|
||||
const ModeIcon = mode.icon;
|
||||
const isSelected = mode.id === selectedMode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => {
|
||||
onModeChange(mode.id);
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
|
||||
isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
|
||||
{ModeIcon ? <ModeIcon className="w-5 h-5" /> : <div className="w-5 h-5" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium text-sm ${
|
||||
isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{mode.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded">
|
||||
{t('thinkingMode.selector.active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{mode.description}
|
||||
</p>
|
||||
{mode.prefix && (
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded mt-1 inline-block">
|
||||
{mode.prefix}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThinkingModeSelector;
|
||||
export { thinkingModes };
|
||||
126
src/components/settings/AccountContent.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import ClaudeLogo from '../ClaudeLogo';
|
||||
import CursorLogo from '../CursorLogo';
|
||||
import CodexLogo from '../CodexLogo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const agentConfig = {
|
||||
claude: {
|
||||
name: 'Claude',
|
||||
description: 'Anthropic Claude AI assistant',
|
||||
Logo: ClaudeLogo,
|
||||
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderClass: 'border-blue-200 dark:border-blue-800',
|
||||
textClass: 'text-blue-900 dark:text-blue-100',
|
||||
subtextClass: 'text-blue-700 dark:text-blue-300',
|
||||
buttonClass: 'bg-blue-600 hover:bg-blue-700',
|
||||
},
|
||||
cursor: {
|
||||
name: 'Cursor',
|
||||
description: 'Cursor AI-powered code editor',
|
||||
Logo: CursorLogo,
|
||||
bgClass: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
borderClass: 'border-purple-200 dark:border-purple-800',
|
||||
textClass: 'text-purple-900 dark:text-purple-100',
|
||||
subtextClass: 'text-purple-700 dark:text-purple-300',
|
||||
buttonClass: 'bg-purple-600 hover:bg-purple-700',
|
||||
},
|
||||
codex: {
|
||||
name: 'Codex',
|
||||
description: 'OpenAI Codex AI assistant',
|
||||
Logo: CodexLogo,
|
||||
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
|
||||
borderClass: 'border-gray-300 dark:border-gray-600',
|
||||
textClass: 'text-gray-900 dark:text-gray-100',
|
||||
subtextClass: 'text-gray-700 dark:text-gray-300',
|
||||
buttonClass: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AccountContent({ agent, authStatus, onLogin }) {
|
||||
const { t } = useTranslation('settings');
|
||||
const config = agentConfig[agent];
|
||||
const { Logo } = config;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Logo className="w-6 h-6" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground">{config.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t(`agents.account.${agent}.description`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
|
||||
<div className="space-y-4">
|
||||
{/* Connection Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${config.textClass}`}>
|
||||
{t('agents.connectionStatus')}
|
||||
</div>
|
||||
<div className={`text-sm ${config.subtextClass}`}>
|
||||
{authStatus?.loading ? (
|
||||
t('agents.authStatus.checkingAuth')
|
||||
) : authStatus?.authenticated ? (
|
||||
t('agents.authStatus.loggedInAs', { email: authStatus.email || t('agents.authStatus.authenticatedUser') })
|
||||
) : (
|
||||
t('agents.authStatus.notConnected')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{authStatus?.loading ? (
|
||||
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
|
||||
{t('agents.authStatus.checking')}
|
||||
</Badge>
|
||||
) : authStatus?.authenticated ? (
|
||||
<Badge variant="success" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
{t('agents.authStatus.connected')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
||||
{t('agents.authStatus.disconnected')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className={`font-medium ${config.textClass}`}>
|
||||
{authStatus?.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
|
||||
</div>
|
||||
<div className={`text-sm ${config.subtextClass}`}>
|
||||
{authStatus?.authenticated
|
||||
? t('agents.login.reAuthDescription')
|
||||
: t('agents.login.description', { agent: config.name })}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onLogin}
|
||||
className={`${config.buttonClass} text-white`}
|
||||
size="sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
{authStatus?.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authStatus?.error && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
{t('agents.error', { error: authStatus.error })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/settings/AgentListItem.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import ClaudeLogo from '../ClaudeLogo';
|
||||
import CursorLogo from '../CursorLogo';
|
||||
import CodexLogo from '../CodexLogo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const agentConfig = {
|
||||
claude: {
|
||||
name: 'Claude',
|
||||
color: 'blue',
|
||||
Logo: ClaudeLogo,
|
||||
},
|
||||
cursor: {
|
||||
name: 'Cursor',
|
||||
color: 'purple',
|
||||
Logo: CursorLogo,
|
||||
},
|
||||
codex: {
|
||||
name: 'Codex',
|
||||
color: 'gray',
|
||||
Logo: CodexLogo,
|
||||
},
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
blue: {
|
||||
border: 'border-l-blue-500 md:border-l-blue-500',
|
||||
borderBottom: 'border-b-blue-500',
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
dot: 'bg-blue-500',
|
||||
},
|
||||
purple: {
|
||||
border: 'border-l-purple-500 md:border-l-purple-500',
|
||||
borderBottom: 'border-b-purple-500',
|
||||
bg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
dot: 'bg-purple-500',
|
||||
},
|
||||
gray: {
|
||||
border: 'border-l-gray-700 dark:border-l-gray-300',
|
||||
borderBottom: 'border-b-gray-700 dark:border-b-gray-300',
|
||||
bg: 'bg-gray-100 dark:bg-gray-800/50',
|
||||
dot: 'bg-gray-700 dark:bg-gray-300',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AgentListItem({ agentId, authStatus, isSelected, onClick, isMobile = false }) {
|
||||
const { t } = useTranslation('settings');
|
||||
const config = agentConfig[agentId];
|
||||
const colors = colorClasses[config.color];
|
||||
const { Logo } = config;
|
||||
|
||||
// Mobile: horizontal layout with bottom border
|
||||
if (isMobile) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${
|
||||
isSelected
|
||||
? `${colors.borderBottom} ${colors.bg}`
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Logo className="w-5 h-5" />
|
||||
<span className="text-xs font-medium text-foreground">{config.name}</span>
|
||||
{authStatus?.authenticated && (
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: vertical layout with left border
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left p-3 border-l-4 transition-colors ${
|
||||
isSelected
|
||||
? `${colors.border} ${colors.bg}`
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Logo className="w-4 h-4" />
|
||||
<span className="font-medium text-foreground">{config.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground pl-6">
|
||||
{authStatus?.loading ? (
|
||||
<span className="text-gray-400">{t('agents.authStatus.checking')}</span>
|
||||
) : authStatus?.authenticated ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||
<span className="truncate max-w-[120px]" title={authStatus.email}>
|
||||
{authStatus.email || t('agents.authStatus.connected')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
|
||||
<span>{t('agents.authStatus.notConnected')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
319
src/components/settings/McpServersContent.jsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Server, Plus, Edit3, Trash2, Terminal, Globe, Zap, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const getTransportIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'stdio': return <Terminal className="w-4 h-4" />;
|
||||
case 'sse': return <Zap className="w-4 h-4" />;
|
||||
case 'http': return <Globe className="w-4 h-4" />;
|
||||
default: return <Server className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Claude MCP Servers
|
||||
function ClaudeMcpServers({
|
||||
servers,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTest,
|
||||
onDiscoverTools,
|
||||
testResults,
|
||||
serverTools,
|
||||
toolsLoading,
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('mcpServers.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mcpServers.description.claude')}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('mcpServers.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map(server => (
|
||||
<div key={server.id} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getTransportIcon(server.type)}
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.scope === 'local' ? t('mcpServers.scope.local') : server.scope === 'user' ? t('mcpServers.scope.user') : server.scope}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{server.type === 'stdio' && server.config?.command && (
|
||||
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
||||
)}
|
||||
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
|
||||
<div>{t('mcpServers.config.url')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.url}</code></div>
|
||||
)}
|
||||
{server.config?.args && server.config.args.length > 0 && (
|
||||
<div>{t('mcpServers.config.args')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults?.[server.id] && (
|
||||
<div className={`mt-2 p-2 rounded text-xs ${
|
||||
testResults[server.id].success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
<div className="font-medium">{testResults[server.id].message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Discovery Results */}
|
||||
{serverTools?.[server.id] && serverTools[server.id].tools?.length > 0 && (
|
||||
<div className="mt-2 p-2 rounded text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
|
||||
<div className="font-medium">{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: serverTools[server.id].tools.length })}</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{serverTools[server.id].tools.slice(0, 5).map((tool, i) => (
|
||||
<code key={i} className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{tool.name}</code>
|
||||
))}
|
||||
{serverTools[server.id].tools.length > 5 && (
|
||||
<span className="text-xs opacity-75">{t('mcpServers.tools.more', { count: serverTools[server.id].tools.length - 5 })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(server.id, server.scope)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t('mcpServers.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor MCP Servers
|
||||
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('mcpServers.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mcpServers.description.cursor')}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('mcpServers.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map(server => (
|
||||
<div key={server.name || server.id} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{server.config?.command && (
|
||||
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(server.name)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t('mcpServers.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Codex MCP Servers
|
||||
function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('mcpServers.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mcpServers.description.codex')}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
className="bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('mcpServers.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map(server => (
|
||||
<div key={server.name} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{server.config?.command && (
|
||||
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
||||
)}
|
||||
{server.config?.args && server.config.args.length > 0 && (
|
||||
<div>{t('mcpServers.config.args')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
|
||||
)}
|
||||
{server.config?.env && Object.keys(server.config.env).length > 0 && (
|
||||
<div>{t('mcpServers.config.environment')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{Object.entries(server.config.env).map(([k, v]) => `${k}=${v}`).join(', ')}</code></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(server.name)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t('mcpServers.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="bg-gray-100 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">{t('mcpServers.help.title')}</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('mcpServers.help.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function McpServersContent({ agent, ...props }) {
|
||||
if (agent === 'claude') {
|
||||
return <ClaudeMcpServers {...props} />;
|
||||
}
|
||||
if (agent === 'cursor') {
|
||||
return <CursorMcpServers {...props} />;
|
||||
}
|
||||
if (agent === 'codex') {
|
||||
return <CodexMcpServers {...props} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
612
src/components/settings/PermissionsContent.jsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Shield, AlertTriangle, Plus, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Common tool patterns for Claude
|
||||
const commonClaudeTools = [
|
||||
'Bash(git log:*)',
|
||||
'Bash(git diff:*)',
|
||||
'Bash(git status:*)',
|
||||
'Write',
|
||||
'Read',
|
||||
'Edit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'MultiEdit',
|
||||
'Task',
|
||||
'TodoWrite',
|
||||
'TodoRead',
|
||||
'WebFetch',
|
||||
'WebSearch'
|
||||
];
|
||||
|
||||
// Common shell commands for Cursor
|
||||
const commonCursorCommands = [
|
||||
'Shell(ls)',
|
||||
'Shell(mkdir)',
|
||||
'Shell(cd)',
|
||||
'Shell(cat)',
|
||||
'Shell(echo)',
|
||||
'Shell(git status)',
|
||||
'Shell(git diff)',
|
||||
'Shell(git log)',
|
||||
'Shell(npm install)',
|
||||
'Shell(npm run)',
|
||||
'Shell(python)',
|
||||
'Shell(node)'
|
||||
];
|
||||
|
||||
// Claude Permissions
|
||||
function ClaudePermissions({
|
||||
skipPermissions,
|
||||
setSkipPermissions,
|
||||
allowedTools,
|
||||
setAllowedTools,
|
||||
disallowedTools,
|
||||
setDisallowedTools,
|
||||
newAllowedTool,
|
||||
setNewAllowedTool,
|
||||
newDisallowedTool,
|
||||
setNewDisallowedTool,
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const addAllowedTool = (tool) => {
|
||||
if (tool && !allowedTools.includes(tool)) {
|
||||
setAllowedTools([...allowedTools, tool]);
|
||||
setNewAllowedTool('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAllowedTool = (tool) => {
|
||||
setAllowedTools(allowedTools.filter(t => t !== tool));
|
||||
};
|
||||
|
||||
const addDisallowedTool = (tool) => {
|
||||
if (tool && !disallowedTools.includes(tool)) {
|
||||
setDisallowedTools([...disallowedTools, tool]);
|
||||
setNewDisallowedTool('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeDisallowedTool = (tool) => {
|
||||
setDisallowedTools(disallowedTools.filter(t => t !== tool));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Skip Permissions */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('permissions.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipPermissions}
|
||||
onChange={(e) => setSkipPermissions(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100">
|
||||
{t('permissions.skipPermissions.label')}
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
{t('permissions.skipPermissions.claudeDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Tools */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('permissions.allowedTools.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('permissions.allowedTools.description')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newAllowedTool}
|
||||
onChange={(e) => setNewAllowedTool(e.target.value)}
|
||||
placeholder={t('permissions.allowedTools.placeholder')}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addAllowedTool(newAllowedTool);
|
||||
}
|
||||
}}
|
||||
className="flex-1 h-10"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => addAllowedTool(newAllowedTool)}
|
||||
disabled={!newAllowedTool}
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
|
||||
<span className="sm:hidden">{t('permissions.actions.add')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick add buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('permissions.allowedTools.quickAdd')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{commonClaudeTools.map(tool => (
|
||||
<Button
|
||||
key={tool}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAllowedTool(tool)}
|
||||
disabled={allowedTools.includes(tool)}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
{tool}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allowedTools.map(tool => (
|
||||
<div key={tool} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<span className="font-mono text-sm text-green-800 dark:text-green-200">
|
||||
{tool}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAllowedTool(tool)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{allowedTools.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
{t('permissions.allowedTools.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disallowed Tools */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('permissions.blockedTools.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('permissions.blockedTools.description')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newDisallowedTool}
|
||||
onChange={(e) => setNewDisallowedTool(e.target.value)}
|
||||
placeholder={t('permissions.blockedTools.placeholder')}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addDisallowedTool(newDisallowedTool);
|
||||
}
|
||||
}}
|
||||
className="flex-1 h-10"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => addDisallowedTool(newDisallowedTool)}
|
||||
disabled={!newDisallowedTool}
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
|
||||
<span className="sm:hidden">{t('permissions.actions.add')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{disallowedTools.map(tool => (
|
||||
<div key={tool} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<span className="font-mono text-sm text-red-800 dark:text-red-200">
|
||||
{tool}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisallowedTool(tool)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{disallowedTools.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
{t('permissions.blockedTools.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
{t('permissions.toolExamples.title')}
|
||||
</h4>
|
||||
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git log:*)"</code> {t('permissions.toolExamples.bashGitLog')}</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git diff:*)"</code> {t('permissions.toolExamples.bashGitDiff')}</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Write"</code> {t('permissions.toolExamples.write')}</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor Permissions
|
||||
function CursorPermissions({
|
||||
skipPermissions,
|
||||
setSkipPermissions,
|
||||
allowedCommands,
|
||||
setAllowedCommands,
|
||||
disallowedCommands,
|
||||
setDisallowedCommands,
|
||||
newAllowedCommand,
|
||||
setNewAllowedCommand,
|
||||
newDisallowedCommand,
|
||||
setNewDisallowedCommand,
|
||||
}) {
|
||||
const { t } = useTranslation('settings');
|
||||
const addAllowedCommand = (cmd) => {
|
||||
if (cmd && !allowedCommands.includes(cmd)) {
|
||||
setAllowedCommands([...allowedCommands, cmd]);
|
||||
setNewAllowedCommand('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAllowedCommand = (cmd) => {
|
||||
setAllowedCommands(allowedCommands.filter(c => c !== cmd));
|
||||
};
|
||||
|
||||
const addDisallowedCommand = (cmd) => {
|
||||
if (cmd && !disallowedCommands.includes(cmd)) {
|
||||
setDisallowedCommands([...disallowedCommands, cmd]);
|
||||
setNewDisallowedCommand('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeDisallowedCommand = (cmd) => {
|
||||
setDisallowedCommands(disallowedCommands.filter(c => c !== cmd));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Skip Permissions */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('permissions.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipPermissions}
|
||||
onChange={(e) => setSkipPermissions(e.target.checked)}
|
||||
className="w-4 h-4 text-purple-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-purple-500 focus:ring-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100">
|
||||
{t('permissions.skipPermissions.label')}
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
{t('permissions.skipPermissions.cursorDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Commands */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('permissions.allowedCommands.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('permissions.allowedCommands.description')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newAllowedCommand}
|
||||
onChange={(e) => setNewAllowedCommand(e.target.value)}
|
||||
placeholder={t('permissions.allowedCommands.placeholder')}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addAllowedCommand(newAllowedCommand);
|
||||
}
|
||||
}}
|
||||
className="flex-1 h-10"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => addAllowedCommand(newAllowedCommand)}
|
||||
disabled={!newAllowedCommand}
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
|
||||
<span className="sm:hidden">{t('permissions.actions.add')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick add buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('permissions.allowedCommands.quickAdd')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{commonCursorCommands.map(cmd => (
|
||||
<Button
|
||||
key={cmd}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAllowedCommand(cmd)}
|
||||
disabled={allowedCommands.includes(cmd)}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
{cmd}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allowedCommands.map(cmd => (
|
||||
<div key={cmd} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<span className="font-mono text-sm text-green-800 dark:text-green-200">
|
||||
{cmd}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAllowedCommand(cmd)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{allowedCommands.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
{t('permissions.allowedCommands.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disallowed Commands */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('permissions.blockedCommands.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('permissions.blockedCommands.description')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newDisallowedCommand}
|
||||
onChange={(e) => setNewDisallowedCommand(e.target.value)}
|
||||
placeholder={t('permissions.blockedCommands.placeholder')}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addDisallowedCommand(newDisallowedCommand);
|
||||
}
|
||||
}}
|
||||
className="flex-1 h-10"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => addDisallowedCommand(newDisallowedCommand)}
|
||||
disabled={!newDisallowedCommand}
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
|
||||
<span className="sm:hidden">{t('permissions.actions.add')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{disallowedCommands.map(cmd => (
|
||||
<div key={cmd} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<span className="font-mono text-sm text-red-800 dark:text-red-200">
|
||||
{cmd}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisallowedCommand(cmd)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{disallowedCommands.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
{t('permissions.blockedCommands.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-purple-900 dark:text-purple-100 mb-2">
|
||||
{t('permissions.shellExamples.title')}
|
||||
</h4>
|
||||
<ul className="text-sm text-purple-800 dark:text-purple-200 space-y-1">
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(ls)"</code> {t('permissions.shellExamples.ls')}</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(git status)"</code> {t('permissions.shellExamples.gitStatus')}</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(npm install)"</code> {t('permissions.shellExamples.npmInstall')}</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(rm -rf)"</code> {t('permissions.shellExamples.rmRf')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Codex Permissions
|
||||
function CodexPermissions({ permissionMode, setPermissionMode }) {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{t('permissions.codex.permissionMode')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('permissions.codex.description')}
|
||||
</p>
|
||||
|
||||
{/* Default Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'default'
|
||||
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setPermissionMode('default')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="codexPermissionMode"
|
||||
checked={permissionMode === 'default'}
|
||||
onChange={() => setPermissionMode('default')}
|
||||
className="mt-1 w-4 h-4 text-green-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{t('permissions.codex.modes.default.title')}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('permissions.codex.modes.default.description')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Accept Edits Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'acceptEdits'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setPermissionMode('acceptEdits')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="codexPermissionMode"
|
||||
checked={permissionMode === 'acceptEdits'}
|
||||
onChange={() => setPermissionMode('acceptEdits')}
|
||||
className="mt-1 w-4 h-4 text-green-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-green-900 dark:text-green-100">{t('permissions.codex.modes.acceptEdits.title')}</div>
|
||||
<div className="text-sm text-green-700 dark:text-green-300">
|
||||
{t('permissions.codex.modes.acceptEdits.description')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Bypass Permissions Mode */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
permissionMode === 'bypassPermissions'
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
|
||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setPermissionMode('bypassPermissions')}
|
||||
>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="codexPermissionMode"
|
||||
checked={permissionMode === 'bypassPermissions'}
|
||||
onChange={() => setPermissionMode('bypassPermissions')}
|
||||
className="mt-1 w-4 h-4 text-orange-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100 flex items-center gap-2">
|
||||
{t('permissions.codex.modes.bypassPermissions.title')}
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
{t('permissions.codex.modes.bypassPermissions.description')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{t('permissions.codex.technicalDetails')}
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg text-xs text-muted-foreground space-y-2">
|
||||
<p><strong>{t('permissions.codex.modes.default.title')}:</strong> {t('permissions.codex.technicalInfo.default')}</p>
|
||||
<p><strong>{t('permissions.codex.modes.acceptEdits.title')}:</strong> {t('permissions.codex.technicalInfo.acceptEdits')}</p>
|
||||
<p><strong>{t('permissions.codex.modes.bypassPermissions.title')}:</strong> {t('permissions.codex.technicalInfo.bypassPermissions')}</p>
|
||||
<p className="text-xs opacity-75">{t('permissions.codex.technicalInfo.overrideNote')}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function PermissionsContent({ agent, ...props }) {
|
||||
if (agent === 'claude') {
|
||||
return <ClaudePermissions {...props} />;
|
||||
}
|
||||
if (agent === 'cursor') {
|
||||
return <CursorPermissions {...props} />;
|
||||
}
|
||||
if (agent === 'codex') {
|
||||
return <CodexPermissions {...props} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
5
src/constants/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Environment Flag: Is Platform
|
||||
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
|
||||
*/
|
||||
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const AuthContext = createContext({
|
||||
user: null,
|
||||
@@ -9,6 +10,8 @@ const AuthContext = createContext({
|
||||
logout: () => {},
|
||||
isLoading: true,
|
||||
needsSetup: false,
|
||||
hasCompletedOnboarding: true,
|
||||
refreshOnboardingStatus: () => {},
|
||||
error: null
|
||||
});
|
||||
|
||||
@@ -25,37 +28,63 @@ export const AuthProvider = ({ children }) => {
|
||||
const [token, setToken] = useState(localStorage.getItem('auth-token'));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Check authentication status on mount
|
||||
useEffect(() => {
|
||||
if (IS_PLATFORM) {
|
||||
setUser({ username: 'platform-user' });
|
||||
setNeedsSetup(false);
|
||||
checkOnboardingStatus();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
const response = await api.user.onboardingStatus();
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHasCompletedOnboarding(data.hasCompletedOnboarding);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking onboarding status:', error);
|
||||
setHasCompletedOnboarding(true);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshOnboardingStatus = async () => {
|
||||
await checkOnboardingStatus();
|
||||
};
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
// Check if system needs setup
|
||||
const statusResponse = await api.auth.status();
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
|
||||
if (statusData.needsSetup) {
|
||||
setNeedsSetup(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// If we have a token, verify it
|
||||
if (token) {
|
||||
try {
|
||||
const userResponse = await api.auth.user();
|
||||
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
setUser(userData.user);
|
||||
setNeedsSetup(false);
|
||||
await checkOnboardingStatus();
|
||||
} else {
|
||||
// Token is invalid
|
||||
localStorage.removeItem('auth-token');
|
||||
@@ -70,7 +99,7 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth status check failed:', error);
|
||||
console.error('[AuthContext] Auth status check failed:', error);
|
||||
setError('Failed to check authentication status');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -147,6 +176,8 @@ export const AuthProvider = ({ children }) => {
|
||||
logout,
|
||||
isLoading,
|
||||
needsSetup,
|
||||
hasCompletedOnboarding,
|
||||
refreshOnboardingStatus,
|
||||
error
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useWebSocketContext } from './WebSocketContext';
|
||||
import { useWebSocket } from './WebSocketContext';
|
||||
|
||||
const TaskMasterContext = createContext({
|
||||
// TaskMaster project state
|
||||
@@ -42,7 +42,7 @@ export const useTaskMaster = () => {
|
||||
|
||||
export const TaskMasterProvider = ({ children }) => {
|
||||
// Get WebSocket messages from shared context to avoid duplicate connections
|
||||
const { messages } = useWebSocketContext();
|
||||
const { latestMessage } = useWebSocket();
|
||||
|
||||
// Authentication context
|
||||
const { user, token, isLoading: authLoading } = useAuth();
|
||||
@@ -133,36 +133,14 @@ export const TaskMasterProvider = ({ children }) => {
|
||||
const setCurrentProject = useCallback(async (project) => {
|
||||
try {
|
||||
setCurrentProjectState(project);
|
||||
|
||||
// Clear previous project's data immediately when switching projects
|
||||
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
setProjectTaskMaster(null); // Clear previous TaskMaster data
|
||||
|
||||
// Try to fetch fresh TaskMaster detection data for the project
|
||||
if (project?.name) {
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/detect/${encodeURIComponent(project.name)}`);
|
||||
if (response.ok) {
|
||||
const detectionData = await response.json();
|
||||
setProjectTaskMaster(detectionData.taskmaster);
|
||||
} else {
|
||||
// If individual detection fails, fall back to project data from /api/projects
|
||||
console.warn('Individual TaskMaster detection failed, using project data:', response.status);
|
||||
setProjectTaskMaster(project.taskmaster || null);
|
||||
}
|
||||
} catch (detectionError) {
|
||||
// If individual detection fails, fall back to project data from /api/projects
|
||||
console.warn('TaskMaster detection error, using project data:', detectionError.message);
|
||||
setProjectTaskMaster(project.taskmaster || null);
|
||||
}
|
||||
} else {
|
||||
setProjectTaskMaster(null);
|
||||
}
|
||||
|
||||
setProjectTaskMaster(project?.taskmaster || null);
|
||||
} catch (err) {
|
||||
console.error('Error in setCurrentProject:', err);
|
||||
handleError(err, 'set current project');
|
||||
// Fall back to project data if available
|
||||
setProjectTaskMaster(project?.taskmaster || null);
|
||||
}
|
||||
}, []);
|
||||
@@ -260,9 +238,8 @@ export const TaskMasterProvider = ({ children }) => {
|
||||
}
|
||||
}, [currentProject?.name, user, token, refreshTasks]);
|
||||
|
||||
// Handle WebSocket messages for TaskMaster updates
|
||||
// Handle WebSocket latestMessage for TaskMaster updates
|
||||
useEffect(() => {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
if (!latestMessage) return;
|
||||
|
||||
|
||||
@@ -290,7 +267,7 @@ export const TaskMasterProvider = ({ children }) => {
|
||||
// Ignore non-TaskMaster messages
|
||||
break;
|
||||
}
|
||||
}, [messages, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
|
||||
}, [latestMessage, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
|
||||
|
||||
// Context value
|
||||
const contextValue = {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { useWebSocket } from '../utils/websocket';
|
||||
|
||||
const WebSocketContext = createContext({
|
||||
ws: null,
|
||||
sendMessage: () => {},
|
||||
messages: [],
|
||||
isConnected: false
|
||||
});
|
||||
|
||||
export const useWebSocketContext = () => {
|
||||
const context = useContext(WebSocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useWebSocketContext must be used within a WebSocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const WebSocketProvider = ({ children }) => {
|
||||
const webSocketData = useWebSocket();
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={webSocketData}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebSocketContext;
|
||||
125
src/contexts/WebSocketContext.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
type WebSocketContextType = {
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: any) => void;
|
||||
latestMessage: any | null;
|
||||
isConnected: boolean;
|
||||
};
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||
|
||||
export const useWebSocket = () => {
|
||||
const context = useContext(WebSocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useWebSocket must be used within a WebSocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const buildWebSocketUrl = (token: string | null) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)
|
||||
if (!token) return null;
|
||||
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page
|
||||
};
|
||||
|
||||
const useWebSocketProviderState = (): WebSocketContextType => {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const unmountedRef = useRef(false); // Track if component is unmounted
|
||||
const [latestMessage, setLatestMessage] = useState<any>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { token } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
unmountedRef.current = true;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [token]); // everytime token changes, we reconnect
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmountedRef.current) return; // Prevent connection if unmounted
|
||||
try {
|
||||
// Construct WebSocket URL
|
||||
const wsUrl = buildWebSocketUrl(token);
|
||||
|
||||
if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
setIsConnected(true);
|
||||
wsRef.current = websocket;
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setLatestMessage(data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
setIsConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (unmountedRef.current) return; // Prevent reconnection if unmounted
|
||||
connect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating WebSocket connection:', error);
|
||||
}
|
||||
}, [token]); // everytime token changes, we reconnect
|
||||
|
||||
const sendMessage = useCallback((message: any) => {
|
||||
const socket = wsRef.current;
|
||||
if (socket && isConnected) {
|
||||
socket.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket not connected');
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
const value: WebSocketContextType = useMemo(() =>
|
||||
({
|
||||
ws: wsRef.current,
|
||||
sendMessage,
|
||||
latestMessage,
|
||||
isConnected
|
||||
}), [sendMessage, latestMessage, isConnected]);
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const webSocketData = useWebSocketProviderState();
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={webSocketData}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebSocketContext;
|
||||
129
src/i18n/config.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* i18n Configuration
|
||||
*
|
||||
* Configures i18next for internationalization support.
|
||||
* Features:
|
||||
* - Lazy-loading of translation namespaces
|
||||
* - Language detection from localStorage
|
||||
* - Fallback to English for missing translations
|
||||
* - Development mode warnings for missing keys
|
||||
*/
|
||||
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import translation resources
|
||||
import enCommon from './locales/en/common.json';
|
||||
import enSettings from './locales/en/settings.json';
|
||||
import enAuth from './locales/en/auth.json';
|
||||
import enSidebar from './locales/en/sidebar.json';
|
||||
import enChat from './locales/en/chat.json';
|
||||
import enCodeEditor from './locales/en/codeEditor.json';
|
||||
|
||||
import zhCommon from './locales/zh-CN/common.json';
|
||||
import zhSettings from './locales/zh-CN/settings.json';
|
||||
import zhAuth from './locales/zh-CN/auth.json';
|
||||
import zhSidebar from './locales/zh-CN/sidebar.json';
|
||||
import zhChat from './locales/zh-CN/chat.json';
|
||||
import zhCodeEditor from './locales/zh-CN/codeEditor.json';
|
||||
|
||||
// Import supported languages configuration
|
||||
import { languages } from './languages.js';
|
||||
|
||||
// Get saved language preference from localStorage
|
||||
const getSavedLanguage = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('userLanguage');
|
||||
// Validate that the saved language is supported
|
||||
if (saved && languages.some(lang => lang.value === saved)) {
|
||||
return saved;
|
||||
}
|
||||
return 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize i18next
|
||||
i18n
|
||||
.use(LanguageDetector) // Detect user language
|
||||
.use(initReactI18next) // Pass i18n instance to react-i18next
|
||||
.init({
|
||||
// Resources containing all translations
|
||||
resources: {
|
||||
en: {
|
||||
common: enCommon,
|
||||
settings: enSettings,
|
||||
auth: enAuth,
|
||||
sidebar: enSidebar,
|
||||
chat: enChat,
|
||||
codeEditor: enCodeEditor,
|
||||
},
|
||||
'zh-CN': {
|
||||
common: zhCommon,
|
||||
settings: zhSettings,
|
||||
auth: zhAuth,
|
||||
sidebar: zhSidebar,
|
||||
chat: zhChat,
|
||||
codeEditor: zhCodeEditor,
|
||||
},
|
||||
},
|
||||
|
||||
// Default language
|
||||
lng: getSavedLanguage(),
|
||||
|
||||
// Fallback language when a translation is missing
|
||||
fallbackLng: 'en',
|
||||
|
||||
// Enable debug mode in development (logs missing keys to console)
|
||||
debug: import.meta.env.DEV,
|
||||
|
||||
// Namespaces - load only what's needed
|
||||
ns: ['common', 'settings', 'auth', 'sidebar', 'chat', 'codeEditor'],
|
||||
defaultNS: 'common',
|
||||
|
||||
// Key separator for nested keys (default: '.')
|
||||
keySeparator: '.',
|
||||
|
||||
// Namespace separator (default: ':')
|
||||
nsSeparator: ':',
|
||||
|
||||
// Save missing translations (disabled - requires manual review)
|
||||
saveMissing: false,
|
||||
|
||||
// Interpolation settings
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes values
|
||||
},
|
||||
|
||||
// React-specific settings
|
||||
react: {
|
||||
useSuspense: true, // Use Suspense for lazy-loading
|
||||
bindI18n: 'languageChanged', // Re-render on language change
|
||||
bindI18nStore: false, // Don't re-render on resource changes
|
||||
},
|
||||
|
||||
// Detection options
|
||||
detection: {
|
||||
// Order of language detection (local storage first)
|
||||
order: ['localStorage'],
|
||||
|
||||
// Keys to look for in localStorage
|
||||
lookupLocalStorage: 'userLanguage',
|
||||
|
||||
// Cache user language
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
// Save language preference when it changes
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
try {
|
||||
localStorage.setItem('userLanguage', lng);
|
||||
} catch (error) {
|
||||
console.error('Failed to save language preference:', error);
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
48
src/i18n/languages.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Supported Languages Configuration
|
||||
*
|
||||
* This file contains the list of supported languages for the application.
|
||||
* Each language includes:
|
||||
* - value: Language code (e.g., 'en', 'zh-CN')
|
||||
* - label: Display name in English
|
||||
* - nativeName: Native language name for display
|
||||
*/
|
||||
|
||||
export const languages = [
|
||||
{
|
||||
value: 'en',
|
||||
label: 'English',
|
||||
nativeName: 'English',
|
||||
},
|
||||
{
|
||||
value: 'zh-CN',
|
||||
label: 'Simplified Chinese',
|
||||
nativeName: '简体中文',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get language object by value
|
||||
* @param {string} value - Language code
|
||||
* @returns {Object|undefined} Language object or undefined if not found
|
||||
*/
|
||||
export const getLanguage = (value) => {
|
||||
return languages.find(lang => lang.value === value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all language values
|
||||
* @returns {string[]} Array of language codes
|
||||
*/
|
||||
export const getLanguageValues = () => {
|
||||
return languages.map(lang => lang.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a language is supported
|
||||
* @param {string} value - Language code to check
|
||||
* @returns {boolean} True if language is supported
|
||||
*/
|
||||
export const isLanguageSupported = (value) => {
|
||||
return languages.some(lang => lang.value === value);
|
||||
};
|
||||
37
src/i18n/locales/en/auth.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Welcome Back",
|
||||
"description": "Sign in to your Claude Code UI account",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"submit": "Sign In",
|
||||
"loading": "Signing in...",
|
||||
"errors": {
|
||||
"invalidCredentials": "Invalid username or password",
|
||||
"requiredFields": "Please fill in all fields",
|
||||
"networkError": "Network error. Please try again."
|
||||
},
|
||||
"placeholders": {
|
||||
"username": "Enter your username",
|
||||
"password": "Enter your password"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "Create Account",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"submit": "Create Account",
|
||||
"loading": "Creating account...",
|
||||
"errors": {
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"usernameTaken": "Username is already taken",
|
||||
"weakPassword": "Password is too weak"
|
||||
}
|
||||
},
|
||||
"logout": {
|
||||
"title": "Sign Out",
|
||||
"confirm": "Are you sure you want to sign out?",
|
||||
"button": "Sign Out"
|
||||
}
|
||||
}
|
||||
205
src/i18n/locales/en/chat.json
Normal file
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"codeBlock": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyCode": "Copy code"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
"error": "Error",
|
||||
"tool": "Tool",
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "Tool Settings",
|
||||
"error": "Tool Error",
|
||||
"result": "Tool Result",
|
||||
"viewParams": "View input parameters",
|
||||
"viewRawParams": "View raw parameters",
|
||||
"viewDiff": "View edit diff for",
|
||||
"creatingFile": "Creating new file:",
|
||||
"updatingTodo": "Updating Todo List",
|
||||
"read": "Read",
|
||||
"readFile": "Read file",
|
||||
"updateTodo": "Update todo list",
|
||||
"readTodo": "Read todo list",
|
||||
"searchResults": "results"
|
||||
},
|
||||
"search": {
|
||||
"found": "Found {{count}} {{type}}",
|
||||
"file": "file",
|
||||
"files": "files",
|
||||
"pattern": "pattern:",
|
||||
"in": "in:"
|
||||
},
|
||||
"fileOperations": {
|
||||
"updated": "File updated successfully",
|
||||
"created": "File created successfully",
|
||||
"written": "File written successfully",
|
||||
"diff": "Diff",
|
||||
"newFile": "New File",
|
||||
"viewContent": "View file content",
|
||||
"viewFullOutput": "View full output ({{count}} chars)",
|
||||
"contentDisplayed": "The file content is displayed in the diff view above"
|
||||
},
|
||||
"interactive": {
|
||||
"title": "Interactive Prompt",
|
||||
"waiting": "Waiting for your response in the CLI",
|
||||
"instruction": "Please select an option in your terminal where Claude is running.",
|
||||
"selectedOption": "✓ Claude selected option {{number}}",
|
||||
"instructionDetail": "In the CLI, you would select this option interactively using arrow keys or by typing the number."
|
||||
},
|
||||
"thinking": {
|
||||
"title": "Thinking...",
|
||||
"emoji": "💭 Thinking..."
|
||||
},
|
||||
"json": {
|
||||
"response": "JSON Response"
|
||||
},
|
||||
"permissions": {
|
||||
"grant": "Grant permission for {{tool}}",
|
||||
"added": "Permission added",
|
||||
"addTo": "Adds {{entry}} to Allowed Tools.",
|
||||
"retry": "Permission saved. Retry the request to use the tool.",
|
||||
"error": "Unable to update permissions. Please try again.",
|
||||
"openSettings": "Open settings"
|
||||
},
|
||||
"todo": {
|
||||
"updated": "Todo list has been updated successfully",
|
||||
"current": "Current Todo List"
|
||||
},
|
||||
"plan": {
|
||||
"viewPlan": "📋 View implementation plan",
|
||||
"title": "Implementation Plan"
|
||||
},
|
||||
"usageLimit": {
|
||||
"resetAt": "Claude usage limit reached. Your limit will reset at **{{time}} {{timezone}}** - {{date}}"
|
||||
},
|
||||
"codex": {
|
||||
"permissionMode": "Permission Mode",
|
||||
"modes": {
|
||||
"default": "Default Mode",
|
||||
"acceptEdits": "Accept Edits",
|
||||
"bypassPermissions": "Bypass Permissions",
|
||||
"plan": "Plan Mode"
|
||||
},
|
||||
"descriptions": {
|
||||
"default": "Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace.",
|
||||
"acceptEdits": "All commands run automatically within the workspace. Full auto mode with sandboxed execution.",
|
||||
"bypassPermissions": "Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution.",
|
||||
"plan": "Planning mode - no commands are executed"
|
||||
},
|
||||
"technicalDetails": "Technical details"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
|
||||
"placeholderDefault": "Type your message...",
|
||||
"disabled": "Input disabled",
|
||||
"attachFiles": "Attach files",
|
||||
"attachImages": "Attach images",
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
"hintText": {
|
||||
"ctrlEnter": "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands",
|
||||
"enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
||||
},
|
||||
"clickToChangeMode": "Click to change permission mode (or press Tab in input)",
|
||||
"showAllCommands": "Show all commands"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "Thinking Mode",
|
||||
"description": "Extended thinking gives Claude more time to evaluate alternatives",
|
||||
"active": "Active",
|
||||
"tip": "Higher thinking modes take more time but provide more thorough analysis"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "Standard",
|
||||
"description": "Regular Claude response",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "Think",
|
||||
"description": "Basic extended thinking",
|
||||
"prefix": "think"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "Think Hard",
|
||||
"description": "More thorough evaluation",
|
||||
"prefix": "think hard"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "Think Harder",
|
||||
"description": "Deep analysis with alternatives",
|
||||
"prefix": "think harder"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "Ultrathink",
|
||||
"description": "Maximum thinking budget",
|
||||
"prefix": "ultrathink"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "Thinking mode: {{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "Choose Your AI Assistant",
|
||||
"description": "Select a provider to start a new conversation",
|
||||
"selectModel": "Select Model",
|
||||
"providerInfo": {
|
||||
"anthropic": "by Anthropic",
|
||||
"openai": "by OpenAI",
|
||||
"cursorEditor": "AI Code Editor"
|
||||
},
|
||||
"readyPrompt": {
|
||||
"claude": "Ready to use Claude with {{model}}. Start typing your message below.",
|
||||
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
|
||||
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
|
||||
"default": "Select a provider above to begin"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"continue": {
|
||||
"title": "Continue your conversation",
|
||||
"description": "Ask questions about your code, request changes, or get help with development tasks"
|
||||
},
|
||||
"loading": {
|
||||
"olderMessages": "Loading older messages...",
|
||||
"sessionMessages": "Loading session messages..."
|
||||
},
|
||||
"messages": {
|
||||
"showingOf": "Showing {{shown}} of {{total}} messages",
|
||||
"scrollToLoad": "Scroll up to load more",
|
||||
"showingLast": "Showing last {{count}} messages ({{total}} total)",
|
||||
"loadEarlier": "Load earlier messages"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"selectProject": {
|
||||
"title": "Select a Project",
|
||||
"description": "Choose a project to open an interactive shell in that directory"
|
||||
},
|
||||
"status": {
|
||||
"newSession": "New Session",
|
||||
"initializing": "Initializing...",
|
||||
"restarting": "Restarting..."
|
||||
},
|
||||
"actions": {
|
||||
"disconnect": "Disconnect",
|
||||
"disconnectTitle": "Disconnect from shell",
|
||||
"restart": "Restart",
|
||||
"restartTitle": "Restart Shell (disconnect first)",
|
||||
"connect": "Continue in Shell",
|
||||
"connectTitle": "Connect to shell"
|
||||
},
|
||||
"loading": "Loading terminal...",
|
||||
"connecting": "Connecting to shell...",
|
||||
"startSession": "Start a new Claude session",
|
||||
"resumeSession": "Resume session: {{displayName}}...",
|
||||
"runCommand": "Run {{command}} in {{projectName}}",
|
||||
"startCli": "Starting Claude CLI in {{projectName}}",
|
||||
"defaultCommand": "command"
|
||||
}
|
||||
}
|
||||
30
src/i18n/locales/en/codeEditor.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"toolbar": {
|
||||
"changes": "changes",
|
||||
"previousChange": "Previous change",
|
||||
"nextChange": "Next change",
|
||||
"hideDiff": "Hide diff highlighting",
|
||||
"showDiff": "Show diff highlighting",
|
||||
"settings": "Editor Settings",
|
||||
"collapse": "Collapse editor",
|
||||
"expand": "Expand editor to full width"
|
||||
},
|
||||
"loading": "Loading {{fileName}}...",
|
||||
"header": {
|
||||
"showingChanges": "Showing changes"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download file",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved!",
|
||||
"exitFullscreen": "Exit fullscreen",
|
||||
"fullscreen": "Fullscreen",
|
||||
"close": "Close"
|
||||
},
|
||||
"footer": {
|
||||
"lines": "Lines:",
|
||||
"characters": "Characters:",
|
||||
"shortcuts": "Press Ctrl+S to save • Esc to close"
|
||||
}
|
||||
}
|
||||
222
src/i18n/locales/en/common.json
Normal file
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"buttons": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"create": "Create",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"submit": "Submit",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"search": "Search",
|
||||
"clear": "Clear",
|
||||
"copy": "Copy",
|
||||
"download": "Download",
|
||||
"upload": "Upload",
|
||||
"browse": "Browse"
|
||||
},
|
||||
"tabs": {
|
||||
"chat": "Chat",
|
||||
"shell": "Shell",
|
||||
"files": "Files",
|
||||
"git": "Source Control",
|
||||
"tasks": "Tasks"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"failed": "Failed",
|
||||
"pending": "Pending",
|
||||
"completed": "Completed",
|
||||
"inProgress": "In Progress"
|
||||
},
|
||||
"messages": {
|
||||
"savedSuccessfully": "Saved successfully",
|
||||
"deletedSuccessfully": "Deleted successfully",
|
||||
"updatedSuccessfully": "Updated successfully",
|
||||
"operationFailed": "Operation failed",
|
||||
"networkError": "Network error. Please check your connection.",
|
||||
"unauthorized": "Unauthorized. Please log in.",
|
||||
"notFound": "Not found",
|
||||
"invalidInput": "Invalid input",
|
||||
"requiredField": "This field is required",
|
||||
"unknownError": "An unknown error occurred"
|
||||
},
|
||||
"navigation": {
|
||||
"settings": "Settings",
|
||||
"home": "Home",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"common": {
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"optional": "Optional",
|
||||
"version": "Version",
|
||||
"select": "Select",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{{count}} mins ago",
|
||||
"hoursAgo": "{{count}} hours ago",
|
||||
"daysAgo": "{{count}} days ago",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"fileOperations": {
|
||||
"newFile": "New File",
|
||||
"newFolder": "New Folder",
|
||||
"rename": "Rename",
|
||||
"move": "Move",
|
||||
"copyPath": "Copy Path",
|
||||
"openInEditor": "Open in Editor"
|
||||
},
|
||||
"mainContent": {
|
||||
"loading": "Loading Claude Code UI",
|
||||
"settingUpWorkspace": "Setting up your workspace...",
|
||||
"chooseProject": "Choose Your Project",
|
||||
"selectProjectDescription": "Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.",
|
||||
"tip": "Tip",
|
||||
"createProjectMobile": "Tap the menu button above to access projects",
|
||||
"createProjectDesktop": "Create a new project by clicking the folder icon in the sidebar",
|
||||
"newSession": "New Session",
|
||||
"untitledSession": "Untitled Session",
|
||||
"projectFiles": "Project Files"
|
||||
},
|
||||
"fileTree": {
|
||||
"loading": "Loading files...",
|
||||
"files": "Files",
|
||||
"simpleView": "Simple view",
|
||||
"compactView": "Compact view",
|
||||
"detailedView": "Detailed view",
|
||||
"searchPlaceholder": "Search files and folders...",
|
||||
"clearSearch": "Clear search",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"permissions": "Permissions",
|
||||
"noFilesFound": "No files found",
|
||||
"checkProjectPath": "Check if the project path is accessible",
|
||||
"noMatchesFound": "No matches found",
|
||||
"tryDifferentSearch": "Try a different search term or clear the search",
|
||||
"justNow": "just now",
|
||||
"minAgo": "{{count}} min ago",
|
||||
"hoursAgo": "{{count}} hours ago",
|
||||
"daysAgo": "{{count}} days ago"
|
||||
},
|
||||
"projectWizard": {
|
||||
"title": "Create New Project",
|
||||
"steps": {
|
||||
"type": "Type",
|
||||
"configure": "Configure",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"step1": {
|
||||
"question": "Do you already have a workspace, or would you like to create a new one?",
|
||||
"existing": {
|
||||
"title": "Existing Workspace",
|
||||
"description": "I already have a workspace on my server and just need to add it to the project list"
|
||||
},
|
||||
"new": {
|
||||
"title": "New Workspace",
|
||||
"description": "Create a new workspace, optionally clone from a GitHub repository"
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"existingPath": "Workspace Path",
|
||||
"newPath": "Workspace Path",
|
||||
"existingPlaceholder": "/path/to/existing/workspace",
|
||||
"newPlaceholder": "/path/to/new/workspace",
|
||||
"existingHelp": "Full path to your existing workspace directory",
|
||||
"newHelp": "Full path to your workspace directory",
|
||||
"githubUrl": "GitHub URL (Optional)",
|
||||
"githubPlaceholder": "https://github.com/username/repository",
|
||||
"githubHelp": "Optional: provide a GitHub URL to clone a repository",
|
||||
"githubAuth": "GitHub Authentication (Optional)",
|
||||
"githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.",
|
||||
"loadingTokens": "Loading stored tokens...",
|
||||
"storedToken": "Stored Token",
|
||||
"newToken": "New Token",
|
||||
"nonePublic": "None (Public)",
|
||||
"selectToken": "Select Token",
|
||||
"selectTokenPlaceholder": "-- Select a token --",
|
||||
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"tokenHelp": "This token will be used only for this operation",
|
||||
"publicRepoInfo": "Public repositories don't require authentication. You can skip providing a token if cloning a public repo.",
|
||||
"noTokensHelp": "No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.",
|
||||
"optionalTokenPublic": "GitHub Token (Optional for Public Repos)",
|
||||
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
|
||||
},
|
||||
"step3": {
|
||||
"reviewConfig": "Review Your Configuration",
|
||||
"workspaceType": "Workspace Type:",
|
||||
"existingWorkspace": "Existing Workspace",
|
||||
"newWorkspace": "New Workspace",
|
||||
"path": "Path:",
|
||||
"cloneFrom": "Clone From:",
|
||||
"authentication": "Authentication:",
|
||||
"usingStoredToken": "Using stored token:",
|
||||
"usingProvidedToken": "Using provided token",
|
||||
"noAuthentication": "No authentication",
|
||||
"sshKey": "SSH Key",
|
||||
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
|
||||
"newWithClone": "The repository will be cloned from this folder.",
|
||||
"newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
|
||||
"cloningRepository": "Cloning repository..."
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"createProject": "Create Project",
|
||||
"creating": "Creating...",
|
||||
"cloning": "Cloning..."
|
||||
},
|
||||
"errors": {
|
||||
"selectType": "Please select whether you have an existing workspace or want to create a new one",
|
||||
"providePath": "Please provide a workspace path",
|
||||
"failedToCreate": "Failed to create workspace",
|
||||
"failedToCreateFolder": "Failed to create folder"
|
||||
}
|
||||
},
|
||||
"versionUpdate": {
|
||||
"title": "Update Available",
|
||||
"newVersionReady": "A new version is ready",
|
||||
"currentVersion": "Current Version",
|
||||
"latestVersion": "Latest Version",
|
||||
"whatsNew": "What's New:",
|
||||
"viewFullRelease": "View full release",
|
||||
"updateProgress": "Update Progress:",
|
||||
"manualUpgrade": "Manual upgrade:",
|
||||
"manualUpgradeHint": "Or click \"Update Now\" to run the update automatically.",
|
||||
"updateCompleted": "Update completed successfully!",
|
||||
"restartServer": "Please restart the server to apply changes.",
|
||||
"updateFailed": "Update failed",
|
||||
"buttons": {
|
||||
"close": "Close",
|
||||
"later": "Later",
|
||||
"copyCommand": "Copy Command",
|
||||
"updateNow": "Update Now",
|
||||
"updating": "Updating..."
|
||||
},
|
||||
"ariaLabels": {
|
||||
"closeModal": "Close version upgrade modal",
|
||||
"showSidebar": "Show sidebar",
|
||||
"settings": "Settings",
|
||||
"updateAvailable": "Update available",
|
||||
"closeSidebar": "Close sidebar"
|
||||
}
|
||||
}
|
||||
}
|
||||
418
src/i18n/locales/en/settings.json
Normal file
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"title": "Settings",
|
||||
"tabs": {
|
||||
"account": "Account",
|
||||
"permissions": "Permissions",
|
||||
"mcpServers": "MCP Servers",
|
||||
"appearance": "Appearance"
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"language": "Language",
|
||||
"languageLabel": "Display Language",
|
||||
"languageDescription": "Choose your preferred language for the interface",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"profile": "Profile",
|
||||
"changePassword": "Change Password"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Servers",
|
||||
"addServer": "Add Server",
|
||||
"editServer": "Edit Server",
|
||||
"deleteServer": "Delete Server",
|
||||
"serverName": "Server Name",
|
||||
"serverType": "Server Type",
|
||||
"config": "Configuration",
|
||||
"testConnection": "Test Connection",
|
||||
"status": "Status",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"scope": {
|
||||
"label": "Scope",
|
||||
"user": "User",
|
||||
"project": "Project"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"theme": "Theme",
|
||||
"codeEditor": "Code Editor",
|
||||
"editorTheme": "Editor Theme",
|
||||
"wordWrap": "Word Wrap",
|
||||
"showMinimap": "Show Minimap",
|
||||
"lineNumbers": "Line Numbers",
|
||||
"fontSize": "Font Size"
|
||||
},
|
||||
"actions": {
|
||||
"saveChanges": "Save Changes",
|
||||
"resetToDefaults": "Reset to Defaults",
|
||||
"cancelChanges": "Cancel Changes"
|
||||
},
|
||||
"quickSettings": {
|
||||
"title": "Quick Settings",
|
||||
"sections": {
|
||||
"appearance": "Appearance",
|
||||
"toolDisplay": "Tool Display",
|
||||
"viewOptions": "View Options",
|
||||
"inputSettings": "Input Settings",
|
||||
"whisperDictation": "Whisper Dictation"
|
||||
},
|
||||
"darkMode": "Dark Mode",
|
||||
"autoExpandTools": "Auto-expand tools",
|
||||
"showRawParameters": "Show raw parameters",
|
||||
"showThinking": "Show thinking",
|
||||
"autoScrollToBottom": "Auto-scroll to bottom",
|
||||
"sendByCtrlEnter": "Send by Ctrl+Enter",
|
||||
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
|
||||
"dragHandle": {
|
||||
"dragging": "Dragging handle",
|
||||
"closePanel": "Close settings panel",
|
||||
"openPanel": "Open settings panel",
|
||||
"draggingStatus": "Dragging...",
|
||||
"toggleAndMove": "Click to toggle, drag to move"
|
||||
},
|
||||
"whisper": {
|
||||
"modes": {
|
||||
"default": "Default Mode",
|
||||
"defaultDescription": "Direct transcription of your speech",
|
||||
"prompt": "Prompt Enhancement",
|
||||
"promptDescription": "Transform rough ideas into clear, detailed AI prompts",
|
||||
"vibe": "Vibe Mode",
|
||||
"vibeDescription": "Format ideas as clear agent instructions with details"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"agents": "Agents",
|
||||
"appearance": "Appearance",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & Tokens",
|
||||
"tasks": "Tasks"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
"label": "Dark Mode",
|
||||
"description": "Toggle between light and dark themes"
|
||||
},
|
||||
"projectSorting": {
|
||||
"label": "Project Sorting",
|
||||
"description": "How projects are ordered in the sidebar",
|
||||
"alphabetical": "Alphabetical",
|
||||
"recentActivity": "Recent Activity"
|
||||
},
|
||||
"codeEditor": {
|
||||
"title": "Code Editor",
|
||||
"theme": {
|
||||
"label": "Editor Theme",
|
||||
"description": "Default theme for the code editor"
|
||||
},
|
||||
"wordWrap": {
|
||||
"label": "Word Wrap",
|
||||
"description": "Enable word wrapping by default in the editor"
|
||||
},
|
||||
"showMinimap": {
|
||||
"label": "Show Minimap",
|
||||
"description": "Display a minimap for easier navigation in diff view"
|
||||
},
|
||||
"lineNumbers": {
|
||||
"label": "Show Line Numbers",
|
||||
"description": "Display line numbers in the editor"
|
||||
},
|
||||
"fontSize": {
|
||||
"label": "Font Size",
|
||||
"description": "Editor font size in pixels"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcpForm": {
|
||||
"title": {
|
||||
"add": "Add MCP Server",
|
||||
"edit": "Edit MCP Server"
|
||||
},
|
||||
"importMode": {
|
||||
"form": "Form Input",
|
||||
"json": "JSON Import"
|
||||
},
|
||||
"scope": {
|
||||
"label": "Scope",
|
||||
"userGlobal": "User (Global)",
|
||||
"projectLocal": "Project (Local)",
|
||||
"userDescription": "User scope: Available across all projects on your machine",
|
||||
"projectDescription": "Local scope: Only available in the selected project",
|
||||
"cannotChange": "Scope cannot be changed when editing an existing server"
|
||||
},
|
||||
"fields": {
|
||||
"serverName": "Server Name",
|
||||
"transportType": "Transport Type",
|
||||
"command": "Command",
|
||||
"arguments": "Arguments (one per line)",
|
||||
"jsonConfig": "JSON Configuration",
|
||||
"url": "URL",
|
||||
"envVars": "Environment Variables (KEY=value, one per line)",
|
||||
"headers": "Headers (KEY=value, one per line)",
|
||||
"selectProject": "Select a project..."
|
||||
},
|
||||
"placeholders": {
|
||||
"serverName": "my-server"
|
||||
},
|
||||
"validation": {
|
||||
"missingType": "Missing required field: type",
|
||||
"stdioRequiresCommand": "stdio type requires a command field",
|
||||
"httpRequiresUrl": "{{type}} type requires a url field",
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"jsonHelp": "Paste your MCP server configuration in JSON format. Example formats:",
|
||||
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
|
||||
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
|
||||
},
|
||||
"configDetails": "Configuration Details (from {{configFile}})",
|
||||
"projectPath": "Path: {{path}}",
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving...",
|
||||
"addServer": "Add Server",
|
||||
"updateServer": "Update Server"
|
||||
}
|
||||
},
|
||||
"saveStatus": {
|
||||
"success": "Settings saved successfully!",
|
||||
"error": "Failed to save settings",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"footerActions": {
|
||||
"save": "Save Settings",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"git": {
|
||||
"title": "Git Configuration",
|
||||
"description": "Configure your git identity for commits. These settings will be applied globally via git config --global",
|
||||
"name": {
|
||||
"label": "Git Name",
|
||||
"help": "Your name for git commits"
|
||||
},
|
||||
"email": {
|
||||
"label": "Git Email",
|
||||
"help": "Your email for git commits"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"status": {
|
||||
"success": "Saved successfully"
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"title": "API Keys",
|
||||
"description": "Generate API keys to access the external API from other applications.",
|
||||
"newKey": {
|
||||
"alertTitle": "⚠️ Save Your API Key",
|
||||
"alertMessage": "This is the only time you'll see this key. Store it securely.",
|
||||
"iveSavedIt": "I've saved it"
|
||||
},
|
||||
"form": {
|
||||
"placeholder": "API Key Name (e.g., Production Server)",
|
||||
"createButton": "Create",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"newButton": "New API Key",
|
||||
"empty": "No API keys created yet.",
|
||||
"list": {
|
||||
"created": "Created:",
|
||||
"lastUsed": "Last used:"
|
||||
},
|
||||
"confirmDelete": "Are you sure you want to delete this API key?",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"github": {
|
||||
"title": "GitHub Tokens",
|
||||
"description": "Add GitHub Personal Access Tokens to clone private repositories via the external API.",
|
||||
"descriptionAlt": "Add GitHub Personal Access Tokens to clone private repositories. You can also pass tokens directly in API requests without storing them.",
|
||||
"addButton": "Add Token",
|
||||
"form": {
|
||||
"namePlaceholder": "Token Name (e.g., Personal Repos)",
|
||||
"tokenPlaceholder": "GitHub Personal Access Token (ghp_...)",
|
||||
"descriptionPlaceholder": "Description (optional)",
|
||||
"addButton": "Add Token",
|
||||
"cancelButton": "Cancel",
|
||||
"howToCreate": "How to create a GitHub Personal Access Token →"
|
||||
},
|
||||
"empty": "No GitHub tokens added yet.",
|
||||
"added": "Added:",
|
||||
"confirmDelete": "Are you sure you want to delete this GitHub token?"
|
||||
},
|
||||
"apiDocsLink": "API Documentation",
|
||||
"documentation": {
|
||||
"title": "External API Documentation",
|
||||
"description": "Learn how to use the external API to trigger Claude/Cursor sessions from your applications.",
|
||||
"viewLink": "View API Documentation →"
|
||||
},
|
||||
"loading": "Loading...",
|
||||
"version": {
|
||||
"updateAvailable": "Update available: v{{version}}"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"checking": "Checking TaskMaster installation...",
|
||||
"notInstalled": {
|
||||
"title": "TaskMaster AI CLI Not Installed",
|
||||
"description": "TaskMaster CLI is required to use task management features. Install it to get started:",
|
||||
"installCommand": "npm install -g task-master-ai",
|
||||
"viewOnGitHub": "View on GitHub",
|
||||
"afterInstallation": "After installation:",
|
||||
"steps": {
|
||||
"restart": "Restart this application",
|
||||
"autoAvailable": "TaskMaster features will automatically become available",
|
||||
"initCommand": "Use task-master init in your project directory"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"enableLabel": "Enable TaskMaster Integration",
|
||||
"enableDescription": "Show TaskMaster tasks, banners, and sidebar indicators across the interface"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"authStatus": {
|
||||
"checking": "Checking...",
|
||||
"connected": "Connected",
|
||||
"notConnected": "Not connected",
|
||||
"disconnected": "Disconnected",
|
||||
"checkingAuth": "Checking authentication status...",
|
||||
"loggedInAs": "Logged in as {{email}}",
|
||||
"authenticatedUser": "authenticated user"
|
||||
},
|
||||
"account": {
|
||||
"claude": {
|
||||
"description": "Anthropic Claude AI assistant"
|
||||
},
|
||||
"cursor": {
|
||||
"description": "Cursor AI-powered code editor"
|
||||
},
|
||||
"codex": {
|
||||
"description": "OpenAI Codex AI assistant"
|
||||
}
|
||||
},
|
||||
"connectionStatus": "Connection Status",
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"reAuthenticate": "Re-authenticate",
|
||||
"description": "Sign in to your {{agent}} account to enable AI features",
|
||||
"reAuthDescription": "Sign in with a different account or refresh credentials",
|
||||
"button": "Login",
|
||||
"reLoginButton": "Re-login"
|
||||
},
|
||||
"error": "Error: {{error}}"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permission Settings",
|
||||
"skipPermissions": {
|
||||
"label": "Skip permission prompts (use with caution)",
|
||||
"claudeDescription": "Equivalent to --dangerously-skip-permissions flag",
|
||||
"cursorDescription": "Equivalent to -f flag in Cursor CLI"
|
||||
},
|
||||
"allowedTools": {
|
||||
"title": "Allowed Tools",
|
||||
"description": "Tools that are automatically allowed without prompting for permission",
|
||||
"placeholder": "e.g., \"Bash(git log:*)\" or \"Write\"",
|
||||
"quickAdd": "Quick add common tools:",
|
||||
"empty": "No allowed tools configured"
|
||||
},
|
||||
"blockedTools": {
|
||||
"title": "Blocked Tools",
|
||||
"description": "Tools that are automatically blocked without prompting for permission",
|
||||
"placeholder": "e.g., \"Bash(rm:*)\"",
|
||||
"empty": "No blocked tools configured"
|
||||
},
|
||||
"allowedCommands": {
|
||||
"title": "Allowed Shell Commands",
|
||||
"description": "Shell commands that are automatically allowed without prompting",
|
||||
"placeholder": "e.g., \"Shell(ls)\" or \"Shell(git status)\"",
|
||||
"quickAdd": "Quick add common commands:",
|
||||
"empty": "No allowed commands configured"
|
||||
},
|
||||
"blockedCommands": {
|
||||
"title": "Blocked Shell Commands",
|
||||
"description": "Shell commands that are automatically blocked",
|
||||
"placeholder": "e.g., \"Shell(rm -rf)\" or \"Shell(sudo)\"",
|
||||
"empty": "No blocked commands configured"
|
||||
},
|
||||
"toolExamples": {
|
||||
"title": "Tool Pattern Examples:",
|
||||
"bashGitLog": "- Allow all git log commands",
|
||||
"bashGitDiff": "- Allow all git diff commands",
|
||||
"write": "- Allow all Write tool usage",
|
||||
"bashRm": "- Block all rm commands (dangerous)"
|
||||
},
|
||||
"shellExamples": {
|
||||
"title": "Shell Command Examples:",
|
||||
"ls": "- Allow ls command",
|
||||
"gitStatus": "- Allow git status",
|
||||
"npmInstall": "- Allow npm install",
|
||||
"rmRf": "- Block recursive delete"
|
||||
},
|
||||
"codex": {
|
||||
"permissionMode": "Permission Mode",
|
||||
"description": "Controls how Codex handles file modifications and command execution",
|
||||
"modes": {
|
||||
"default": {
|
||||
"title": "Default",
|
||||
"description": "Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace."
|
||||
},
|
||||
"acceptEdits": {
|
||||
"title": "Accept Edits",
|
||||
"description": "All commands run automatically within the workspace. Full auto mode with sandboxed execution."
|
||||
},
|
||||
"bypassPermissions": {
|
||||
"title": "Bypass Permissions",
|
||||
"description": "Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution."
|
||||
}
|
||||
},
|
||||
"technicalDetails": "Technical details",
|
||||
"technicalInfo": {
|
||||
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Trusted commands: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (without -exec), etc.",
|
||||
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. All commands auto-execute within project directory.",
|
||||
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Full system access, use only in trusted environments.",
|
||||
"overrideNote": "You can override this per-session using the mode button in the chat interface."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add"
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP Servers",
|
||||
"description": {
|
||||
"claude": "Model Context Protocol servers provide additional tools and data sources to Claude",
|
||||
"cursor": "Model Context Protocol servers provide additional tools and data sources to Cursor",
|
||||
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex"
|
||||
},
|
||||
"addButton": "Add MCP Server",
|
||||
"empty": "No MCP servers configured",
|
||||
"serverType": "Type",
|
||||
"scope": {
|
||||
"local": "local",
|
||||
"user": "user"
|
||||
},
|
||||
"config": {
|
||||
"command": "Command",
|
||||
"url": "URL",
|
||||
"args": "Args",
|
||||
"environment": "Environment"
|
||||
},
|
||||
"tools": {
|
||||
"title": "Tools",
|
||||
"count": "({{count}}):",
|
||||
"more": "+{{count}} more"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit server",
|
||||
"delete": "Delete server"
|
||||
},
|
||||
"help": {
|
||||
"title": "About Codex MCP",
|
||||
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/i18n/locales/en/sidebar.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"projects": {
|
||||
"title": "Projects",
|
||||
"newProject": "New Project",
|
||||
"deleteProject": "Delete Project",
|
||||
"renameProject": "Rename Project",
|
||||
"noProjects": "No projects found",
|
||||
"loadingProjects": "Loading projects...",
|
||||
"searchPlaceholder": "Search projects...",
|
||||
"projectNamePlaceholder": "Project name",
|
||||
"starred": "Starred",
|
||||
"all": "All",
|
||||
"untitledSession": "Untitled Session",
|
||||
"newSession": "New Session",
|
||||
"codexSession": "Codex Session",
|
||||
"fetchingProjects": "Fetching your Claude projects and sessions",
|
||||
"projects": "projects",
|
||||
"noMatchingProjects": "No matching projects",
|
||||
"tryDifferentSearch": "Try adjusting your search term",
|
||||
"runClaudeCli": "Run Claude CLI in a project directory to get started"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code UI",
|
||||
"subtitle": "AI coding assistant interface"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"newSession": "New Session",
|
||||
"deleteSession": "Delete Session",
|
||||
"renameSession": "Rename Session",
|
||||
"noSessions": "No sessions yet",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
"unnamed": "Unnamed",
|
||||
"loading": "Loading...",
|
||||
"showMore": "Show more sessions"
|
||||
},
|
||||
"tooltips": {
|
||||
"viewEnvironments": "View Environments",
|
||||
"hideSidebar": "Hide sidebar",
|
||||
"createProject": "Create new project",
|
||||
"refresh": "Refresh projects and sessions (Ctrl+R)",
|
||||
"renameProject": "Rename project (F2)",
|
||||
"deleteProject": "Delete empty project (Delete)",
|
||||
"addToFavorites": "Add to favorites",
|
||||
"removeFromFavorites": "Remove from favorites",
|
||||
"editSessionName": "Manually edit session name",
|
||||
"deleteSession": "Delete this session permanently",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "Chat",
|
||||
"files": "Files",
|
||||
"git": "Git",
|
||||
"terminal": "Terminal",
|
||||
"tasks": "Tasks"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"settings": "Settings",
|
||||
"collapseAll": "Collapse All",
|
||||
"expandAll": "Expand All",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"rename": "Rename"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"thinking": "Thinking...",
|
||||
"error": "Error",
|
||||
"aborted": "Aborted",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "Just now",
|
||||
"oneMinuteAgo": "1 min ago",
|
||||
"minutesAgo": "{{count}} mins ago",
|
||||
"oneHourAgo": "1 hour ago",
|
||||
"hoursAgo": "{{count}} hours ago",
|
||||
"oneDayAgo": "1 day ago",
|
||||
"daysAgo": "{{count}} days ago"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Are you sure you want to delete this?",
|
||||
"renameSuccess": "Renamed successfully",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"errorOccurred": "An error occurred",
|
||||
"deleteSessionConfirm": "Are you sure you want to delete this session? This action cannot be undone.",
|
||||
"deleteProjectConfirm": "Are you sure you want to delete this empty project? This action cannot be undone.",
|
||||
"enterProjectPath": "Please enter a project path",
|
||||
"deleteSessionFailed": "Failed to delete session. Please try again.",
|
||||
"deleteSessionError": "Error deleting session. Please try again.",
|
||||
"deleteProjectFailed": "Failed to delete project. Please try again.",
|
||||
"deleteProjectError": "Error deleting project. Please try again.",
|
||||
"createProjectFailed": "Failed to create project. Please try again.",
|
||||
"createProjectError": "Error creating project. Please try again."
|
||||
},
|
||||
"version": {
|
||||
"updateAvailable": "Update available"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "Delete Project",
|
||||
"deleteSession": "Delete Session",
|
||||
"confirmDelete": "Are you sure you want to delete",
|
||||
"sessionCount_one": "This project contains {{count}} conversation.",
|
||||
"sessionCount_other": "This project contains {{count}} conversations.",
|
||||
"allConversationsDeleted": "All conversations will be permanently deleted.",
|
||||
"cannotUndo": "This action cannot be undone."
|
||||
}
|
||||
}
|
||||
37
src/i18n/locales/zh-CN/auth.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "欢迎回来",
|
||||
"description": "登录您的 Claude Code UI 账户",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"submit": "登录",
|
||||
"loading": "登录中...",
|
||||
"errors": {
|
||||
"invalidCredentials": "用户名或密码无效",
|
||||
"requiredFields": "请填写所有字段",
|
||||
"networkError": "网络错误,请重试。"
|
||||
},
|
||||
"placeholders": {
|
||||
"username": "输入您的用户名",
|
||||
"password": "输入您的密码"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "创建账户",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"submit": "创建账户",
|
||||
"loading": "创建账户中...",
|
||||
"errors": {
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"usernameTaken": "用户名已被占用",
|
||||
"weakPassword": "密码强度太弱"
|
||||
}
|
||||
},
|
||||
"logout": {
|
||||
"title": "退出登录",
|
||||
"confirm": "确定要退出登录吗?",
|
||||
"button": "退出登录"
|
||||
}
|
||||
}
|
||||
205
src/i18n/locales/zh-CN/chat.json
Normal file
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"codeBlock": {
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"copyCode": "复制代码"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
"error": "错误",
|
||||
"tool": "工具",
|
||||
"claude": "Claude",
|
||||
"cursor": "Cursor",
|
||||
"codex": "Codex"
|
||||
},
|
||||
"tools": {
|
||||
"settings": "工具设置",
|
||||
"error": "工具错误",
|
||||
"result": "工具结果",
|
||||
"viewParams": "查看输入参数",
|
||||
"viewRawParams": "查看原始参数",
|
||||
"viewDiff": "查看编辑差异",
|
||||
"creatingFile": "创建新文件:",
|
||||
"updatingTodo": "更新待办事项",
|
||||
"read": "读取",
|
||||
"readFile": "读取文件",
|
||||
"updateTodo": "更新待办列表",
|
||||
"readTodo": "读取待办列表",
|
||||
"searchResults": "结果"
|
||||
},
|
||||
"search": {
|
||||
"found": "找到 {{count}} 个{{type}}",
|
||||
"file": "文件",
|
||||
"files": "文件",
|
||||
"pattern": "模式:",
|
||||
"in": "在:"
|
||||
},
|
||||
"fileOperations": {
|
||||
"updated": "文件更新成功",
|
||||
"created": "文件创建成功",
|
||||
"written": "文件写入成功",
|
||||
"diff": "差异",
|
||||
"newFile": "新文件",
|
||||
"viewContent": "查看文件内容",
|
||||
"viewFullOutput": "查看完整输出({{count}} 个字符)",
|
||||
"contentDisplayed": "文件内容显示在上面的差异视图中"
|
||||
},
|
||||
"interactive": {
|
||||
"title": "交互式提示",
|
||||
"waiting": "等待您在 CLI 中响应",
|
||||
"instruction": "请在 Claude 运行的终端中选择一个选项。",
|
||||
"selectedOption": "✓ Claude 选择了选项 {{number}}",
|
||||
"instructionDetail": "在 CLI 中,您可以使用方向键或输入数字来交互式地选择此选项。"
|
||||
},
|
||||
"thinking": {
|
||||
"title": "思考中...",
|
||||
"emoji": "💭 思考中..."
|
||||
},
|
||||
"json": {
|
||||
"response": "JSON 响应"
|
||||
},
|
||||
"permissions": {
|
||||
"grant": "授予 {{tool}} 权限",
|
||||
"added": "权限已添加",
|
||||
"addTo": "将 {{entry}} 添加到允许的工具。",
|
||||
"retry": "权限已保存。重试请求以使用该工具。",
|
||||
"error": "无法更新权限。请重试。",
|
||||
"openSettings": "打开设置"
|
||||
},
|
||||
"todo": {
|
||||
"updated": "待办列表已成功更新",
|
||||
"current": "当前待办列表"
|
||||
},
|
||||
"plan": {
|
||||
"viewPlan": "📋 查看实施计划",
|
||||
"title": "实施计划"
|
||||
},
|
||||
"usageLimit": {
|
||||
"resetAt": "Claude 使用限制已达到。您的限制将在 **{{time}} {{timezone}}** - {{date}} 重置"
|
||||
},
|
||||
"codex": {
|
||||
"permissionMode": "权限模式",
|
||||
"modes": {
|
||||
"default": "默认模式",
|
||||
"acceptEdits": "编辑模式",
|
||||
"bypassPermissions": "无限制模式",
|
||||
"plan": "计划模式"
|
||||
},
|
||||
"descriptions": {
|
||||
"default": "只有受信任的命令(ls、cat、grep、git status 等)自动运行。其他命令将被跳过。可以写入工作区。",
|
||||
"acceptEdits": "工作区内的所有命令自动运行。完全自动模式,具有沙盒执行功能。",
|
||||
"bypassPermissions": "完全的系统访问,无限制。所有命令自动运行,具有完整的磁盘和网络访问权限。请谨慎使用。",
|
||||
"plan": "计划模式 - 不执行任何命令"
|
||||
},
|
||||
"technicalDetails": "技术细节"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "输入 / 调用命令,@ 选择文件,或向 {{provider}} 提问...",
|
||||
"placeholderDefault": "输入您的消息...",
|
||||
"disabled": "输入已禁用",
|
||||
"attachFiles": "附加文件",
|
||||
"attachImages": "附加图片",
|
||||
"send": "发送",
|
||||
"stop": "停止",
|
||||
"hintText": {
|
||||
"ctrlEnter": "Ctrl+Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令",
|
||||
"enter": "Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令"
|
||||
},
|
||||
"clickToChangeMode": "点击更改权限模式(或在输入框中按 Tab)",
|
||||
"showAllCommands": "显示所有命令"
|
||||
},
|
||||
"thinkingMode": {
|
||||
"selector": {
|
||||
"title": "思考模式",
|
||||
"description": "扩展思考给 Claude 更多时间来评估替代方案",
|
||||
"active": "激活",
|
||||
"tip": "更高的思考模式需要更多时间,但提供更彻底的分析"
|
||||
},
|
||||
"modes": {
|
||||
"none": {
|
||||
"name": "标准",
|
||||
"description": "常规 Claude 响应",
|
||||
"prefix": ""
|
||||
},
|
||||
"think": {
|
||||
"name": "思考",
|
||||
"description": "基本扩展思考",
|
||||
"prefix": "思考"
|
||||
},
|
||||
"thinkHard": {
|
||||
"name": "深入思考",
|
||||
"description": "更彻底的评估",
|
||||
"prefix": "深入思考"
|
||||
},
|
||||
"thinkHarder": {
|
||||
"name": "更深入思考",
|
||||
"description": "考虑替代方案的深度分析",
|
||||
"prefix": "更深入思考"
|
||||
},
|
||||
"ultrathink": {
|
||||
"name": "超级思考",
|
||||
"description": "最大思考预算",
|
||||
"prefix": "超级思考"
|
||||
}
|
||||
},
|
||||
"buttonTitle": "思考模式:{{mode}}"
|
||||
},
|
||||
"providerSelection": {
|
||||
"title": "选择您的 AI 助手",
|
||||
"description": "选择一个供应商以开始新对话",
|
||||
"selectModel": "选择模型",
|
||||
"providerInfo": {
|
||||
"anthropic": "Anthropic",
|
||||
"openai": "OpenAI",
|
||||
"cursorEditor": "AI 代码编辑器"
|
||||
},
|
||||
"readyPrompt": {
|
||||
"claude": "已准备好使用 Claude {{model}}。在下方输入您的消息。",
|
||||
"cursor": "已准备好使用 Cursor {{model}}。在下方输入您的消息。",
|
||||
"codex": "已准备好使用 Codex {{model}}。在下方输入您的消息。",
|
||||
"default": "请在上方选择一个供应商以开始"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"continue": {
|
||||
"title": "继续您的对话",
|
||||
"description": "询问有关代码的问题、请求更改或获取开发任务的帮助"
|
||||
},
|
||||
"loading": {
|
||||
"olderMessages": "正在加载更早的消息...",
|
||||
"sessionMessages": "正在加载会话消息..."
|
||||
},
|
||||
"messages": {
|
||||
"showingOf": "显示 {{shown}} / {{total}} 条消息",
|
||||
"scrollToLoad": "向上滚动以加载更多",
|
||||
"showingLast": "显示最近 {{count}} 条消息(共 {{total}} 条)",
|
||||
"loadEarlier": "加载更早的消息"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"selectProject": {
|
||||
"title": "选择项目",
|
||||
"description": "选择一个项目以在该目录中打开交互式 Shell"
|
||||
},
|
||||
"status": {
|
||||
"newSession": "新会话",
|
||||
"initializing": "初始化中...",
|
||||
"restarting": "重启中..."
|
||||
},
|
||||
"actions": {
|
||||
"disconnect": "断开连接",
|
||||
"disconnectTitle": "断开 Shell 连接",
|
||||
"restart": "重启",
|
||||
"restartTitle": "重启 Shell(请先断开连接)",
|
||||
"connect": "在 Shell 中继续",
|
||||
"connectTitle": "连接到 Shell"
|
||||
},
|
||||
"loading": "正在加载终端...",
|
||||
"connecting": "正在连接到 Shell...",
|
||||
"startSession": "启动新的 Claude 会话",
|
||||
"resumeSession": "恢复会话:{{displayName}}...",
|
||||
"runCommand": "在 {{projectName}} 中运行 {{command}}",
|
||||
"startCli": "在 {{projectName}} 中启动 Claude CLI",
|
||||
"defaultCommand": "命令"
|
||||
}
|
||||
}
|
||||