Compare commits

...

30 Commits

Author SHA1 Message Date
viper151
97ebef016a Merge pull request #288 from siteboon/fix/move-to-correct-scroll-position-in-long-messages-chat
fix: normalize file path handling and improve scroll position restoration
2026-01-07 22:11:55 +01:00
Haileyesus Dessie
005033136b fix: normalize file path handling and improve scroll position restoration in ChatInterface 2026-01-07 20:59:41 +03:00
viper151
8fb43d358c Merge pull request #283 from siteboon/fix/server-crash-when-opening-settings 2026-01-05 18:59:05 +01:00
Haileyesus Dessie
4c40a33255 fix: improve error handling and response structure in MCP CLI routes for codex 2026-01-05 20:54:26 +03:00
viper151
4086fdaa4e Merge pull request #275 from siteboon/fix/navigate-to-correct-session-id-using-codex
fix: navigate to the correct session ID when updating session state
2026-01-05 17:00:23 +01:00
viper151
124c1ac600 Merge branch 'main' into fix/navigate-to-correct-session-id-using-codex 2026-01-05 16:59:24 +01:00
Haileyesus Dessie
9efe433d99 fix: get codex sessions in windows; improve message counting logic; fix session navigation in ChatInterface 2026-01-05 16:35:20 +03:00
Haileyesus Dessie
189a1b174c Merge pull request #244 from ybalbert001/main
[FixBug] The Desktop version's "New Project" button is always hidden
2026-01-01 14:53:28 +03:00
Haileyesus Dessie
04a0ff311e Merge branch 'main' into main 2026-01-01 14:49:30 +03:00
Haileyesus Dessie
efae890e34 Update button title for creating new project 2026-01-01 14:46:09 +03:00
Haileyesus Dessie
ba70ad8e81 fix: navigate to the correct session ID when updating session state 2025-12-31 19:10:33 +03:00
simosmik
b066ec4c01 fix: change codex login for platform mode 2025-12-31 10:47:55 +00:00
simosmik
104e4260a7 Release 1.13.6 2025-12-31 08:00:36 +00:00
simosmik
8af982e706 feat: add update command to CLI for checking and installing the latest version 2025-12-31 07:59:13 +00:00
viper151
29783f609f Merge branch 'main' into main 2025-12-31 08:53:45 +01:00
simosmik
ea19bd9a00 Release 1.13.5 2025-12-31 07:52:43 +00:00
viper151
6d4e5017d0 Merge pull request #257 from panta82/main
Fix issue: Broken pasted image upload
2025-12-31 08:49:03 +01:00
viper151
9b217ada0d Merge branch 'main' into main 2025-12-31 08:48:30 +01:00
simosmik
04efaa41f6 feat: add custom port and database path options to CLI commands 2025-12-31 07:42:01 +00:00
simosmik
5aef9c683a Release 1.13.3 2025-12-31 07:20:41 +00:00
simosmik
724cb5bb5c fix: adding shared folder to npm build 2025-12-31 07:17:39 +00:00
simosmik
4e163c8c10 Release 1.13.2 2025-12-30 18:10:21 +00:00
simosmik
b315360f8a fix: replace HOME env variable with os.homedir() to support windows 2025-12-30 18:07:04 +00:00
viper151
04821b8ad5 Merge pull request #273 from siteboon/fix/npmignore
Fix/npmignore
2025-12-30 18:53:22 +01:00
simosmik
00278a13d8 Release 1.13.1 2025-12-30 17:51:32 +00:00
simosmik
676d2415a0 adding npmignore 2025-12-30 17:49:30 +00:00
simosmik
babe96eedd fix: API would be stringified twice. That is now fixed. 2025-12-29 23:18:38 +00:00
simosmik
60c8bda755 fix: pass model parameter to Claude and Codex SDKs
Previously, the model parameter was accepted by the /api/agent endpoint
and extracted from requests, but was never passed through to the Claude
SDK or Codex SDK, causing all requests to use default models regardless
of user selection.

Changes:
- Add model parameter to queryClaudeSDK() options in routes/agent.js
- Add model to threadOptions in openai-codex.js
- Remove unused /cost slash command and PRICING constants
- Centralize all model definitions in shared/modelConstants.js
- Update API documentation to dynamically load models from constants
2025-12-29 16:19:09 +00:00
Ivan Pantic
19bb741af0 Fix issue: Broken pasted image upload 2025-12-12 00:27:09 +01:00
Yuanbo Li
73a0b5bebd [FixBug] The Desktop version's "New Project" button is wrapped by the conditional logic projects.length > 0, causing it to not display when there are no projects, preventing users from creating new projects. 2025-11-26 11:45:01 +08:00
20 changed files with 616 additions and 310 deletions

57
.npmignore Normal file
View 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

View File

