mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-30 05:17:29 +00:00
Compare commits
23 Commits
v1.13.2
...
97ebef016a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ebef016a | ||
|
|
005033136b | ||
|
|
8fb43d358c | ||
|
|
4c40a33255 | ||
|
|
4086fdaa4e | ||
|
|
124c1ac600 | ||
|
|
9efe433d99 | ||
|
|
189a1b174c | ||
|
|
04a0ff311e | ||
|
|
efae890e34 | ||
|
|
ba70ad8e81 | ||
|
|
b066ec4c01 | ||
|
|
104e4260a7 | ||
|
|
8af982e706 | ||
|
|
29783f609f | ||
|
|
ea19bd9a00 | ||
|
|
6d4e5017d0 | ||
|
|
9b217ada0d | ||
|
|
04efaa41f6 | ||
|
|
5aef9c683a | ||
|
|
724cb5bb5c | ||
|
|
19bb741af0 | ||
|
|
73a0b5bebd |
48
README.md
48
README.md
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.13.2",
|
"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.2",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.13.2",
|
"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"
|
||||||
],
|
],
|
||||||
|
|||||||
131
server/cli.js
131
server/cli.js
@@ -131,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
|
||||||
@@ -145,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)
|
||||||
@@ -165,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'}
|
||||||
|
|
||||||
@@ -183,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':
|
||||||
@@ -212,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');
|
||||||
|
|||||||
@@ -280,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) {
|
||||||
|
|||||||
@@ -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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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', '');
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1696,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);
|
||||||
@@ -2710,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) {
|
||||||
@@ -2719,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
|
||||||
@@ -2873,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)
|
||||||
@@ -2971,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
|
||||||
@@ -3530,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);
|
||||||
}
|
}
|
||||||
@@ -4410,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 ? (
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user