Compare commits
59 Commits
v1.12.0
...
72c4b0749e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c4b0749e | ||
|
|
97ebef016a | ||
|
|
005033136b | ||
|
|
8fb43d358c | ||
|
|
4c40a33255 | ||
|
|
4086fdaa4e | ||
|
|
124c1ac600 | ||
|
|
9efe433d99 | ||
|
|
189a1b174c | ||
|
|
04a0ff311e | ||
|
|
efae890e34 | ||
|
|
ea33810a4f | ||
|
|
4fe6cc4272 | ||
|
|
ba70ad8e81 | ||
|
|
b066ec4c01 | ||
|
|
104e4260a7 | ||
|
|
8af982e706 | ||
|
|
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 | ||
|
|
73a0b5bebd | ||
|
|
3a72a262a9 | ||
|
|
e952cf0a42 | ||
|
|
18d0874142 | ||
|
|
33e70c4b55 | ||
|
|
98c8b14b4f | ||
|
|
544c72434a | ||
|
|
b892985700 | ||
|
|
8c629a1a05 | ||
|
|
2df8c8e786 | ||
|
|
f91f9f702d | ||
|
|
6219c273a2 | ||
|
|
33834d808b | ||
|
|
abe8cd46a2 | ||
|
|
521fce32d0 | ||
|
|
2815e206dc |
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 |
83
README.md
@@ -1,10 +1,10 @@
|
||||
<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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,7 +30,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 +41,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 +57,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)
|
||||
|
||||
@@ -87,32 +88,31 @@ claude-code-ui
|
||||
|
||||
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
|
||||
|
||||
### CLI Commands
|
||||
**To update**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### 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
|
||||
# Start the server (default command)
|
||||
claude-code-ui
|
||||
cloudcli start
|
||||
|
||||
# Show configuration and data locations
|
||||
cloudcli status
|
||||
|
||||
# Show help information
|
||||
cloudcli help
|
||||
|
||||
# Show version
|
||||
cloudcli version
|
||||
```
|
||||
|
||||
**The `cloudcli status` command shows you:**
|
||||
- Installation directory location
|
||||
- Database location (where credentials are stored)
|
||||
- Current configuration (PORT, DATABASE_PATH, etc.)
|
||||
- Claude projects folder location
|
||||
- Configuration file location
|
||||
|
||||
cloudcli # Start with defaults
|
||||
cloudcli -p 8080 # Start on custom port
|
||||
cloudcli status # Show current configuration
|
||||
```
|
||||
|
||||
### Run as Background Service (Recommended for Production)
|
||||
@@ -133,6 +133,9 @@ 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
|
||||
```
|
||||
|
||||
|
||||
@@ -218,15 +221,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
|
||||
@@ -264,16 +267,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)
|
||||
@@ -320,7 +323,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
|
||||
|
||||
@@ -343,6 +346,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
|
||||
@@ -361,5 +366,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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
37
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
@@ -18,12 +18,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",
|
||||
@@ -1061,6 +1064,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@iarna/toml": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
|
||||
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||
@@ -2416,6 +2425,15 @@
|
||||
"@octokit/openapi-types": "^26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-sdk": {
|
||||
"version": "0.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.75.0.tgz",
|
||||
"integrity": "sha512-4X5kHPXLu16SmGUdsvSa9xRuVmRC8oQw62iH8dRyIDbyy2MNkh068NNoHWDoJErRLUc4X4Ed2ceiNs6Tbkswnw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@phun-ky/typeof": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@phun-ky/typeof/-/typeof-2.0.3.tgz",
|
||||
@@ -3024,6 +3042,15 @@
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
|
||||
@@ -3738,9 +3765,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001727",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
|
||||
"version": "1.0.30001761",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
|
||||
"integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.6",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"files": [
|
||||
"server/",
|
||||
"shared/",
|
||||
"dist/",
|
||||
"README.md"
|
||||
],
|
||||
@@ -49,12 +50,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",
|
||||
|
||||
@@ -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 |
@@ -16,6 +16,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
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();
|
||||
@@ -57,7 +58,7 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
|
||||
// Add plan mode default tools
|
||||
if (permissionMode === 'plan') {
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
|
||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
|
||||
for (const tool of planModeTools) {
|
||||
if (!allowedTools.includes(tool)) {
|
||||
allowedTools.push(tool);
|
||||
@@ -76,8 +77,9 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
@@ -183,7 +185,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,
|
||||
@@ -239,7 +241,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);
|
||||
@@ -272,7 +274,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);
|
||||
}
|
||||
@@ -292,7 +294,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;
|
||||
}
|
||||
|
||||
@@ -302,7 +304,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;
|
||||
}
|
||||
|
||||
@@ -312,7 +314,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
|
||||
@@ -320,20 +322,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;
|
||||
}
|
||||
}
|
||||
@@ -380,7 +382,7 @@ 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) {
|
||||
@@ -396,33 +398,33 @@ 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
|
||||
}));
|
||||
});
|
||||
|
||||
// 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
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,14 +438,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);
|
||||
@@ -457,10 +459,10 @@ 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
|
||||
}));
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -480,7 +482,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();
|
||||
|
||||
134
server/cli.js
@@ -14,6 +14,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
@@ -115,7 +116,7 @@ function showStatus() {
|
||||
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
|
||||
|
||||
// Claude projects folder
|
||||
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
|
||||
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)}`);
|
||||
@@ -130,10 +131,10 @@ function showStatus() {
|
||||
|
||||
console.log('\n' + c.dim('═'.repeat(60)));
|
||||
console.log(`\n${c.tip('[TIP]')} Hints:`);
|
||||
console.log(` ${c.dim('>')} Set DATABASE_PATH env variable to use a custom database location`);
|
||||
console.log(` ${c.dim('>')} Create .env file in installation directory for persistent config`);
|
||||
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`);
|
||||
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`);
|
||||
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
|
||||
@@ -144,19 +145,28 @@ function showHelp() {
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Usage:
|
||||
claude-code-ui [command]
|
||||
cloudcli [command]
|
||||
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:
|
||||
$ claude-code-ui # Start the server
|
||||
$ cloudcli status # Show configuration
|
||||
$ cloudcli help # Show help
|
||||
$ 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)
|
||||
@@ -164,11 +174,6 @@ Environment Variables:
|
||||
CLAUDE_CLI_PATH Set custom Claude CLI path
|
||||
CONTEXT_WINDOW Set context window size (default: 160000)
|
||||
|
||||
Configuration:
|
||||
Create a .env file in the installation directory to set
|
||||
persistent environment variables. Use 'cloudcli status' to
|
||||
see the installation directory path.
|
||||
|
||||
Documentation:
|
||||
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
|
||||
|
||||
@@ -182,16 +187,110 @@ 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 = args[0] || 'start';
|
||||
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':
|
||||
@@ -211,6 +310,9 @@ async function main() {
|
||||
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');
|
||||
|
||||
@@ -102,29 +102,29 @@ 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
|
||||
}));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
// Forward user message
|
||||
ws.send(JSON.stringify({
|
||||
ws.send({
|
||||
type: 'cursor-user',
|
||||
data: response
|
||||
}));
|
||||
});
|
||||
break;
|
||||
|
||||
case 'assistant':
|
||||
@@ -134,7 +134,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',
|
||||
@@ -143,7 +143,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
text: textContent
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -153,37 +153,37 @@ 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'
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
}));
|
||||
});
|
||||
}
|
||||
} 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
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -191,10 +191,10 @@ 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()
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
@@ -205,12 +205,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 +226,12 @@ 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
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
|
||||
@@ -55,12 +55,40 @@ if (process.env.DATABASE_PATH) {
|
||||
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 () => {
|
||||
try {
|
||||
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;
|
||||
@@ -126,6 +154,42 @@ const userDb = {
|
||||
} 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
|
||||
|
||||
319
server/index.js
@@ -60,6 +60,7 @@ import mime from 'mime-types';
|
||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
|
||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||
import gitRoutes from './routes/git.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import mcpRoutes from './routes/mcp.js';
|
||||
@@ -70,6 +71,9 @@ import commandsRoutes from './routes/commands.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import agentRoutes from './routes/agent.js';
|
||||
import projectsRoutes from './routes/projects.js';
|
||||
import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
|
||||
@@ -80,7 +84,7 @@ const connectedClients = new Set();
|
||||
// Setup file system watcher for Claude projects folder using chokidar
|
||||
async function setupProjectsWatcher() {
|
||||
const chokidar = (await import('chokidar')).default;
|
||||
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
|
||||
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
|
||||
|
||||
if (projectsWatcher) {
|
||||
projectsWatcher.close();
|
||||
@@ -164,6 +168,9 @@ async function setupProjectsWatcher() {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
const ptySessionsMap = new Map();
|
||||
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
// Single WebSocket server that handles both paths
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
@@ -206,7 +213,17 @@ const wss = new WebSocketServer({
|
||||
app.locals.wss = wss;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.json({
|
||||
limit: '50mb',
|
||||
type: (req) => {
|
||||
// Skip multipart/form-data requests (for file uploads like images)
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
return false;
|
||||
}
|
||||
return contentType.includes('json');
|
||||
}
|
||||
}));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
// Public health check endpoint (no authentication required)
|
||||
@@ -247,6 +264,15 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
|
||||
// Settings API Routes (protected)
|
||||
app.use('/api/settings', authenticateToken, settingsRoutes);
|
||||
|
||||
// CLI Authentication API Routes (protected)
|
||||
app.use('/api/cli', authenticateToken, cliAuthRoutes);
|
||||
|
||||
// User API Routes (protected)
|
||||
app.use('/api/user', authenticateToken, userRoutes);
|
||||
|
||||
// Codex API Routes (protected)
|
||||
app.use('/api/codex', authenticateToken, codexRoutes);
|
||||
|
||||
// Agent API Routes (uses API key authentication)
|
||||
app.use('/api/agent', agentRoutes);
|
||||
|
||||
@@ -397,9 +423,12 @@ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res)
|
||||
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
|
||||
await deleteSession(projectName, sessionId);
|
||||
console.log(`[API] Session ${sessionId} deleted successfully`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -433,7 +462,7 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
||||
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: dirPath } = req.query;
|
||||
|
||||
@@ -481,9 +510,9 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
suggestions.push(...directories);
|
||||
}
|
||||
|
||||
res.json({
|
||||
res.json({
|
||||
path: targetPath,
|
||||
suggestions: suggestions
|
||||
suggestions: suggestions
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -688,6 +717,32 @@ wss.on('connection', (ws, request) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||
*/
|
||||
class WebSocketWriter {
|
||||
constructor(ws) {
|
||||
this.ws = ws;
|
||||
this.sessionId = null;
|
||||
this.isWebSocketWriter = true; // Marker for transport detection
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
||||
// Providers send raw objects, we stringify for WebSocket
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
setSessionId(sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
getSessionId() {
|
||||
return this.sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat WebSocket connections
|
||||
function handleChatConnection(ws) {
|
||||
console.log('[INFO] Chat WebSocket connected');
|
||||
@@ -695,6 +750,9 @@ function handleChatConnection(ws) {
|
||||
// Add to connected clients for project updates
|
||||
connectedClients.add(ws);
|
||||
|
||||
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
||||
const writer = new WebSocketWriter(ws);
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
@@ -705,13 +763,19 @@ function handleChatConnection(ws) {
|
||||
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||
|
||||
// Use Claude Agents SDK
|
||||
await queryClaudeSDK(data.command, data.options, ws);
|
||||
await queryClaudeSDK(data.command, data.options, writer);
|
||||
} else if (data.type === 'cursor-command') {
|
||||
console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
|
||||
console.log('📁 Project:', data.options?.cwd || 'Unknown');
|
||||
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||
console.log('🤖 Model:', data.options?.model || 'default');
|
||||
await spawnCursor(data.command, data.options, ws);
|
||||
await spawnCursor(data.command, data.options, writer);
|
||||
} else if (data.type === 'codex-command') {
|
||||
console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
|
||||
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
|
||||
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||
console.log('🤖 Model:', data.options?.model || 'default');
|
||||
await queryCodex(data.command, data.options, writer);
|
||||
} else if (data.type === 'cursor-resume') {
|
||||
// Backward compatibility: treat as cursor-command with resume and no prompt
|
||||
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
||||
@@ -719,7 +783,7 @@ function handleChatConnection(ws) {
|
||||
sessionId: data.sessionId,
|
||||
resume: true,
|
||||
cwd: data.options?.cwd
|
||||
}, ws);
|
||||
}, writer);
|
||||
} else if (data.type === 'abort-session') {
|
||||
console.log('[DEBUG] Abort session request:', data.sessionId);
|
||||
const provider = data.provider || 'claude';
|
||||
@@ -727,26 +791,28 @@ function handleChatConnection(ws) {
|
||||
|
||||
if (provider === 'cursor') {
|
||||
success = abortCursorSession(data.sessionId);
|
||||
} else if (provider === 'codex') {
|
||||
success = abortCodexSession(data.sessionId);
|
||||
} else {
|
||||
// Use Claude Agents SDK
|
||||
success = await abortClaudeSDKSession(data.sessionId);
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
writer.send({
|
||||
type: 'session-aborted',
|
||||
sessionId: data.sessionId,
|
||||
provider,
|
||||
success
|
||||
}));
|
||||
});
|
||||
} else if (data.type === 'cursor-abort') {
|
||||
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
||||
const success = abortCursorSession(data.sessionId);
|
||||
ws.send(JSON.stringify({
|
||||
writer.send({
|
||||
type: 'session-aborted',
|
||||
sessionId: data.sessionId,
|
||||
provider: 'cursor',
|
||||
success
|
||||
}));
|
||||
});
|
||||
} else if (data.type === 'check-session-status') {
|
||||
// Check if a specific session is currently processing
|
||||
const provider = data.provider || 'claude';
|
||||
@@ -755,34 +821,37 @@ function handleChatConnection(ws) {
|
||||
|
||||
if (provider === 'cursor') {
|
||||
isActive = isCursorSessionActive(sessionId);
|
||||
} else if (provider === 'codex') {
|
||||
isActive = isCodexSessionActive(sessionId);
|
||||
} else {
|
||||
// Use Claude Agents SDK
|
||||
isActive = isClaudeSDKSessionActive(sessionId);
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
writer.send({
|
||||
type: 'session-status',
|
||||
sessionId,
|
||||
provider,
|
||||
isProcessing: isActive
|
||||
}));
|
||||
});
|
||||
} else if (data.type === 'get-active-sessions') {
|
||||
// Get all currently active sessions
|
||||
const activeSessions = {
|
||||
claude: getActiveClaudeSDKSessions(),
|
||||
cursor: getActiveCursorSessions()
|
||||
cursor: getActiveCursorSessions(),
|
||||
codex: getActiveCodexSessions()
|
||||
};
|
||||
ws.send(JSON.stringify({
|
||||
writer.send({
|
||||
type: 'active-sessions',
|
||||
sessions: activeSessions
|
||||
}));
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Chat WebSocket error:', error.message);
|
||||
ws.send(JSON.stringify({
|
||||
writer.send({
|
||||
type: 'error',
|
||||
error: error.message
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -797,6 +866,8 @@ function handleChatConnection(ws) {
|
||||
function handleShellConnection(ws) {
|
||||
console.log('🐚 Shell client connected');
|
||||
let shellProcess = null;
|
||||
let ptySessionKey = null;
|
||||
let outputBuffer = [];
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
@@ -804,7 +875,6 @@ function handleShellConnection(ws) {
|
||||
console.log('📨 Shell message received:', data.type);
|
||||
|
||||
if (data.type === 'init') {
|
||||
// Initialize shell with project path and session info
|
||||
const projectPath = data.projectPath || process.cwd();
|
||||
const sessionId = data.sessionId;
|
||||
const hasSession = data.hasSession;
|
||||
@@ -812,6 +882,57 @@ function handleShellConnection(ws) {
|
||||
const initialCommand = data.initialCommand;
|
||||
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
||||
|
||||
// Login commands (Claude/Cursor auth) should never reuse cached sessions
|
||||
const isLoginCommand = initialCommand && (
|
||||
initialCommand.includes('setup-token') ||
|
||||
initialCommand.includes('cursor-agent login') ||
|
||||
initialCommand.includes('auth login')
|
||||
);
|
||||
|
||||
// Include command hash in session key so different commands get separate sessions
|
||||
const commandSuffix = isPlainShell && initialCommand
|
||||
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
|
||||
: '';
|
||||
ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
|
||||
|
||||
// Kill any existing login session before starting fresh
|
||||
if (isLoginCommand) {
|
||||
const oldSession = ptySessionsMap.get(ptySessionKey);
|
||||
if (oldSession) {
|
||||
console.log('🧹 Cleaning up existing login session:', ptySessionKey);
|
||||
if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
|
||||
if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
|
||||
ptySessionsMap.delete(ptySessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
|
||||
if (existingSession) {
|
||||
console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
|
||||
shellProcess = existingSession.pty;
|
||||
|
||||
clearTimeout(existingSession.timeoutId);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
|
||||
}));
|
||||
|
||||
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
||||
console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
|
||||
existingSession.buffer.forEach(bufferedData => {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: bufferedData
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
existingSession.ws = ws;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[INFO] Starting shell in:', projectPath);
|
||||
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
||||
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
||||
@@ -885,11 +1006,16 @@ function handleShellConnection(ws) {
|
||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
||||
const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||
|
||||
// Use terminal dimensions from client if provided, otherwise use defaults
|
||||
const termCols = data.cols || 80;
|
||||
const termRows = data.rows || 24;
|
||||
console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
|
||||
|
||||
shellProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
|
||||
cols: termCols,
|
||||
rows: termRows,
|
||||
cwd: os.homedir(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
@@ -902,9 +1028,28 @@ function handleShellConnection(ws) {
|
||||
|
||||
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
|
||||
|
||||
ptySessionsMap.set(ptySessionKey, {
|
||||
pty: shellProcess,
|
||||
ws: ws,
|
||||
buffer: [],
|
||||
timeoutId: null,
|
||||
projectPath,
|
||||
sessionId
|
||||
});
|
||||
|
||||
// Handle data output
|
||||
shellProcess.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (!session) return;
|
||||
|
||||
if (session.buffer.length < 5000) {
|
||||
session.buffer.push(data);
|
||||
} else {
|
||||
session.buffer.shift();
|
||||
session.buffer.push(data);
|
||||
}
|
||||
|
||||
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
let outputData = data;
|
||||
|
||||
// Check for various URL opening patterns
|
||||
@@ -928,7 +1073,7 @@ function handleShellConnection(ws) {
|
||||
console.log('[DEBUG] Detected URL for opening:', url);
|
||||
|
||||
// Send URL opening message to client
|
||||
ws.send(JSON.stringify({
|
||||
session.ws.send(JSON.stringify({
|
||||
type: 'url_open',
|
||||
url: url
|
||||
}));
|
||||
@@ -941,7 +1086,7 @@ function handleShellConnection(ws) {
|
||||
});
|
||||
|
||||
// Send regular output
|
||||
ws.send(JSON.stringify({
|
||||
session.ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: outputData
|
||||
}));
|
||||
@@ -951,12 +1096,17 @@ function handleShellConnection(ws) {
|
||||
// Handle process exit
|
||||
shellProcess.onExit((exitCode) => {
|
||||
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
|
||||
}));
|
||||
}
|
||||
if (session && session.timeoutId) {
|
||||
clearTimeout(session.timeoutId);
|
||||
}
|
||||
ptySessionsMap.delete(ptySessionKey);
|
||||
shellProcess = null;
|
||||
});
|
||||
|
||||
@@ -999,9 +1149,21 @@ function handleShellConnection(ws) {
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Shell client disconnected');
|
||||
if (shellProcess && shellProcess.kill) {
|
||||
console.log('🔴 Killing shell process:', shellProcess.pid);
|
||||
shellProcess.kill();
|
||||
|
||||
if (ptySessionKey) {
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (session) {
|
||||
console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
|
||||
session.ws = null;
|
||||
|
||||
session.timeoutId = setTimeout(() => {
|
||||
console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
|
||||
if (session.pty && session.pty.kill) {
|
||||
session.pty.kill();
|
||||
}
|
||||
ptySessionsMap.delete(ptySessionKey);
|
||||
}, PTY_SESSION_TIMEOUT);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1247,8 +1409,98 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
||||
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
const { provider = 'claude' } = req.query;
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// Allow only safe characters in sessionId
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
|
||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||
if (provider === 'cursor') {
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Cursor sessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Codex sessions
|
||||
if (provider === 'codex') {
|
||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
||||
|
||||
// Find the session file by searching for the session ID
|
||||
const findSessionFile = async (dir) => {
|
||||
try {
|
||||
const entries = await fsPromises.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(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
||||
|
||||
if (!sessionFilePath) {
|
||||
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
||||
}
|
||||
|
||||
// Read and parse the Codex JSONL file
|
||||
let fileContent;
|
||||
try {
|
||||
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const lines = fileContent.trim().split('\n');
|
||||
let totalTokens = 0;
|
||||
let contextWindow = 200000; // Default for Codex/OpenAI
|
||||
|
||||
// Find the latest token_count event with info (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
|
||||
// Codex stores token info in event_msg with type: "token_count"
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const tokenInfo = entry.payload.info;
|
||||
if (tokenInfo.total_token_usage) {
|
||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
||||
}
|
||||
if (tokenInfo.model_context_window) {
|
||||
contextWindow = tokenInfo.model_context_window;
|
||||
}
|
||||
break; // Stop after finding the latest token count
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip lines that can't be parsed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
used: totalTokens,
|
||||
total: contextWindow
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Claude sessions (default)
|
||||
// Extract actual project path
|
||||
let projectPath;
|
||||
try {
|
||||
@@ -1264,11 +1516,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
||||
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||
|
||||
// Allow only safe characters in sessionId
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
||||
|
||||
// Constrain to projectDir
|
||||
|
||||
389
server/openai-codex.js
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -372,7 +380,7 @@ async function extractProjectDirectory(projectName) {
|
||||
}
|
||||
|
||||
async function getProjects() {
|
||||
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
|
||||
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
||||
const config = await loadProjectConfig();
|
||||
const projects = [];
|
||||
const existingProjects = new Set();
|
||||
@@ -425,7 +433,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);
|
||||
@@ -478,16 +494,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);
|
||||
@@ -522,7 +546,7 @@ async function getProjects() {
|
||||
}
|
||||
|
||||
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 +828,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 +917,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);
|
||||
@@ -956,7 +980,7 @@ async function isProjectEmpty(projectName) {
|
||||
|
||||
// Delete an empty project
|
||||
async function deleteProject(projectName) {
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
// First check if the project is empty
|
||||
@@ -996,7 +1020,7 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
|
||||
// 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}`);
|
||||
@@ -1141,6 +1165,425 @@ async function getCursorSessions(projectPath) {
|
||||
}
|
||||
|
||||
|
||||
// Fetch Codex sessions for a given project path
|
||||
async function getCodexSessions(projectPath) {
|
||||
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 only the first 5 sessions for performance
|
||||
return sessions.slice(0, 5);
|
||||
|
||||
} 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 +1597,8 @@ export {
|
||||
loadProjectConfig,
|
||||
saveProjectConfig,
|
||||
extractProjectDirectory,
|
||||
clearProjectDirectoryCache
|
||||
clearProjectDirectoryCache,
|
||||
getCodexSessions,
|
||||
getCodexSessionMessages,
|
||||
deleteCodexSession
|
||||
};
|
||||
|
||||
@@ -4,16 +4,45 @@ 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';
|
||||
|
||||
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 (VITE_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 (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
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 +451,7 @@ class SSEStreamWriter {
|
||||
constructor(res) {
|
||||
this.res = res;
|
||||
this.sessionId = null;
|
||||
this.isSSEStreamWriter = true; // Marker for transport detection
|
||||
}
|
||||
|
||||
send(data) {
|
||||
@@ -429,7 +459,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 +636,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 +854,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 +946,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 +960,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;
|
||||
345
server/routes/codex.js
Normal file
@@ -0,0 +1,345 @@
|
||||
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: false, message: 'No Codex configuration file found', 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);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
```
|
||||
21
src/App.jsx
@@ -55,7 +55,7 @@ function AppContent() {
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsInitialTab, setSettingsInitialTab] = useState('tools');
|
||||
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
|
||||
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
||||
const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false);
|
||||
const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false);
|
||||
@@ -89,7 +89,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');
|
||||
@@ -176,7 +177,9 @@ function AppContent() {
|
||||
// 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', '');
|
||||
@@ -229,14 +232,16 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -966,4 +971,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
function ApiKeysSettings() {
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
@@ -23,19 +24,14 @@ 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/credentials?type=github_token', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const githubRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
|
||||
const githubData = await githubRes.json();
|
||||
setGithubTokens(githubData.credentials || []);
|
||||
} catch (error) {
|
||||
@@ -49,13 +45,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 })
|
||||
});
|
||||
|
||||
@@ -75,10 +66,8 @@ function ApiKeysSettings() {
|
||||
if (!confirm('Are you sure you want to delete this API key?')) 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 +77,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,13 +91,8 @@ function ApiKeysSettings() {
|
||||
if (!newTokenName.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: newTokenName,
|
||||
credentialType: 'github_token',
|
||||
@@ -137,10 +116,8 @@ function ApiKeysSettings() {
|
||||
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/credentials/${tokenId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
await authenticatedFetch(`/api/settings/credentials/${tokenId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
@@ -150,13 +127,8 @@ function ApiKeysSettings() {
|
||||
|
||||
const toggleGithubToken = async (tokenId, isActive) => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
await fetch(`/api/settings/credentials/${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();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect, memo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
@@ -25,6 +25,7 @@ import { useDropzone } from 'react-dropzone';
|
||||
import TodoList from './TodoList';
|
||||
import ClaudeLogo from './ClaudeLogo.jsx';
|
||||
import CursorLogo from './CursorLogo.jsx';
|
||||
import CodexLogo from './CodexLogo.jsx';
|
||||
import NextTaskBanner from './NextTaskBanner.jsx';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
@@ -34,6 +35,7 @@ import { MicButton } from './MicButton.jsx';
|
||||
import { api, authenticatedFetch } from '../utils/api';
|
||||
import Fuse from 'fuse.js';
|
||||
import CommandMenu from './CommandMenu';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
|
||||
|
||||
|
||||
// Helper function to decode HTML entities in text
|
||||
@@ -442,13 +444,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
|
||||
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
|
||||
<CursorLogo className="w-full h-full" />
|
||||
) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? (
|
||||
<CodexLogo className="w-full h-full" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')}
|
||||
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1522,6 +1526,23 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<span className="font-medium">Read todo list</span>
|
||||
</div>
|
||||
</div>
|
||||
) : message.isThinking ? (
|
||||
/* Thinking messages - collapsible by default */
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 font-medium flex items-center gap-2">
|
||||
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>💭 Thinking...</span>
|
||||
</summary>
|
||||
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm">
|
||||
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Thinking accordion for reasoning */}
|
||||
@@ -1537,7 +1558,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
|
||||
{(() => {
|
||||
const content = formatUsageLimitText(String(message.content || ''));
|
||||
|
||||
@@ -1641,7 +1662,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
||||
//
|
||||
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) {
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
@@ -1675,6 +1696,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const inputContainerRef = useRef(null);
|
||||
const scrollContainerRef = useRef(null);
|
||||
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
const topLoadLockRef = useRef(false);
|
||||
const pendingScrollRestoreRef = useRef(null);
|
||||
// Streaming throttle buffers
|
||||
const streamBufferRef = useRef('');
|
||||
const streamTimerRef = useRef(null);
|
||||
@@ -1703,7 +1727,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
return localStorage.getItem('selected-provider') || 'claude';
|
||||
});
|
||||
const [cursorModel, setCursorModel] = useState(() => {
|
||||
return localStorage.getItem('cursor-model') || 'gpt-5';
|
||||
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
|
||||
});
|
||||
const [claudeModel, setClaudeModel] = useState(() => {
|
||||
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
|
||||
});
|
||||
const [codexModel, setCodexModel] = useState(() => {
|
||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||
});
|
||||
// Load permission mode for the current session
|
||||
useEffect(() => {
|
||||
@@ -1728,25 +1758,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Load Cursor default model from config
|
||||
useEffect(() => {
|
||||
if (provider === 'cursor') {
|
||||
fetch('/api/cursor/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth-token')}`
|
||||
}
|
||||
})
|
||||
authenticatedFetch('/api/cursor/config')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.config?.model?.modelId) {
|
||||
// Map Cursor model IDs to our simplified names
|
||||
const modelMap = {
|
||||
'gpt-5': 'gpt-5',
|
||||
'claude-4-sonnet': 'sonnet-4',
|
||||
'sonnet-4': 'sonnet-4',
|
||||
'claude-4-opus': 'opus-4.1',
|
||||
'opus-4.1': 'opus-4.1'
|
||||
};
|
||||
const mappedModel = modelMap[data.config.model.modelId] || data.config.model.modelId;
|
||||
// Use the model from config directly
|
||||
const modelId = data.config.model.modelId;
|
||||
if (!localStorage.getItem('cursor-model')) {
|
||||
setCursorModel(mappedModel);
|
||||
setCursorModel(modelId);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2040,7 +2059,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
projectName: selectedProject.name,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor' ? cursorModel : 'claude-sonnet-4.5',
|
||||
model: provider === 'cursor' ? cursorModel : claudeModel,
|
||||
tokenUsage: tokenBudget
|
||||
};
|
||||
|
||||
@@ -2113,24 +2132,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}, []);
|
||||
|
||||
// Load session messages from API with pagination
|
||||
const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false) => {
|
||||
const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false, provider = 'claude') => {
|
||||
if (!projectName || !sessionId) return [];
|
||||
|
||||
|
||||
const isInitialLoad = !loadMore;
|
||||
if (isInitialLoad) {
|
||||
setIsLoadingSessionMessages(true);
|
||||
} else {
|
||||
setIsLoadingMoreMessages(true);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const currentOffset = loadMore ? messagesOffset : 0;
|
||||
const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset);
|
||||
const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset, provider);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session messages');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
// Extract token usage if present (Codex includes it in messages response)
|
||||
if (isInitialLoad && data.tokenUsage) {
|
||||
setTokenBudget(data.tokenUsage);
|
||||
}
|
||||
|
||||
// Handle paginated response
|
||||
if (data.hasMore !== undefined) {
|
||||
setHasMoreMessages(data.hasMore);
|
||||
@@ -2573,6 +2597,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}
|
||||
|
||||
// Handle thinking messages (Codex reasoning)
|
||||
else if (msg.type === 'thinking' && msg.message?.content) {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: unescapeWithMathProtection(msg.message.content),
|
||||
timestamp: msg.timestamp || new Date().toISOString(),
|
||||
isThinking: true
|
||||
});
|
||||
}
|
||||
|
||||
// Handle tool_use messages (Codex function calls)
|
||||
else if (msg.type === 'tool_use' && msg.toolName) {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: msg.timestamp || new Date().toISOString(),
|
||||
isToolUse: true,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput || '',
|
||||
toolCallId: msg.toolCallId
|
||||
});
|
||||
}
|
||||
|
||||
// Handle tool_result messages (Codex function outputs)
|
||||
else if (msg.type === 'tool_result') {
|
||||
// Find the matching tool_use by callId, or the last tool_use without a result
|
||||
for (let i = converted.length - 1; i >= 0; i--) {
|
||||
if (converted[i].isToolUse && !converted[i].toolResult) {
|
||||
if (!msg.toolCallId || converted[i].toolCallId === msg.toolCallId) {
|
||||
converted[i].toolResult = {
|
||||
content: msg.output || '',
|
||||
isError: false
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
else if (msg.message?.role === 'assistant' && msg.message?.content) {
|
||||
if (Array.isArray(msg.message.content)) {
|
||||
@@ -2650,6 +2713,39 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
return scrollHeight - scrollTop - clientHeight < 50;
|
||||
}, []);
|
||||
|
||||
const loadOlderMessages = useCallback(async (container) => {
|
||||
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false;
|
||||
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
|
||||
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider === 'cursor') return false;
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
const previousScrollHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
|
||||
try {
|
||||
const moreMessages = await loadSessionMessages(
|
||||
selectedProject.name,
|
||||
selectedSession.id,
|
||||
true,
|
||||
sessionProvider
|
||||
);
|
||||
|
||||
if (moreMessages.length > 0) {
|
||||
pendingScrollRestoreRef.current = {
|
||||
height: previousScrollHeight,
|
||||
top: previousScrollTop
|
||||
};
|
||||
// Prepend new messages to the existing ones
|
||||
setSessionMessages(prev => [...moreMessages, ...prev]);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}, [hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
|
||||
|
||||
// Handle scroll events to detect when user manually scrolls up and load more messages
|
||||
const handleScroll = useCallback(async () => {
|
||||
if (scrollContainerRef.current) {
|
||||
@@ -2659,32 +2755,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Check if we should load more messages (scrolled near top)
|
||||
const scrolledNearTop = container.scrollTop < 100;
|
||||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||||
|
||||
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') {
|
||||
// Save current scroll position
|
||||
const previousScrollHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
|
||||
// Load more messages
|
||||
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true);
|
||||
|
||||
if (moreMessages.length > 0) {
|
||||
// Prepend new messages to the existing ones
|
||||
setSessionMessages(prev => [...moreMessages, ...prev]);
|
||||
|
||||
// Restore scroll position after DOM update
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const newScrollHeight = scrollContainerRef.current.scrollHeight;
|
||||
const scrollDiff = newScrollHeight - previousScrollHeight;
|
||||
scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff;
|
||||
}
|
||||
}, 0);
|
||||
if (!scrolledNearTop) {
|
||||
topLoadLockRef.current = false;
|
||||
} else if (!topLoadLockRef.current) {
|
||||
const didLoad = await loadOlderMessages(container);
|
||||
if (didLoad) {
|
||||
topLoadLockRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
|
||||
}, [isNearBottom, loadOlderMessages]);
|
||||
|
||||
// Restore scroll position after paginated messages render
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
||||
|
||||
const { height, top } = pendingScrollRestoreRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
const scrollDiff = newScrollHeight - height;
|
||||
|
||||
container.scrollTop = top + Math.max(scrollDiff, 0);
|
||||
pendingScrollRestoreRef.current = null;
|
||||
}, [chatMessages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load session messages when session changes
|
||||
@@ -2758,7 +2851,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Only load messages from API if this is a user-initiated session change
|
||||
// For system-initiated changes, preserve existing messages and rely on WebSocket
|
||||
if (!isSystemSessionChange) {
|
||||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false);
|
||||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude');
|
||||
setSessionMessages(messages);
|
||||
// convertedMessages will be automatically updated via useMemo
|
||||
// Scroll will be handled by the main scroll useEffect after messages are rendered
|
||||
@@ -2807,13 +2900,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
setSessionMessages([]);
|
||||
setChatMessages(converted);
|
||||
} else {
|
||||
// Reload Claude messages from API/JSONL
|
||||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false);
|
||||
// Reload Claude/Codex messages from API/JSONL
|
||||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude');
|
||||
setSessionMessages(messages);
|
||||
// convertedMessages will be automatically updated via useMemo
|
||||
|
||||
// Smart scroll behavior: only auto-scroll if user is near bottom
|
||||
if (isNearBottom && autoScrollToBottom) {
|
||||
const shouldAutoScroll = autoScrollToBottom && isNearBottom();
|
||||
if (shouldAutoScroll) {
|
||||
setTimeout(() => scrollToBottom(), 200);
|
||||
}
|
||||
// If user scrolled up, preserve their position (they're reading history)
|
||||
@@ -2894,7 +2988,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Filter messages by session ID to prevent cross-session interference
|
||||
// Skip filtering for global messages that apply to all sessions
|
||||
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete'];
|
||||
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete', 'codex-complete'];
|
||||
const isGlobalMessage = globalMessageTypes.includes(latestMessage.type);
|
||||
|
||||
// For new sessions (currentSessionId is null), allow messages through
|
||||
@@ -2911,6 +3005,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
if (latestMessage.sessionId && !currentSessionId) {
|
||||
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
|
||||
|
||||
// Mark as system change to prevent clearing messages when session ID updates
|
||||
setIsSystemSessionChange(true);
|
||||
|
||||
// Session Protection: Replace temporary "new-session-*" identifier with real session ID
|
||||
// This maintains protection continuity - no gap between temp ID and real ID
|
||||
// The temporary session is removed and real session is marked as active
|
||||
@@ -2921,8 +3018,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
break;
|
||||
|
||||
case 'token-budget':
|
||||
// Token budget now fetched via API after message completion instead of WebSocket
|
||||
// This case is kept for compatibility but does nothing
|
||||
// Use token budget from WebSocket for active sessions
|
||||
if (latestMessage.data) {
|
||||
setTokenBudget(latestMessage.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'claude-response':
|
||||
@@ -3315,23 +3414,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
|
||||
// Fetch updated token usage after message completes
|
||||
if (selectedProject && selectedSession?.id) {
|
||||
const fetchUpdatedTokenUsage = async () => {
|
||||
try {
|
||||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTokenBudget(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch updated token usage:', error);
|
||||
}
|
||||
};
|
||||
fetchUpdatedTokenUsage();
|
||||
}
|
||||
}
|
||||
|
||||
// Always mark the completed session as inactive and not processing
|
||||
@@ -3359,7 +3441,159 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'codex-response':
|
||||
// Handle Codex SDK responses
|
||||
const codexData = latestMessage.data;
|
||||
if (codexData) {
|
||||
// Handle item events
|
||||
if (codexData.type === 'item') {
|
||||
switch (codexData.itemType) {
|
||||
case 'agent_message':
|
||||
if (codexData.message?.content?.trim()) {
|
||||
const content = decodeHtmlEntities(codexData.message.content);
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reasoning':
|
||||
if (codexData.message?.content?.trim()) {
|
||||
const content = decodeHtmlEntities(codexData.message.content);
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: content,
|
||||
timestamp: new Date(),
|
||||
isThinking: true
|
||||
}]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'command_execution':
|
||||
if (codexData.command) {
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: 'Bash',
|
||||
toolInput: codexData.command,
|
||||
toolResult: codexData.output || null,
|
||||
exitCode: codexData.exitCode
|
||||
}]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file_change':
|
||||
if (codexData.changes?.length > 0) {
|
||||
const changesList = codexData.changes.map(c => `${c.kind}: ${c.path}`).join('\n');
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: 'FileChanges',
|
||||
toolInput: changesList,
|
||||
toolResult: `Status: ${codexData.status}`
|
||||
}]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mcp_tool_call':
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: `${codexData.server}:${codexData.tool}`,
|
||||
toolInput: JSON.stringify(codexData.arguments, null, 2),
|
||||
toolResult: codexData.result ? JSON.stringify(codexData.result, null, 2) : (codexData.error?.message || null)
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
if (codexData.message?.content) {
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'error',
|
||||
content: codexData.message.content,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[Codex] Unhandled item type:', codexData.itemType, codexData);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle turn complete
|
||||
if (codexData.type === 'turn_complete') {
|
||||
// Turn completed, message stream done
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// Handle turn failed
|
||||
if (codexData.type === 'turn_failed') {
|
||||
setIsLoading(false);
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'error',
|
||||
content: codexData.error?.message || 'Turn failed',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'codex-complete':
|
||||
// Handle Codex session completion
|
||||
const codexCompletedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId');
|
||||
|
||||
if (codexCompletedSessionId === currentSessionId || !currentSessionId) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
|
||||
if (codexCompletedSessionId) {
|
||||
if (onSessionInactive) {
|
||||
onSessionInactive(codexCompletedSessionId);
|
||||
}
|
||||
if (onSessionNotProcessing) {
|
||||
onSessionNotProcessing(codexCompletedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
|
||||
if (codexPendingSessionId && !currentSessionId) {
|
||||
setCurrentSessionId(codexActualSessionId);
|
||||
setIsSystemSessionChange(true);
|
||||
if (onNavigateToSession) {
|
||||
onNavigateToSession(codexActualSessionId);
|
||||
}
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
console.log('Codex session complete, ID set to:', codexPendingSessionId);
|
||||
}
|
||||
|
||||
if (selectedProject) {
|
||||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'codex-error':
|
||||
// Handle Codex errors
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'error',
|
||||
content: latestMessage.error || 'An error occurred with Codex',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'session-aborted': {
|
||||
// Get session ID from message or fall back to current session
|
||||
const abortedSessionId = latestMessage.sessionId || currentSessionId;
|
||||
@@ -3610,21 +3844,26 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
// Load token usage when session changes (but don't poll to avoid conflicts with WebSocket)
|
||||
// Load token usage when session changes for Claude sessions only
|
||||
// (Codex token usage is included in messages response, Cursor doesn't support it)
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
||||
// Reset for new/empty sessions
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch token usage once when session loads
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
|
||||
// Skip for Codex (included in messages) and Cursor (not supported)
|
||||
if (sessionProvider !== 'claude') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch token usage for Claude sessions
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||||
|
||||
const response = await authenticatedFetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTokenBudget(data);
|
||||
@@ -3637,7 +3876,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
};
|
||||
|
||||
fetchInitialTokenUsage();
|
||||
}, [selectedSession?.id, selectedProject?.path]);
|
||||
}, [selectedSession?.id, selectedSession?.__provider, selectedProject?.path]);
|
||||
|
||||
const handleTranscript = useCallback((text) => {
|
||||
if (text.trim()) {
|
||||
@@ -3752,15 +3991,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
});
|
||||
|
||||
try {
|
||||
const token = safeLocalStorage.getItem('auth-token');
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${selectedProject.name}/upload-images`, {
|
||||
const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
body: formData
|
||||
});
|
||||
|
||||
@@ -3815,7 +4048,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Get tools settings from localStorage based on provider
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings';
|
||||
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : provider === 'codex' ? 'codex-settings' : 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
@@ -3850,6 +4083,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
toolsSettings: toolsSettings
|
||||
}
|
||||
});
|
||||
} else if (provider === 'codex') {
|
||||
// Send Codex command
|
||||
sendMessage({
|
||||
type: 'codex-command',
|
||||
command: input,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: selectedProject.fullPath || selectedProject.path,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: !!effectiveSessionId,
|
||||
model: codexModel,
|
||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Send Claude command (existing code)
|
||||
sendMessage({
|
||||
@@ -3862,6 +4110,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
resume: !!currentSessionId,
|
||||
toolsSettings: toolsSettings,
|
||||
permissionMode: permissionMode,
|
||||
model: claudeModel,
|
||||
images: uploadedImages // Pass images to backend
|
||||
}
|
||||
});
|
||||
@@ -3882,7 +4131,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
if (selectedProject) {
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
}
|
||||
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
|
||||
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
|
||||
|
||||
// Store handleSubmit in ref so handleCustomCommand can access it
|
||||
useEffect(() => {
|
||||
@@ -3992,7 +4241,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Handle Tab key for mode switching (only when dropdowns are not showing)
|
||||
if (e.key === 'Tab' && !showFileDropdown && !showCommandMenu) {
|
||||
e.preventDefault();
|
||||
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
// Codex doesn't support plan mode
|
||||
const modes = provider === 'codex'
|
||||
? ['default', 'acceptEdits', 'bypassPermissions']
|
||||
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
const currentIndex = modes.indexOf(permissionMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
const newMode = modes[nextIndex];
|
||||
@@ -4161,7 +4413,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
};
|
||||
|
||||
const handleModeSwitch = () => {
|
||||
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
// Codex doesn't support plan mode
|
||||
const modes = provider === 'codex'
|
||||
? ['default', 'acceptEdits', 'bypassPermissions']
|
||||
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
const currentIndex = modes.indexOf(permissionMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
const newMode = modes[nextIndex];
|
||||
@@ -4197,6 +4452,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
{/* Messages Area - Scrollable Middle Section */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
|
||||
>
|
||||
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
||||
@@ -4279,42 +4536,106 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Selection for Cursor - Always reserve space to prevent jumping */}
|
||||
<div className={`mb-6 transition-opacity duration-200 ${provider === 'cursor' ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{provider === 'cursor' ? 'Select Model' : '\u00A0'}
|
||||
</label>
|
||||
<select
|
||||
value={cursorModel}
|
||||
onChange={(e) => {
|
||||
const newModel = e.target.value;
|
||||
setCursorModel(newModel);
|
||||
localStorage.setItem('cursor-model', newModel);
|
||||
|
||||
{/* Codex Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setProvider('codex');
|
||||
localStorage.setItem('selected-provider', 'codex');
|
||||
// Focus input after selection
|
||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||
}}
|
||||
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
|
||||
disabled={provider !== 'cursor'}
|
||||
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
|
||||
provider === 'codex'
|
||||
? 'border-gray-800 dark:border-gray-300 shadow-lg ring-2 ring-gray-800/20 dark:ring-gray-300/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<option value="gpt-5">GPT-5</option>
|
||||
<option value="sonnet-4">Sonnet-4</option>
|
||||
<option value="opus-4.1">Opus 4.1</option>
|
||||
</select>
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<CodexLogo className="w-10 h-10" />
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">Codex</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">by OpenAI</p>
|
||||
</div>
|
||||
</div>
|
||||
{provider === 'codex' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="w-5 h-5 bg-gray-800 dark:bg-gray-300 rounded-full flex items-center justify-center">
|
||||
<svg className="w-3 h-3 text-white dark:text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - Always reserve space to prevent jumping */}
|
||||
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Select Model
|
||||
</label>
|
||||
{provider === 'claude' ? (
|
||||
<select
|
||||
value={claudeModel}
|
||||
onChange={(e) => {
|
||||
const newModel = e.target.value;
|
||||
setClaudeModel(newModel);
|
||||
localStorage.setItem('claude-model', newModel);
|
||||
}}
|
||||
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
|
||||
>
|
||||
{CLAUDE_MODELS.OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : provider === 'codex' ? (
|
||||
<select
|
||||
value={codexModel}
|
||||
onChange={(e) => {
|
||||
const newModel = e.target.value;
|
||||
setCodexModel(newModel);
|
||||
localStorage.setItem('codex-model', newModel);
|
||||
}}
|
||||
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500 min-w-[140px]"
|
||||
>
|
||||
{CODEX_MODELS.OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
value={cursorModel}
|
||||
onChange={(e) => {
|
||||
const newModel = e.target.value;
|
||||
setCursorModel(newModel);
|
||||
localStorage.setItem('cursor-model', newModel);
|
||||
}}
|
||||
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
|
||||
disabled={provider !== 'cursor'}
|
||||
>
|
||||
{CURSOR_MODELS.OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{provider === 'claude'
|
||||
? 'Ready to use Claude AI. Start typing your message below.'
|
||||
{provider === 'claude'
|
||||
? `Ready to use Claude with ${claudeModel}. Start typing your message below.`
|
||||
: provider === 'cursor'
|
||||
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
|
||||
: provider === 'codex'
|
||||
? `Ready to use Codex with ${codexModel}. Start typing your message below.`
|
||||
: 'Select a provider above to begin'
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Show NextTaskBanner when provider is selected and ready */}
|
||||
{provider && tasksEnabled && (
|
||||
{/* Show NextTaskBanner when provider is selected and ready, only if TaskMaster is installed */}
|
||||
{provider && tasksEnabled && isTaskMasterInstalled && (
|
||||
<div className="mt-4 px-4 sm:px-0">
|
||||
<NextTaskBanner
|
||||
<NextTaskBanner
|
||||
onStartTask={() => setInput('Start the next task')}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
/>
|
||||
@@ -4329,10 +4650,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
Ask questions about your code, request changes, or get help with development tasks
|
||||
</p>
|
||||
|
||||
{/* Show NextTaskBanner for existing sessions too */}
|
||||
{tasksEnabled && (
|
||||
{/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */}
|
||||
{tasksEnabled && isTaskMasterInstalled && (
|
||||
<div className="mt-4 px-4 sm:px-0">
|
||||
<NextTaskBanner
|
||||
<NextTaskBanner
|
||||
onStartTask={() => setInput('Start the next task')}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
/>
|
||||
@@ -4407,11 +4728,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
|
||||
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
|
||||
<CursorLogo className="w-full h-full" />
|
||||
) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? (
|
||||
<CodexLogo className="w-full h-full" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude'}</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude'}</div>
|
||||
{/* Abort button removed - functionality not yet implemented at backend */}
|
||||
</div>
|
||||
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
||||
|
||||
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;
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
function CredentialsSettings() {
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
@@ -29,19 +30,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) {
|
||||
@@ -55,13 +51,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 })
|
||||
});
|
||||
|
||||
@@ -81,10 +72,8 @@ function CredentialsSettings() {
|
||||
if (!confirm('Are you sure you want to delete this API key?')) 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) {
|
||||
@@ -94,13 +83,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();
|
||||
@@ -113,13 +97,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',
|
||||
@@ -145,10 +124,8 @@ function CredentialsSettings() {
|
||||
if (!confirm('Are you sure you want to delete this GitHub token?')) 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) {
|
||||
@@ -158,13 +135,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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -1161,6 +1188,31 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
<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" />
|
||||
|
||||
129
src/components/GitSettings.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
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';
|
||||
|
||||
function GitSettings() {
|
||||
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">Git Configuration</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure your git identity for commits. These settings will be applied globally via <code className="bg-muted px-2 py-0.5 rounded text-xs">git config --global</code>
|
||||
</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">
|
||||
Git Name
|
||||
</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">
|
||||
Your name for git commits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
|
||||
Git Email
|
||||
</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">
|
||||
Your email for git commits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={saveGitConfig}
|
||||
disabled={gitConfigSaving || !gitName || !gitEmail}
|
||||
>
|
||||
{gitConfigSaving ? 'Saving...' : 'Save Configuration'}
|
||||
</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" />
|
||||
Saved successfully
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GitSettings;
|
||||
@@ -1,6 +1,7 @@
|
||||
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)}`;
|
||||
@@ -18,16 +19,7 @@ function ImageViewer({ file, onClose }) {
|
||||
setError(null);
|
||||
setImageUrl(null);
|
||||
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
setError('Missing authentication token');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(imagePath, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
const response = await authenticatedFetch(imagePath, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
|
||||
92
src/components/LoginModal.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { X } from 'lucide-react';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function LoginModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
provider = 'claude',
|
||||
project,
|
||||
onComplete,
|
||||
customCommand
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getCommand = () => {
|
||||
if (customCommand) return customCommand;
|
||||
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return 'claude setup-token --dangerously-skip-permissions';
|
||||
case 'cursor':
|
||||
return 'cursor-agent login';
|
||||
case 'codex':
|
||||
return isPlatform ? 'codex login --device-auth' : 'codex login';
|
||||
default:
|
||||
return 'claude setup-token --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;
|
||||
@@ -496,20 +496,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">
|
||||
|
||||
585
src/components/Onboarding.jsx
Normal file
@@ -0,0 +1,585 @@
|
||||
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';
|
||||
|
||||
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: '' });
|
||||
|
||||
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;
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
@@ -24,14 +25,20 @@ const LoadingScreen = () => (
|
||||
);
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, isLoading, needsSetup } = useAuth();
|
||||
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
|
||||
|
||||
// Platform mode: skip all auth UI and directly render children
|
||||
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <Onboarding onComplete={refreshOnboardingStatus} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
// Normal OSS mode: standard auth flow
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -44,6 +51,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,7 +12,8 @@ import {
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Languages
|
||||
Languages,
|
||||
GripVertical
|
||||
} from 'lucide-react';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
@@ -38,11 +39,170 @@ const QuickSettingsPanel = ({
|
||||
});
|
||||
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,24 +210,37 @@ 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 ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
|
||||
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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';
|
||||
|
||||
// CSS to remove xterm focus outline
|
||||
const xtermStyles = `
|
||||
.xterm .xterm-screen {
|
||||
outline: none !important;
|
||||
@@ -18,7 +17,6 @@ const xtermStyles = `
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles
|
||||
if (typeof document !== 'undefined') {
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.type = 'text/css';
|
||||
@@ -26,10 +24,7 @@ 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 terminalRef = useRef(null);
|
||||
const terminal = useRef(null);
|
||||
const fitAddon = useRef(null);
|
||||
@@ -40,177 +35,211 @@ 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 {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
let wsUrl;
|
||||
|
||||
if (isPlatform) {
|
||||
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
|
||||
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 +248,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 +256,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 +266,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 +295,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 +314,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 +325,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,177 +346,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 {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
|
||||
// Construct WebSocket URL
|
||||
let wsUrl;
|
||||
|
||||
if (isPlatform) {
|
||||
// Platform mode: Use same domain as the page (goes through proxy)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell`;
|
||||
} else {
|
||||
// OSS mode: Connect to same host:port that served the page
|
||||
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);
|
||||
|
||||
// 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 (
|
||||
@@ -526,23 +380,25 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
@@ -566,7 +422,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button
|
||||
onClick={restartShell}
|
||||
disabled={isRestarting || isConnected}
|
||||
@@ -582,18 +438,15 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
@@ -608,23 +461,17 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
<span>Continue in Shell</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
{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)}...`;
|
||||
})() :
|
||||
selectedSession ?
|
||||
`Resume session: ${sessionDisplayNameLong}...` :
|
||||
'Start a new Claude session'
|
||||
}
|
||||
</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">
|
||||
@@ -633,7 +480,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
||||
<span className="text-base font-medium">Connecting to shell...</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
{isPlainShell ?
|
||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRig
|
||||
import { cn } from '../lib/utils';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo.jsx';
|
||||
import CodexLogo from './CodexLogo.jsx';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
import ProjectCreationWizard from './ProjectCreationWizard';
|
||||
import { api } from '../utils/api';
|
||||
@@ -220,12 +221,17 @@ function Sidebar({
|
||||
|
||||
// Helper function to get all sessions for a project (initial + additional)
|
||||
const getAllSessions = (project) => {
|
||||
// Combine Claude and Cursor sessions; Sidebar will display icon per row
|
||||
// Combine Claude, Cursor, and Codex sessions; Sidebar will display icon per row
|
||||
const claudeSessions = [...(project.sessions || []), ...(additionalSessions[project.name] || [])].map(s => ({ ...s, __provider: 'claude' }));
|
||||
const cursorSessions = (project.cursorSessions || []).map(s => ({ ...s, __provider: 'cursor' }));
|
||||
const codexSessions = (project.codexSessions || []).map(s => ({ ...s, __provider: 'codex' }));
|
||||
// Sort by most recent activity/date
|
||||
const normalizeDate = (s) => new Date(s.__provider === 'cursor' ? s.createdAt : s.lastActivity);
|
||||
return [...claudeSessions, ...cursorSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a));
|
||||
const normalizeDate = (s) => {
|
||||
if (s.__provider === 'cursor') return new Date(s.createdAt);
|
||||
if (s.__provider === 'codex') return new Date(s.createdAt || s.lastActivity);
|
||||
return new Date(s.lastActivity);
|
||||
};
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a));
|
||||
};
|
||||
|
||||
// Helper function to get the last activity date for a project
|
||||
@@ -297,25 +303,39 @@ function Sidebar({
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const deleteSession = async (projectName, sessionId) => {
|
||||
const deleteSession = async (projectName, sessionId, provider = 'claude') => {
|
||||
if (!confirm('Are you sure you want to delete this session? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.deleteSession(projectName, sessionId);
|
||||
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
|
||||
|
||||
// Call the appropriate API based on provider
|
||||
let response;
|
||||
if (provider === 'codex') {
|
||||
response = await api.deleteCodexSession(sessionId);
|
||||
} else {
|
||||
response = await api.deleteSession(projectName, sessionId);
|
||||
}
|
||||
|
||||
console.log('[Sidebar] Delete response:', { ok: response.ok, status: response.status });
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[Sidebar] Session deleted successfully, calling callback');
|
||||
// Call parent callback if provided
|
||||
if (onSessionDelete) {
|
||||
onSessionDelete(sessionId);
|
||||
} else {
|
||||
console.warn('[Sidebar] No onSessionDelete callback provided');
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to delete session');
|
||||
const errorText = await response.text();
|
||||
console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText });
|
||||
alert('Failed to delete session. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
console.error('[Sidebar] Error deleting session:', error);
|
||||
alert('Error deleting session. Please try again.');
|
||||
}
|
||||
};
|
||||
@@ -575,9 +595,44 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Filter and Actions */}
|
||||
{/* Action Buttons - Desktop only - Always show when not loading */}
|
||||
{!isLoading && !isMobile && (
|
||||
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
|
||||
onClick={() => setShowNewProject(true)}
|
||||
title="Create new project"
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
|
||||
New Project
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh projects and sessions (Ctrl+R)"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Filter - Only show when there are projects */}
|
||||
{projects.length > 0 && !isLoading && (
|
||||
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
|
||||
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -596,39 +651,6 @@ function Sidebar({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Desktop only */}
|
||||
{!isMobile && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
|
||||
onClick={() => setShowNewProject(true)}
|
||||
title="Create new project (Ctrl+N)"
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
|
||||
New Project
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh projects and sessions (Ctrl+R)"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1006,17 +1028,33 @@ function Sidebar({
|
||||
</div>
|
||||
) : (
|
||||
getAllSessions(project).map((session) => {
|
||||
// Handle both Claude and Cursor session formats
|
||||
// Handle Claude, Cursor, and Codex session formats
|
||||
const isCursorSession = session.__provider === 'cursor';
|
||||
|
||||
const isCodexSession = session.__provider === 'codex';
|
||||
|
||||
// Calculate if session is active (within last 10 minutes)
|
||||
const sessionDate = new Date(isCursorSession ? session.createdAt : session.lastActivity);
|
||||
const getSessionDate = () => {
|
||||
if (isCursorSession) return new Date(session.createdAt);
|
||||
if (isCodexSession) return new Date(session.createdAt || session.lastActivity);
|
||||
return new Date(session.lastActivity);
|
||||
};
|
||||
const sessionDate = getSessionDate();
|
||||
const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60));
|
||||
const isActive = diffInMinutes < 10;
|
||||
|
||||
|
||||
// Get session display values
|
||||
const sessionName = isCursorSession ? (session.name || 'Untitled Session') : (session.summary || 'New Session');
|
||||
const sessionTime = isCursorSession ? session.createdAt : session.lastActivity;
|
||||
const getSessionName = () => {
|
||||
if (isCursorSession) return session.name || 'Untitled Session';
|
||||
if (isCodexSession) return session.summary || session.name || 'Codex Session';
|
||||
return session.summary || 'New Session';
|
||||
};
|
||||
const sessionName = getSessionName();
|
||||
const getSessionTime = () => {
|
||||
if (isCursorSession) return session.createdAt;
|
||||
if (isCodexSession) return session.createdAt || session.lastActivity;
|
||||
return session.lastActivity;
|
||||
};
|
||||
const sessionTime = getSessionTime();
|
||||
const messageCount = session.messageCount || 0;
|
||||
|
||||
return (
|
||||
@@ -1051,6 +1089,8 @@ function Sidebar({
|
||||
)}>
|
||||
{isCursorSession ? (
|
||||
<CursorLogo className="w-3 h-3" />
|
||||
) : isCodexSession ? (
|
||||
<CodexLogo className="w-3 h-3" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-3 h-3" />
|
||||
)}
|
||||
@@ -1073,21 +1113,22 @@ function Sidebar({
|
||||
<span className="ml-1 opacity-70">
|
||||
{isCursorSession ? (
|
||||
<CursorLogo className="w-3 h-3" />
|
||||
) : isCodexSession ? (
|
||||
<CodexLogo className="w-3 h-3" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-3 h-3" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile delete button - only for Claude sessions */}
|
||||
{!isCursorSession && (
|
||||
<button
|
||||
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id);
|
||||
deleteSession(project.name, session.id, session.__provider);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
|
||||
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id, session.__provider))}
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
@@ -1110,6 +1151,8 @@ function Sidebar({
|
||||
<div className="flex items-start gap-2 min-w-0 w-full">
|
||||
{isCursorSession ? (
|
||||
<CursorLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
) : isCodexSession ? (
|
||||
<CodexLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
@@ -1127,10 +1170,11 @@ function Sidebar({
|
||||
{messageCount}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Provider tiny icon */}
|
||||
<span className="ml-1 opacity-70">
|
||||
{isCursorSession ? (
|
||||
<CursorLogo className="w-3 h-3" />
|
||||
) : isCodexSession ? (
|
||||
<CodexLogo className="w-3 h-3" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-3 h-3" />
|
||||
)}
|
||||
@@ -1139,10 +1183,9 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{/* Desktop hover buttons - only for Claude sessions */}
|
||||
{!isCursorSession && (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
{editingSession === session.id ? (
|
||||
{editingSession === session.id && !isCodexSession ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1185,40 +1228,24 @@ function Sidebar({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Generate summary button */}
|
||||
{/* <button
|
||||
className="w-6 h-6 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
generateSessionSummary(project.name, session.id);
|
||||
}}
|
||||
title="Generate AI summary for this session"
|
||||
disabled={generatingSummary[`${project.name}-${session.id}`]}
|
||||
>
|
||||
{generatingSummary[`${project.name}-${session.id}`] ? (
|
||||
<div className="w-3 h-3 animate-spin rounded-full border border-blue-600 dark:border-blue-400 border-t-transparent" />
|
||||
) : (
|
||||
<Sparkles className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
</button> */}
|
||||
{/* Edit button */}
|
||||
<button
|
||||
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSession(session.id);
|
||||
setEditingSessionName(session.summary || 'New Session');
|
||||
}}
|
||||
title="Manually edit session name"
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
{/* Delete button */}
|
||||
{!isCodexSession && (
|
||||
<button
|
||||
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSession(session.id);
|
||||
setEditingSessionName(session.summary || 'New Session');
|
||||
}}
|
||||
title="Manually edit session name"
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id);
|
||||
deleteSession(project.name, session.id, session.__provider);
|
||||
}}
|
||||
title="Delete this session permanently"
|
||||
>
|
||||
@@ -1367,4 +1394,4 @@ function Sidebar({
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
export default Sidebar;
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
src/components/TasksSettings.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Zap } from 'lucide-react';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
function TasksSettings() {
|
||||
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">Checking TaskMaster installation...</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">
|
||||
TaskMaster AI CLI Not Installed
|
||||
</div>
|
||||
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
|
||||
<p>TaskMaster CLI is required to use task management features. Install it to get started:</p>
|
||||
|
||||
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
|
||||
<code>npm install -g task-master-ai</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>
|
||||
View on GitHub
|
||||
<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">After installation:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-xs">
|
||||
<li>Restart this application</li>
|
||||
<li>TaskMaster features will automatically become available</li>
|
||||
<li>Use <code className="bg-orange-100 dark:bg-orange-800 px-1 rounded">task-master init</code> in your project directory</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">
|
||||
Enable TaskMaster Integration
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
Show TaskMaster tasks, banners, and sidebar indicators across the interface
|
||||
</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;
|
||||
124
src/components/settings/AccountContent.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
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';
|
||||
|
||||
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 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} Account</h3>
|
||||
<p className="text-sm text-muted-foreground">{config.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}`}>
|
||||
Connection Status
|
||||
</div>
|
||||
<div className={`text-sm ${config.subtextClass}`}>
|
||||
{authStatus?.loading ? (
|
||||
'Checking authentication status...'
|
||||
) : authStatus?.authenticated ? (
|
||||
`Logged in as ${authStatus.email || 'authenticated user'}`
|
||||
) : (
|
||||
'Not connected'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{authStatus?.loading ? (
|
||||
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
|
||||
Checking...
|
||||
</Badge>
|
||||
) : authStatus?.authenticated ? (
|
||||
<Badge variant="success" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
||||
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 ? 'Re-authenticate' : 'Login'}
|
||||
</div>
|
||||
<div className={`text-sm ${config.subtextClass}`}>
|
||||
{authStatus?.authenticated
|
||||
? 'Sign in with a different account or refresh credentials'
|
||||
: `Sign in to your ${config.name} account to enable AI features`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onLogin}
|
||||
className={`${config.buttonClass} text-white`}
|
||||
size="sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
{authStatus?.authenticated ? 'Re-login' : 'Login'}
|
||||
</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">
|
||||
Error: {authStatus.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/components/settings/AgentListItem.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import ClaudeLogo from '../ClaudeLogo';
|
||||
import CursorLogo from '../CursorLogo';
|
||||
import CodexLogo from '../CodexLogo';
|
||||
|
||||
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 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">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 || 'Connected'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
|
||||
<span>Not connected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
314
src/components/settings/McpServersContent.jsx
Normal file
@@ -0,0 +1,314 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
}) {
|
||||
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">
|
||||
MCP Servers
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model Context Protocol servers provide additional tools and data sources to 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" />
|
||||
Add MCP Server
|
||||
</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' ? 'local' : server.scope === 'user' ? 'user' : server.scope}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{server.type === 'stdio' && server.config?.command && (
|
||||
<div>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>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>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">Tools ({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">+{serverTools[server.id].tools.length - 5} more</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="Edit server"
|
||||
>
|
||||
<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="Delete server"
|
||||
>
|
||||
<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">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor MCP Servers
|
||||
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
|
||||
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">
|
||||
MCP Servers
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model Context Protocol servers provide additional tools and data sources to 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" />
|
||||
Add MCP Server
|
||||
</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>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"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(server.name)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<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">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Codex MCP Servers
|
||||
function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
|
||||
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">
|
||||
MCP Servers
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model Context Protocol servers provide additional tools and data sources to 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" />
|
||||
Add MCP Server
|
||||
</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>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>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>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="Edit server"
|
||||
>
|
||||
<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="Delete server"
|
||||
>
|
||||
<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">
|
||||
No MCP servers configured
|
||||
</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">About Codex MCP</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities
|
||||
with additional tools and resources.
|
||||
</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;
|
||||
}
|
||||
611
src/components/settings/PermissionsContent.jsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Shield, AlertTriangle, Plus, X } from 'lucide-react';
|
||||
|
||||
// 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 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">
|
||||
Permission Settings
|
||||
</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">
|
||||
Skip permission prompts (use with caution)
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
Equivalent to --dangerously-skip-permissions flag
|
||||
</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">
|
||||
Allowed Tools
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tools that are automatically allowed without prompting for permission
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newAllowedTool}
|
||||
onChange={(e) => setNewAllowedTool(e.target.value)}
|
||||
placeholder='e.g., "Bash(git log:*)" or "Write"'
|
||||
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">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">
|
||||
Quick add common tools:
|
||||
</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">
|
||||
No allowed tools configured
|
||||
</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">
|
||||
Blocked Tools
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tools that are automatically blocked without prompting for permission
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newDisallowedTool}
|
||||
onChange={(e) => setNewDisallowedTool(e.target.value)}
|
||||
placeholder='e.g., "Bash(rm:*)"'
|
||||
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">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">
|
||||
No blocked tools configured
|
||||
</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">
|
||||
Tool Pattern Examples:
|
||||
</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> - Allow all git log commands</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git diff:*)"</code> - Allow all git diff commands</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Write"</code> - Allow all Write tool usage</li>
|
||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> - Block all rm commands (dangerous)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor Permissions
|
||||
function CursorPermissions({
|
||||
skipPermissions,
|
||||
setSkipPermissions,
|
||||
allowedCommands,
|
||||
setAllowedCommands,
|
||||
disallowedCommands,
|
||||
setDisallowedCommands,
|
||||
newAllowedCommand,
|
||||
setNewAllowedCommand,
|
||||
newDisallowedCommand,
|
||||
setNewDisallowedCommand,
|
||||
}) {
|
||||
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">
|
||||
Permission Settings
|
||||
</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">
|
||||
Skip permission prompts (use with caution)
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
Equivalent to -f flag in Cursor CLI
|
||||
</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">
|
||||
Allowed Shell Commands
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shell commands that are automatically allowed without prompting
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newAllowedCommand}
|
||||
onChange={(e) => setNewAllowedCommand(e.target.value)}
|
||||
placeholder='e.g., "Shell(ls)" or "Shell(git status)"'
|
||||
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">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">
|
||||
Quick add common commands:
|
||||
</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">
|
||||
No allowed commands configured
|
||||
</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">
|
||||
Blocked Shell Commands
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shell commands that are automatically blocked
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={newDisallowedCommand}
|
||||
onChange={(e) => setNewDisallowedCommand(e.target.value)}
|
||||
placeholder='e.g., "Shell(rm -rf)" or "Shell(sudo)"'
|
||||
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">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">
|
||||
No blocked commands configured
|
||||
</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">
|
||||
Shell Command Examples:
|
||||
</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> - Allow ls command</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(git status)"</code> - Allow git status</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(npm install)"</code> - Allow npm install</li>
|
||||
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(rm -rf)"</code> - Block recursive delete</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Codex Permissions
|
||||
function CodexPermissions({ permissionMode, setPermissionMode }) {
|
||||
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">
|
||||
Permission Mode
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Controls how Codex handles file modifications and command execution
|
||||
</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">Default</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Only trusted commands (ls, cat, grep, git status, etc.) run automatically.
|
||||
Other commands are skipped. Can write to workspace.
|
||||
</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">Accept Edits</div>
|
||||
<div className="text-sm text-green-700 dark:text-green-300">
|
||||
All commands run automatically within the workspace.
|
||||
Full auto mode with sandboxed execution.
|
||||
</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">
|
||||
Bypass Permissions
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-300">
|
||||
Full system access with no restrictions. All commands run automatically
|
||||
with full disk and network access. Use with caution.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Technical details
|
||||
</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>Default:</strong> sandboxMode=workspace-write, approvalPolicy=untrusted. Trusted commands: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (without -exec), etc.</p>
|
||||
<p><strong>Accept Edits:</strong> sandboxMode=workspace-write, approvalPolicy=never. All commands auto-execute within project directory.</p>
|
||||
<p><strong>Bypass Permissions:</strong> sandboxMode=danger-full-access, approvalPolicy=never. Full system access, use only in trusted environments.</p>
|
||||
<p className="text-xs opacity-75">You can override this per-session using the mode button in the chat interface.</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;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const AuthContext = createContext({
|
||||
logout: () => {},
|
||||
isLoading: true,
|
||||
needsSetup: false,
|
||||
hasCompletedOnboarding: true,
|
||||
refreshOnboardingStatus: () => {},
|
||||
error: null
|
||||
});
|
||||
|
||||
@@ -25,22 +27,38 @@ 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(() => {
|
||||
// Platform mode: skip all auth checks, set dummy user
|
||||
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||
setUser({ username: 'platform-user' });
|
||||
setNeedsSetup(false);
|
||||
checkOnboardingStatus();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal OSS mode: check auth status
|
||||
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);
|
||||
@@ -65,6 +83,7 @@ export const AuthProvider = ({ children }) => {
|
||||
const userData = await userResponse.json();
|
||||
setUser(userData.user);
|
||||
setNeedsSetup(false);
|
||||
await checkOnboardingStatus();
|
||||
} else {
|
||||
// Token is invalid
|
||||
localStorage.removeItem('auth-token');
|
||||
@@ -156,6 +175,8 @@ export const AuthProvider = ({ children }) => {
|
||||
logout,
|
||||
isLoading,
|
||||
needsSetup,
|
||||
hasCompletedOnboarding,
|
||||
refreshOnboardingStatus,
|
||||
error
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// Utility function for authenticated API calls
|
||||
export const authenticatedFetch = (url, options = {}) => {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
|
||||
const defaultHeaders = {};
|
||||
|
||||
// Only set Content-Type for non-FormData requests
|
||||
if (!(options.body instanceof FormData)) {
|
||||
defaultHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (!isPlatform && token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
@@ -43,14 +47,23 @@ export const api = {
|
||||
projects: () => authenticatedFetch('/api/projects'),
|
||||
sessions: (projectName, limit = 5, offset = 0) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
|
||||
sessionMessages: (projectName, sessionId, limit = null, offset = 0) => {
|
||||
sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== null) {
|
||||
params.append('limit', limit);
|
||||
params.append('offset', offset);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
const url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
// Route to the correct endpoint based on provider
|
||||
let url;
|
||||
if (provider === 'codex') {
|
||||
url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
} else if (provider === 'cursor') {
|
||||
url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
} else {
|
||||
url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
||||
}
|
||||
return authenticatedFetch(url);
|
||||
},
|
||||
renameProject: (projectName, displayName) =>
|
||||
@@ -62,6 +75,10 @@ export const api = {
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteCodexSession: (sessionId) =>
|
||||
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteProject: (projectName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}`, {
|
||||
method: 'DELETE',
|
||||
@@ -137,10 +154,25 @@ export const api = {
|
||||
browseFilesystem: (dirPath = null) => {
|
||||
const params = new URLSearchParams();
|
||||
if (dirPath) params.append('path', dirPath);
|
||||
|
||||
|
||||
return authenticatedFetch(`/api/browse-filesystem?${params}`);
|
||||
},
|
||||
|
||||
// User endpoints
|
||||
user: {
|
||||
gitConfig: () => authenticatedFetch('/api/user/git-config'),
|
||||
updateGitConfig: (gitName, gitEmail) =>
|
||||
authenticatedFetch('/api/user/git-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ gitName, gitEmail }),
|
||||
}),
|
||||
onboardingStatus: () => authenticatedFetch('/api/user/onboarding-status'),
|
||||
completeOnboarding: () =>
|
||||
authenticatedFetch('/api/user/complete-onboarding', {
|
||||
method: 'POST',
|
||||
}),
|
||||
},
|
||||
|
||||
// Generic GET method for any endpoint
|
||||
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
ws: true
|
||||
},
|
||||
'/shell': {
|
||||
target: `ws://localhost:${env.PORT || 3002}`,
|
||||
target: `ws://localhost:${env.PORT || 3001}`,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
|
||||