@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64"> <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> </div>
@@ -88,32 +88,31 @@ claude-code-ui
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again. **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: 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 ```bash
# Start the server (default command) cloudcli # Start with defaults
claude-code-ui cloudcli -p 8080 # Start on custom port
cloudcli start cloudcli status # Show current configuration
# 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
``` ```
### Run as Background Service (Recommended for Production) ### Run as Background Service (Recommended for Production)
@@ -134,6 +133,9 @@ pm2 start claude-code-ui --name "claude-code-ui"
# Or using the shorter alias # Or using the shorter alias
pm2 start cloudcli --name "claude-code-ui" pm2 start cloudcli --name "claude-code-ui"
# Start on a custom port
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
``` ```

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.0", "version": "1.13.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.0", "version": "1.13.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29", "@anthropic-ai/claude-agent-sdk": "^0.1.29",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.0", "version": "1.13.6",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",
@@ -10,6 +10,7 @@
}, },
"files": [ "files": [
"server/", "server/",
"shared/",
"dist/", "dist/",
"README.md" "README.md"
], ],

View File

@@ -489,7 +489,7 @@
<span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span> <span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span>
</div> </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> <h4>Request Body Parameters</h4>
<table> <table>
@@ -524,7 +524,7 @@
<td><code>provider</code></td> <td><code>provider</code></td>
<td>string</td> <td>string</td>
<td><span class="badge badge-optional">Optional</span></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>
<tr> <tr>
<td><code>stream</code></td> <td><code>stream</code></td>
@@ -536,7 +536,9 @@
<td><code>model</code></td> <td><code>model</code></td>
<td>string</td> <td>string</td>
<td><span class="badge badge-optional">Optional</span></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>
<tr> <tr>
<td><code>cleanup</code></td> <td><code>cleanup</code></td>
@@ -818,15 +820,35 @@ data: {"type":"done"}</code></pre>
</div> </div>
</div> </div>
<script> <script type="module">
// Import model constants
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
// Dynamic URL replacement // Dynamic URL replacement
const apiUrl = window.location.origin; const apiUrl = window.location.origin;
document.querySelectorAll('.api-url').forEach(el => { document.querySelectorAll('.api-url').forEach(el => {
el.textContent = apiUrl; 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 // Tab switching
function showTab(tabName) { window.showTab = function(tabName) {
const parentBlock = event.target.closest('.example-block'); const parentBlock = event.target.closest('.example-block');
if (!parentBlock) return; if (!parentBlock) return;
@@ -842,7 +864,7 @@ data: {"type":"done"}</code></pre>
targetTab.classList.add('active'); targetTab.classList.add('active');
event.target.classList.add('active'); event.target.classList.add('active');
} }
} };
</script> </script>
<!-- Prism.js --> <!-- Prism.js -->

View File

@@ -16,6 +16,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
// Session tracking: Map of session IDs to active query instances // Session tracking: Map of session IDs to active query instances
const activeSessions = new Map(); const activeSessions = new Map();
@@ -77,7 +78,7 @@ function mapCliOptionsToSDK(options = {}) {
// Map model (default to sonnet) // Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || 'sonnet'; sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
console.log(`Using model: ${sdkOptions.model}`); console.log(`Using model: ${sdkOptions.model}`);
// Map system prompt configuration // Map system prompt configuration
@@ -397,10 +398,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send session-created event only once for new sessions // Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) { if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true; sessionCreatedSent = true;
ws.send(JSON.stringify({ ws.send({
type: 'session-created', type: 'session-created',
sessionId: capturedSessionId sessionId: capturedSessionId
})); });
} else { } else {
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent); console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
} }
@@ -410,20 +411,20 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Transform and send message to WebSocket // Transform and send message to WebSocket
const transformedMessage = transformMessage(message); const transformedMessage = transformMessage(message);
ws.send(JSON.stringify({ ws.send({
type: 'claude-response', type: 'claude-response',
data: transformedMessage data: transformedMessage
})); });
// Extract and send token budget updates from result messages // Extract and send token budget updates from result messages
if (message.type === 'result') { if (message.type === 'result') {
const tokenBudget = extractTokenBudget(message); const tokenBudget = extractTokenBudget(message);
if (tokenBudget) { if (tokenBudget) {
console.log('Token budget from modelUsage:', tokenBudget); console.log('Token budget from modelUsage:', tokenBudget);
ws.send(JSON.stringify({ ws.send({
type: 'token-budget', type: 'token-budget',
data: tokenBudget data: tokenBudget
})); });
} }
} }
} }
@@ -438,12 +439,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send completion event // Send completion event
console.log('Streaming complete, sending claude-complete event'); console.log('Streaming complete, sending claude-complete event');
ws.send(JSON.stringify({ ws.send({
type: 'claude-complete', type: 'claude-complete',
sessionId: capturedSessionId, sessionId: capturedSessionId,
exitCode: 0, exitCode: 0,
isNewSession: !sessionId && !!command isNewSession: !sessionId && !!command
})); });
console.log('claude-complete event sent'); console.log('claude-complete event sent');
} catch (error) { } catch (error) {
@@ -458,10 +459,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
await cleanupTempFiles(tempImagePaths, tempDir); await cleanupTempFiles(tempImagePaths, tempDir);
// Send error to WebSocket // Send error to WebSocket
ws.send(JSON.stringify({ ws.send({
type: 'claude-error', type: 'claude-error',
error: error.message error: error.message
})); });
throw error; throw error;
} }

View File

@@ -14,6 +14,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
@@ -115,7 +116,7 @@ function showStatus() {
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`); console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
// Claude projects folder // 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); const projectsExists = fs.existsSync(claudeProjectsPath);
console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`); console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`);
console.log(` ${c.dim(claudeProjectsPath)}`); console.log(` ${c.dim(claudeProjectsPath)}`);
@@ -130,10 +131,10 @@ function showStatus() {
console.log('\n' + c.dim('═'.repeat(60))); console.log('\n' + c.dim('═'.repeat(60)));
console.log(`\n${c.tip('[TIP]')} Hints:`); 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('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
console.log(` ${c.dim('>')} Create .env file in installation directory for persistent config`); console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`); console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`); console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
} }
// Show help // Show help
@@ -144,19 +145,28 @@ function showHelp() {
╚═══════════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════════╝
Usage: Usage:
claude-code-ui [command] claude-code-ui [command] [options]
cloudcli [command] cloudcli [command] [options]
Commands: Commands:
start Start the Claude Code UI server (default) start Start the Claude Code UI server (default)
status Show configuration and data locations status Show configuration and data locations
update Update to the latest version
help Show this help information help Show this help information
version Show version 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: Examples:
$ claude-code-ui # Start the server $ cloudcli # Start with defaults
$ cloudcli status # Show configuration $ cloudcli --port 8080 # Start on port 8080
$ cloudcli help # Show help $ cloudcli -p 3000 # Short form for port
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli status # Show configuration
Environment Variables: Environment Variables:
PORT Set server port (default: 3001) PORT Set server port (default: 3001)
@@ -164,11 +174,6 @@ Environment Variables:
CLAUDE_CLI_PATH Set custom Claude CLI path CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000) 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: Documentation:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'} ${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
@@ -182,16 +187,110 @@ function showVersion() {
console.log(`${packageJson.version}`); 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 // Start the server
async function startServer() { async function startServer() {
// Check for updates silently on startup
checkForUpdates(true);
// Import and run the server // Import and run the server
await import('./index.js'); 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 // Main CLI handler
async function main() { async function main() {
const args = process.argv.slice(2); 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) { switch (command) {
case 'start': case 'start':
@@ -211,6 +310,9 @@ async function main() {
case '--version': case '--version':
showVersion(); showVersion();
break; break;
case 'update':
await updatePackage();
break;
default: default:
console.error(`\n❌ Unknown command: ${command}`); console.error(`\n❌ Unknown command: ${command}`);
console.log(' Run "cloudcli help" for usage information.\n'); console.log(' Run "cloudcli help" for usage information.\n');

View File

@@ -102,29 +102,29 @@ async function spawnCursor(command, options = {}, ws) {
// Send session-created event only once for new sessions // Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) { if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true; sessionCreatedSent = true;
ws.send(JSON.stringify({ ws.send({
type: 'session-created', type: 'session-created',
sessionId: capturedSessionId, sessionId: capturedSessionId,
model: response.model, model: response.model,
cwd: response.cwd cwd: response.cwd
})); });
} }
} }
// Send system info to frontend // Send system info to frontend
ws.send(JSON.stringify({ ws.send({
type: 'cursor-system', type: 'cursor-system',
data: response data: response
})); });
} }
break; break;
case 'user': case 'user':
// Forward user message // Forward user message
ws.send(JSON.stringify({ ws.send({
type: 'cursor-user', type: 'cursor-user',
data: response data: response
})); });
break; break;
case 'assistant': case 'assistant':
@@ -134,7 +134,7 @@ async function spawnCursor(command, options = {}, ws) {
messageBuffer += textContent; messageBuffer += textContent;
// Send as Claude-compatible format for frontend // Send as Claude-compatible format for frontend
ws.send(JSON.stringify({ ws.send({
type: 'claude-response', type: 'claude-response',
data: { data: {
type: 'content_block_delta', type: 'content_block_delta',
@@ -143,7 +143,7 @@ async function spawnCursor(command, options = {}, ws) {
text: textContent text: textContent
} }
} }
})); });
} }
break; break;
@@ -153,37 +153,37 @@ async function spawnCursor(command, options = {}, ws) {
// Send final message if we have buffered content // Send final message if we have buffered content
if (messageBuffer) { if (messageBuffer) {
ws.send(JSON.stringify({ ws.send({
type: 'claude-response', type: 'claude-response',
data: { data: {
type: 'content_block_stop' type: 'content_block_stop'
} }
})); });
} }
// Send completion event // Send completion event
ws.send(JSON.stringify({ ws.send({
type: 'cursor-result', type: 'cursor-result',
sessionId: capturedSessionId || sessionId, sessionId: capturedSessionId || sessionId,
data: response, data: response,
success: response.subtype === 'success' success: response.subtype === 'success'
})); });
break; break;
default: default:
// Forward any other message types // Forward any other message types
ws.send(JSON.stringify({ ws.send({
type: 'cursor-response', type: 'cursor-response',
data: response data: response
})); });
} }
} catch (parseError) { } catch (parseError) {
console.log('📄 Non-JSON response:', line); console.log('📄 Non-JSON response:', line);
// If not JSON, send as raw text // If not JSON, send as raw text
ws.send(JSON.stringify({ ws.send({
type: 'cursor-output', type: 'cursor-output',
data: line data: line
})); });
} }
} }
}); });
@@ -191,10 +191,10 @@ async function spawnCursor(command, options = {}, ws) {
// Handle stderr // Handle stderr
cursorProcess.stderr.on('data', (data) => { cursorProcess.stderr.on('data', (data) => {
console.error('Cursor CLI stderr:', data.toString()); console.error('Cursor CLI stderr:', data.toString());
ws.send(JSON.stringify({ ws.send({
type: 'cursor-error', type: 'cursor-error',
error: data.toString() error: data.toString()
})); });
}); });
// Handle process completion // Handle process completion
@@ -205,12 +205,12 @@ async function spawnCursor(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey; const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId); activeCursorProcesses.delete(finalSessionId);
ws.send(JSON.stringify({ ws.send({
type: 'claude-complete', type: 'claude-complete',
sessionId: finalSessionId, sessionId: finalSessionId,
exitCode: code, exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session isNewSession: !sessionId && !!command // Flag to indicate this was a new session
})); });
if (code === 0) { if (code === 0) {
resolve(); resolve();
@@ -227,10 +227,10 @@ async function spawnCursor(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey; const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId); activeCursorProcesses.delete(finalSessionId);
ws.send(JSON.stringify({ ws.send({
type: 'cursor-error', type: 'cursor-error',
error: error.message error: error.message
})); });
reject(error); reject(error);
}); });

View File

@@ -84,7 +84,7 @@ const connectedClients = new Set();
// Setup file system watcher for Claude projects folder using chokidar // Setup file system watcher for Claude projects folder using chokidar
async function setupProjectsWatcher() { async function setupProjectsWatcher() {
const chokidar = (await import('chokidar')).default; 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) { if (projectsWatcher) {
projectsWatcher.close(); projectsWatcher.close();
@@ -717,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 // Handle chat WebSocket connections
function handleChatConnection(ws) { function handleChatConnection(ws) {
console.log('[INFO] Chat WebSocket connected'); console.log('[INFO] Chat WebSocket connected');
@@ -724,6 +750,9 @@ function handleChatConnection(ws) {
// Add to connected clients for project updates // Add to connected clients for project updates
connectedClients.add(ws); connectedClients.add(ws);
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
const writer = new WebSocketWriter(ws);
ws.on('message', async (message) => { ws.on('message', async (message) => {
try { try {
const data = JSON.parse(message); const data = JSON.parse(message);
@@ -734,19 +763,19 @@ function handleChatConnection(ws) {
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
// Use Claude Agents SDK // Use Claude Agents SDK
await queryClaudeSDK(data.command, data.options, ws); await queryClaudeSDK(data.command, data.options, writer);
} else if (data.type === 'cursor-command') { } else if (data.type === 'cursor-command') {
console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]'); console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.cwd || 'Unknown'); console.log('📁 Project:', data.options?.cwd || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default'); 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') { } else if (data.type === 'codex-command') {
console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]'); console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown'); console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
console.log('🤖 Model:', data.options?.model || 'default'); console.log('🤖 Model:', data.options?.model || 'default');
await queryCodex(data.command, data.options, ws); await queryCodex(data.command, data.options, writer);
} else if (data.type === 'cursor-resume') { } else if (data.type === 'cursor-resume') {
// Backward compatibility: treat as cursor-command with resume and no prompt // Backward compatibility: treat as cursor-command with resume and no prompt
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId); console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
@@ -754,7 +783,7 @@ function handleChatConnection(ws) {
sessionId: data.sessionId, sessionId: data.sessionId,
resume: true, resume: true,
cwd: data.options?.cwd cwd: data.options?.cwd
}, ws); }, writer);
} else if (data.type === 'abort-session') { } else if (data.type === 'abort-session') {
console.log('[DEBUG] Abort session request:', data.sessionId); console.log('[DEBUG] Abort session request:', data.sessionId);
const provider = data.provider || 'claude'; const provider = data.provider || 'claude';
@@ -769,21 +798,21 @@ function handleChatConnection(ws) {
success = await abortClaudeSDKSession(data.sessionId); success = await abortClaudeSDKSession(data.sessionId);
} }
ws.send(JSON.stringify({ writer.send({
type: 'session-aborted', type: 'session-aborted',
sessionId: data.sessionId, sessionId: data.sessionId,
provider, provider,
success success
})); });
} else if (data.type === 'cursor-abort') { } else if (data.type === 'cursor-abort') {
console.log('[DEBUG] Abort Cursor session:', data.sessionId); console.log('[DEBUG] Abort Cursor session:', data.sessionId);
const success = abortCursorSession(data.sessionId); const success = abortCursorSession(data.sessionId);
ws.send(JSON.stringify({ writer.send({
type: 'session-aborted', type: 'session-aborted',
sessionId: data.sessionId, sessionId: data.sessionId,
provider: 'cursor', provider: 'cursor',
success success
})); });
} else if (data.type === 'check-session-status') { } else if (data.type === 'check-session-status') {
// Check if a specific session is currently processing // Check if a specific session is currently processing
const provider = data.provider || 'claude'; const provider = data.provider || 'claude';
@@ -799,12 +828,12 @@ function handleChatConnection(ws) {
isActive = isClaudeSDKSessionActive(sessionId); isActive = isClaudeSDKSessionActive(sessionId);
} }
ws.send(JSON.stringify({ writer.send({
type: 'session-status', type: 'session-status',
sessionId, sessionId,
provider, provider,
isProcessing: isActive isProcessing: isActive
})); });
} else if (data.type === 'get-active-sessions') { } else if (data.type === 'get-active-sessions') {
// Get all currently active sessions // Get all currently active sessions
const activeSessions = { const activeSessions = {
@@ -812,17 +841,17 @@ function handleChatConnection(ws) {
cursor: getActiveCursorSessions(), cursor: getActiveCursorSessions(),
codex: getActiveCodexSessions() codex: getActiveCodexSessions()
}; };
ws.send(JSON.stringify({ writer.send({
type: 'active-sessions', type: 'active-sessions',
sessions: activeSessions sessions: activeSessions
})); });
} }
} catch (error) { } catch (error) {
console.error('[ERROR] Chat WebSocket error:', error.message); console.error('[ERROR] Chat WebSocket error:', error.message);
ws.send(JSON.stringify({ writer.send({
type: 'error', type: 'error',
error: error.message error: error.message
})); });
} }
}); });
@@ -986,7 +1015,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color', name: 'xterm-256color',
cols: termCols, cols: termCols,
rows: termRows, rows: termRows,
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'), cwd: os.homedir(),
env: { env: {
...process.env, ...process.env,
TERM: 'xterm-256color', TERM: 'xterm-256color',

View File

@@ -213,7 +213,8 @@ export async function queryCodex(command, options = {}, ws) {
workingDirectory, workingDirectory,
skipGitRepoCheck: true, skipGitRepoCheck: true,
sandboxMode, sandboxMode,
approvalPolicy approvalPolicy,
model
}; };
// Start or resume thread // Start or resume thread
@@ -279,7 +280,8 @@ export async function queryCodex(command, options = {}, ws) {
// Send completion event // Send completion event
sendMessage(ws, { sendMessage(ws, {
type: 'codex-complete', type: 'codex-complete',
sessionId: currentSessionId sessionId: currentSessionId,
actualSessionId: thread.id
}); });
} catch (error) { } catch (error) {
@@ -359,12 +361,12 @@ export function getActiveCodexSessions() {
*/ */
function sendMessage(ws, data) { function sendMessage(ws, data) {
try { try {
if (typeof ws.send === 'function') { if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
// WebSocket // Writer handles stringification (SSEStreamWriter or WebSocketWriter)
ws.send(data);
} else if (typeof ws.send === 'function') {
// Raw WebSocket - stringify here
ws.send(JSON.stringify(data)); ws.send(JSON.stringify(data));
} else if (typeof ws.write === 'function') {
// SSE writer (for agent API)
ws.write(`data: ${JSON.stringify(data)}\n\n`);
} }
} catch (error) { } catch (error) {
console.error('[Codex] Error sending message:', error); console.error('[Codex] Error sending message:', error);

View File

@@ -204,7 +204,7 @@ function clearProjectDirectoryCache() {
// Load project configuration file // Load project configuration file
async function loadProjectConfig() { 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 { try {
const configData = await fs.readFile(configPath, 'utf8'); const configData = await fs.readFile(configPath, 'utf8');
return JSON.parse(configData); return JSON.parse(configData);
@@ -216,7 +216,7 @@ async function loadProjectConfig() {
// Save project configuration file // Save project configuration file
async function saveProjectConfig(config) { 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'); const configPath = path.join(claudeDir, 'project-config.json');
// Ensure the .claude directory exists // Ensure the .claude directory exists
@@ -276,7 +276,7 @@ async function extractProjectDirectory(projectName) {
return originalPath; return originalPath;
} }
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName); const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
const cwdCounts = new Map(); const cwdCounts = new Map();
let latestTimestamp = 0; let latestTimestamp = 0;
let latestCwd = null; let latestCwd = null;
@@ -380,7 +380,7 @@ async function extractProjectDirectory(projectName) {
} }
async function getProjects() { 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 config = await loadProjectConfig();
const projects = []; const projects = [];
const existingProjects = new Set(); const existingProjects = new Set();
@@ -546,7 +546,7 @@ async function getProjects() {
} }
async function getSessions(projectName, limit = 5, offset = 0) { 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 { try {
const files = await fs.readdir(projectDir); const files = await fs.readdir(projectDir);
@@ -828,7 +828,7 @@ async function parseJsonlSessions(filePath) {
// Get messages for a specific session with pagination support // Get messages for a specific session with pagination support
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) { 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 { try {
const files = await fs.readdir(projectDir); const files = await fs.readdir(projectDir);
@@ -917,7 +917,7 @@ async function renameProject(projectName, newDisplayName) {
// Delete a session from a project // Delete a session from a project
async function deleteSession(projectName, sessionId) { 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 { try {
const files = await fs.readdir(projectDir); const files = await fs.readdir(projectDir);
@@ -980,7 +980,7 @@ async function isProjectEmpty(projectName) {
// Delete an empty project // Delete an empty project
async function deleteProject(projectName) { async function deleteProject(projectName) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName); const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try { try {
// First check if the project is empty // First check if the project is empty
@@ -1020,7 +1020,7 @@ async function addProjectManually(projectPath, displayName = null) {
// Check if project already exists in config // Check if project already exists in config
const config = await loadProjectConfig(); 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]) { if (config[projectName]) {
throw new Error(`Project already configured for path: ${absolutePath}`); throw new Error(`Project already configured for path: ${absolutePath}`);
@@ -1206,7 +1206,12 @@ async function getCodexSessions(projectPath) {
const sessionData = await parseCodexSessionFile(filePath); const sessionData = await parseCodexSessionFile(filePath);
// Check if this session matches the project path // Check if this session matches the project path
if (sessionData && sessionData.cwd === projectPath) { // 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({ sessions.push({
id: sessionData.id, id: sessionData.id,
summary: sessionData.summary || 'Codex Session', summary: sessionData.summary || 'Codex Session',
@@ -1273,12 +1278,12 @@ async function parseCodexSessionFile(filePath) {
// Count messages and extract user messages for summary // Count messages and extract user messages for summary
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') { if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
messageCount++; messageCount++;
if (entry.payload.text) { if (entry.payload.message) {
lastUserMessage = entry.payload.text; lastUserMessage = entry.payload.message;
} }
} }
if (entry.type === 'response_item' && entry.payload?.type === 'message') { if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
messageCount++; messageCount++;
} }

View File

@@ -10,6 +10,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js'; import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js'; import { queryCodex } from '../openai-codex.js';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
const router = express.Router(); const router = express.Router();
@@ -450,6 +451,7 @@ class SSEStreamWriter {
constructor(res) { constructor(res) {
this.res = res; this.res = res;
this.sessionId = null; this.sessionId = null;
this.isSSEStreamWriter = true; // Marker for transport detection
} }
send(data) { send(data) {
@@ -457,7 +459,7 @@ class SSEStreamWriter {
return; return;
} }
// Format as SSE // Format as SSE - providers send raw objects, we stringify
this.res.write(`data: ${JSON.stringify(data)}\n\n`); this.res.write(`data: ${JSON.stringify(data)}\n\n`);
} }
@@ -634,9 +636,14 @@ class ResponseCollector {
* - true: Returns text/event-stream with incremental updates * - true: Returns text/event-stream with incremental updates
* - false: Returns complete JSON response after completion * - false: Returns complete JSON response after completion
* *
* @param {string} model - (Optional) Model identifier for Cursor provider. * @param {string} model - (Optional) Model identifier for providers.
* Only applicable when provider='cursor'. *
* Examples: 'gpt-4', 'claude-3-opus', etc. * 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. * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
* Default: true * Default: true
@@ -939,6 +946,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath, projectPath: finalProjectPath,
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: null, // New session sessionId: null, // New session
model: model,
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
}, writer); }, writer);
@@ -959,7 +967,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath, projectPath: finalProjectPath,
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: null, sessionId: null,
model: model || 'gpt-5.2', model: model || CODEX_MODELS.DEFAULT,
permissionMode: 'bypassPermissions' permissionMode: 'bypassPermissions'
}, writer); }, writer);
} }

View File

@@ -8,6 +8,17 @@ import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '.
const router = express.Router(); 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) => { router.get('/config', async (req, res) => {
try { try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml'); const configPath = path.join(os.homedir(), '.codex', 'config.toml');
@@ -88,24 +99,30 @@ router.delete('/sessions/:sessionId', async (req, res) => {
router.get('/mcp/cli/list', async (req, res) => { router.get('/mcp/cli/list', async (req, res) => {
try { try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) }); respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else { } else {
res.status(500).json({ error: 'Codex CLI command failed', details: stderr }); respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); 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) { } catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message }); res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
@@ -133,24 +150,30 @@ router.post('/mcp/cli/add', async (req, res) => {
cliArgs.push(...args); cliArgs.push(...args);
} }
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` }); respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else { } else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); 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) { } catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message }); res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
@@ -161,24 +184,30 @@ router.delete('/mcp/cli/remove/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` }); respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else { } else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); 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) { } catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message }); res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
@@ -189,24 +218,30 @@ router.get('/mcp/cli/get/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) }); respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else { } else {
res.status(404).json({ error: 'Codex CLI command failed', details: stderr }); respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); 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) { } catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message }); res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });

View File

@@ -4,6 +4,7 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import os from 'os'; import os from 'os';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -182,23 +183,15 @@ Custom commands can be created in:
}, },
'/model': async (args, context) => { '/model': async (args, context) => {
// Read available models from config or defaults // Read available models from centralized constants
const availableModels = { const availableModels = {
claude: [ claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
'claude-sonnet-4.5', cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
'claude-sonnet-4', codex: CODEX_MODELS.OPTIONS.map(o => o.value)
'claude-opus-4',
'claude-sonnet-3.5'
],
cursor: [
'gpt-5',
'sonnet-4',
'opus-4.1'
]
}; };
const currentProvider = context?.provider || 'claude'; const currentProvider = context?.provider || 'claude';
const currentModel = context?.model || 'claude-sonnet-4.5'; const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
return { return {
type: 'builtin', 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) => { '/status': async (args, context) => {
// Read version from package.json // Read version from package.json
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json'); const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');

View File

@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
import { open } from 'sqlite'; import { open } from 'sqlite';
import crypto from 'crypto'; import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
const router = express.Router(); const router = express.Router();
@@ -33,7 +34,7 @@ router.get('/config', async (req, res) => {
config: { config: {
version: 1, version: 1,
model: { model: {
modelId: "gpt-5", modelId: CURSOR_MODELS.DEFAULT,
displayName: "GPT-5" displayName: "GPT-5"
}, },
permissions: { permissions: {

65
shared/modelConstants.js Normal file
View 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'
};

View File

@@ -177,7 +177,9 @@ function AppContent() {
// If so, and the session is not active, trigger a message reload in ChatInterface // If so, and the session is not active, trigger a message reload in ChatInterface
if (latestMessage.changedFile && selectedSession && selectedProject) { if (latestMessage.changedFile && selectedSession && selectedProject) {
// Extract session ID from changedFile (format: "project-name/session-id.jsonl") // 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) { if (changedFileParts.length >= 2) {
const filename = changedFileParts[changedFileParts.length - 1]; const filename = changedFileParts[changedFileParts.length - 1];
const changedSessionId = filename.replace('.jsonl', ''); const changedSessionId = filename.replace('.jsonl', '');

View File

@@ -16,7 +16,7 @@
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates. * 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 ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
@@ -35,6 +35,7 @@ import { MicButton } from './MicButton.jsx';
import { api, authenticatedFetch } from '../utils/api'; import { api, authenticatedFetch } from '../utils/api';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import CommandMenu from './CommandMenu'; import CommandMenu from './CommandMenu';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
// Helper function to decode HTML entities in text // Helper function to decode HTML entities in text
@@ -1695,6 +1696,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const inputContainerRef = useRef(null); const inputContainerRef = useRef(null);
const scrollContainerRef = useRef(null); const scrollContainerRef = useRef(null);
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls 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 // Streaming throttle buffers
const streamBufferRef = useRef(''); const streamBufferRef = useRef('');
const streamTimerRef = useRef(null); const streamTimerRef = useRef(null);
@@ -1723,13 +1727,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return localStorage.getItem('selected-provider') || 'claude'; return localStorage.getItem('selected-provider') || 'claude';
}); });
const [cursorModel, setCursorModel] = useState(() => { const [cursorModel, setCursorModel] = useState(() => {
return localStorage.getItem('cursor-model') || 'gpt-5'; return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
}); });
const [claudeModel, setClaudeModel] = useState(() => { const [claudeModel, setClaudeModel] = useState(() => {
return localStorage.getItem('claude-model') || 'sonnet'; return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
}); });
const [codexModel, setCodexModel] = useState(() => { const [codexModel, setCodexModel] = useState(() => {
return localStorage.getItem('codex-model') || 'gpt-5.2'; return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
}); });
// Load permission mode for the current session // Load permission mode for the current session
useEffect(() => { useEffect(() => {
@@ -1758,17 +1762,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data.success && data.config?.model?.modelId) { if (data.success && data.config?.model?.modelId) {
// Map Cursor model IDs to our simplified names // Use the model from config directly
const modelMap = { const modelId = data.config.model.modelId;
'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;
if (!localStorage.getItem('cursor-model')) { if (!localStorage.getItem('cursor-model')) {
setCursorModel(mappedModel); setCursorModel(modelId);
} }
} }
}) })
@@ -2716,6 +2713,39 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return scrollHeight - scrollTop - clientHeight < 50; 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 // Handle scroll events to detect when user manually scrolls up and load more messages
const handleScroll = useCallback(async () => { const handleScroll = useCallback(async () => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
@@ -2725,32 +2755,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Check if we should load more messages (scrolled near top) // Check if we should load more messages (scrolled near top)
const scrolledNearTop = container.scrollTop < 100; const scrolledNearTop = container.scrollTop < 100;
const provider = localStorage.getItem('selected-provider') || 'claude'; if (!scrolledNearTop) {
topLoadLockRef.current = false;
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') { } else if (!topLoadLockRef.current) {
// Save current scroll position const didLoad = await loadOlderMessages(container);
const previousScrollHeight = container.scrollHeight; if (didLoad) {
const previousScrollTop = container.scrollTop; topLoadLockRef.current = true;
// Load more messages
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true, selectedSession.__provider || 'claude');
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);
} }
} }
} }
}, [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(() => { useEffect(() => {
// Load session messages when session changes // Load session messages when session changes
@@ -2879,7 +2906,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// convertedMessages will be automatically updated via useMemo // convertedMessages will be automatically updated via useMemo
// Smart scroll behavior: only auto-scroll if user is near bottom // Smart scroll behavior: only auto-scroll if user is near bottom
if (isNearBottom && autoScrollToBottom) { const shouldAutoScroll = autoScrollToBottom && isNearBottom();
if (shouldAutoScroll) {
setTimeout(() => scrollToBottom(), 200); setTimeout(() => scrollToBottom(), 200);
} }
// If user scrolled up, preserve their position (they're reading history) // If user scrolled up, preserve their position (they're reading history)
@@ -2977,6 +3005,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (latestMessage.sessionId && !currentSessionId) { if (latestMessage.sessionId && !currentSessionId) {
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); 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 // Session Protection: Replace temporary "new-session-*" identifier with real session ID
// This maintains protection continuity - no gap between temp ID and real 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 // The temporary session is removed and real session is marked as active
@@ -3536,8 +3567,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
if (codexPendingSessionId && !currentSessionId) { if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexPendingSessionId); setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true);
if (onNavigateToSession) {
onNavigateToSession(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId); console.log('Codex session complete, ID set to:', codexPendingSessionId);
} }
@@ -4416,6 +4452,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Messages Area - Scrollable Middle Section */} {/* Messages Area - Scrollable Middle Section */}
<div <div
ref={scrollContainerRef} 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" 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 ? ( {isLoadingSessionMessages && chatMessages.length === 0 ? (
@@ -4547,11 +4585,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}} }}
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]" 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]"
> >
<option value="sonnet">Sonnet</option> {CLAUDE_MODELS.OPTIONS.map(({ value, label }) => (
<option value="opus">Opus</option> <option key={value} value={value}>{label}</option>
<option value="haiku">Haiku</option> ))}
<option value="opusplan">Opus Plan</option>
<option value="sonnet[1m]">Sonnet [1M]</option>
</select> </select>
) : provider === 'codex' ? ( ) : provider === 'codex' ? (
<select <select
@@ -4563,10 +4599,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}} }}
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]" 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]"
> >
<option value="gpt-5.2">GPT-5.2</option> {CODEX_MODELS.OPTIONS.map(({ value, label }) => (
<option value="gpt-5.1-codex-max">GPT-5.1 Codex Max</option> <option key={value} value={value}>{label}</option>
<option value="o3">O3</option> ))}
<option value="o4-mini">O4-mini</option>
</select> </select>
) : ( ) : (
<select <select
@@ -4579,23 +4614,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
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]" 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'} disabled={provider !== 'cursor'}
> >
<option value="gpt-5.2-high">GPT-5.2 High</option> {CURSOR_MODELS.OPTIONS.map(({ value, label }) => (
<option value="gemini-3-pro">Gemini 3 Pro</option> <option key={value} value={value}>{label}</option>
<option value="opus-4.5-thinking">Claude 4.5 Opus (Thinking)</option> ))}
<option value="gpt-5.2">GPT-5.2</option>
<option value="gpt-5.1">GPT-5.1</option>
<option value="gpt-5.1-high">GPT-5.1 High</option>
<option value="composer-1">Composer 1</option>
<option value="auto">Auto</option>
<option value="sonnet-4.5">Claude 4.5 Sonnet</option>
<option value="sonnet-4.5-thinking">Claude 4.5 Sonnet (Thinking)</option>
<option value="opus-4.5">Claude 4.5 Opus</option>
<option value="gpt-5.1-codex">GPT-5.1 Codex</option>
<option value="gpt-5.1-codex-high">GPT-5.1 Codex High</option>
<option value="gpt-5.1-codex-max">GPT-5.1 Codex Max</option>
<option value="gpt-5.1-codex-max-high">GPT-5.1 Codex Max High</option>
<option value="opus-4.1">Claude 4.1 Opus</option>
<option value="grok">Grok</option>
</select> </select>
)} )}
</div> </div>

View File

@@ -25,13 +25,15 @@ function LoginModal({
const getCommand = () => { const getCommand = () => {
if (customCommand) return customCommand; if (customCommand) return customCommand;
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
switch (provider) { switch (provider) {
case 'claude': case 'claude':
return 'claude setup-token --dangerously-skip-permissions'; return 'claude setup-token --dangerously-skip-permissions';
case 'cursor': case 'cursor':
return 'cursor-agent login'; return 'cursor-agent login';
case 'codex': case 'codex':
return 'codex login'; return isPlatform ? 'codex login --device-auth' : 'codex login';
default: default:
return 'claude setup-token --dangerously-skip-permissions'; return 'claude setup-token --dangerously-skip-permissions';
} }

View File

@@ -595,9 +595,44 @@ function Sidebar({
</div> </div>
</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 && ( {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"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
@@ -616,39 +651,6 @@ function Sidebar({
</button> </button>
)} )}
</div> </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> </div>
)} )}