mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-09 01:49:38 +00:00
Compare commits
7 Commits
72e97c4fbc
...
c7dbab086b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7dbab086b | ||
|
|
b31f7afdf5 | ||
|
|
57739a659f | ||
|
|
a5813e66d9 | ||
|
|
18ea4a19dd | ||
|
|
1c95c598eb | ||
|
|
d1733f34e0 |
19
.env.example
19
.env.example
@@ -1,5 +1,15 @@
|
|||||||
# Claude Code UI Environment Configuration
|
# Claude Code UI Environment Configuration
|
||||||
# Only includes variables that are actually used in the code
|
# Only includes variables that are actually used in the code
|
||||||
|
#
|
||||||
|
# TIP: Run 'cloudcli status' to see where this file should be located
|
||||||
|
# and to view your current configuration.
|
||||||
|
#
|
||||||
|
# Available CLI commands:
|
||||||
|
# claude-code-ui - Start the server (default)
|
||||||
|
# cloudcli start - Start the server
|
||||||
|
# cloudcli status - Show configuration and data locations
|
||||||
|
# cloudcli help - Show help information
|
||||||
|
# cloudcli version - Show version information
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SERVER CONFIGURATION
|
# SERVER CONFIGURATION
|
||||||
@@ -19,10 +29,11 @@ VITE_PORT=5173
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Path to the authentication database file
|
# Path to the authentication database file
|
||||||
# This should be set to a persistent volume path when running in containers
|
# This is where user credentials, API keys, and tokens are stored.
|
||||||
# Default: server/database/auth.db (relative to project root)
|
#
|
||||||
# Example for Docker: /data/auth.db
|
# To use a custom location:
|
||||||
# DATABASE_PATH=/data/auth.db
|
# DATABASE_PATH=/path/to/your/custom/auth.db
|
||||||
|
#
|
||||||
# Claude Code context window size (maximum tokens per session)
|
# Claude Code context window size (maximum tokens per session)
|
||||||
# Note: VITE_ prefix makes it available to frontend
|
# Note: VITE_ prefix makes it available to frontend
|
||||||
VITE_CONTEXT_WINDOW=160000
|
VITE_CONTEXT_WINDOW=160000
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -69,8 +69,7 @@ npx @siteboon/claude-code-ui
|
|||||||
|
|
||||||
The server will start and be accessible at `http://localhost:3001` (or your configured PORT).
|
The server will start and be accessible at `http://localhost:3001` (or your configured PORT).
|
||||||
|
|
||||||
**To restart**: Simply run the same `npx` command again after stopping the server (Ctrl+C or Cmd+C).
|
**To restart**: Simply run the same `npx` command again after stopping the server
|
||||||
|
|
||||||
### Global Installation (For Regular Use)
|
### Global Installation (For Regular Use)
|
||||||
|
|
||||||
For frequent use, install globally once:
|
For frequent use, install globally once:
|
||||||
@@ -85,32 +84,71 @@ Then start with a simple command:
|
|||||||
claude-code-ui
|
claude-code-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- Faster startup (no download/cache check)
|
|
||||||
- Simple command to remember
|
|
||||||
- Same experience every time
|
|
||||||
|
|
||||||
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
|
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
|
||||||
|
|
||||||
### Run as Background Service (Optional)
|
### CLI Commands
|
||||||
|
|
||||||
To keep the server running in the background, use PM2:
|
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install PM2 globally (one-time)
|
# Start the server (default command)
|
||||||
npm install -g pm2
|
claude-code-ui
|
||||||
|
cloudcli start
|
||||||
|
|
||||||
# Start the server
|
# Show configuration and data locations
|
||||||
pm2 start claude-code-ui --name "claude-ui"
|
cloudcli status
|
||||||
|
|
||||||
# Manage the service
|
# Show help information
|
||||||
pm2 list # View status
|
cloudcli help
|
||||||
pm2 restart claude-ui # Restart
|
|
||||||
pm2 stop claude-ui # Stop
|
# Show version
|
||||||
pm2 logs claude-ui # View logs
|
cloudcli version
|
||||||
pm2 startup # Auto-start on system boot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**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)
|
||||||
|
|
||||||
|
For production use, run Claude Code UI as a background service using PM2 (Process Manager 2):
|
||||||
|
|
||||||
|
#### Install PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g pm2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Start as Background Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the server in background
|
||||||
|
pm2 start claude-code-ui --name "claude-code-ui"
|
||||||
|
|
||||||
|
# Or using the shorter alias
|
||||||
|
pm2 start cloudcli --name "claude-code-ui"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
#### Auto-Start on System Boot
|
||||||
|
|
||||||
|
To make Claude Code UI start automatically when your system boots:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate startup script for your platform
|
||||||
|
pm2 startup
|
||||||
|
|
||||||
|
# Save current process list
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Local Development Installation
|
### Local Development Installation
|
||||||
|
|
||||||
1. **Clone the repository:**
|
1. **Clone the repository:**
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.10.5",
|
"version": "1.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.10.5",
|
"version": "1.11.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||||
@@ -54,7 +54,8 @@
|
|||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"claude-code-ui": "server/index.js"
|
"claude-code-ui": "server/cli.js",
|
||||||
|
"cloudcli": "server/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.10.5",
|
"version": "1.11.0",
|
||||||
"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",
|
||||||
"bin": {
|
"bin": {
|
||||||
"claude-code-ui": "server/index.js"
|
"claude-code-ui": "server/cli.js",
|
||||||
|
"cloudcli": "server/cli.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"server/",
|
"server/",
|
||||||
|
|||||||
225
server/cli.js
Executable file
225
server/cli.js
Executable file
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Claude Code UI CLI
|
||||||
|
*
|
||||||
|
* Provides command-line utilities for managing Claude Code UI
|
||||||
|
*
|
||||||
|
* Commands:
|
||||||
|
* (no args) - Start the server (default)
|
||||||
|
* start - Start the server
|
||||||
|
* status - Show configuration and data locations
|
||||||
|
* help - Show help information
|
||||||
|
* version - Show version information
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// ANSI color codes for terminal output
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bright: '\x1b[1m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
|
||||||
|
// Foreground colors
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
magenta: '\x1b[35m',
|
||||||
|
white: '\x1b[37m',
|
||||||
|
gray: '\x1b[90m',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to colorize text
|
||||||
|
const c = {
|
||||||
|
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
||||||
|
ok: (text) => `${colors.green}${text}${colors.reset}`,
|
||||||
|
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
|
||||||
|
error: (text) => `${colors.yellow}${text}${colors.reset}`,
|
||||||
|
tip: (text) => `${colors.blue}${text}${colors.reset}`,
|
||||||
|
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
||||||
|
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load package.json for version info
|
||||||
|
const packageJsonPath = path.join(__dirname, '../package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
|
||||||
|
// Load environment variables from .env file if it exists
|
||||||
|
function loadEnvFile() {
|
||||||
|
try {
|
||||||
|
const envPath = path.join(__dirname, '../.env');
|
||||||
|
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||||
|
envFile.split('\n').forEach(line => {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||||
|
const [key, ...valueParts] = trimmedLine.split('=');
|
||||||
|
if (key && valueParts.length > 0 && !process.env[key]) {
|
||||||
|
process.env[key] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// .env file is optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the database path (same logic as db.js)
|
||||||
|
function getDatabasePath() {
|
||||||
|
loadEnvFile();
|
||||||
|
return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the installation directory
|
||||||
|
function getInstallDir() {
|
||||||
|
return path.join(__dirname, '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show status command
|
||||||
|
function showStatus() {
|
||||||
|
console.log(`\n${c.bright('Claude Code UI - Status')}\n`);
|
||||||
|
console.log(c.dim('═'.repeat(60)));
|
||||||
|
|
||||||
|
// Version info
|
||||||
|
console.log(`\n${c.info('[INFO]')} Version: ${c.bright(packageJson.version)}`);
|
||||||
|
|
||||||
|
// Installation location
|
||||||
|
const installDir = getInstallDir();
|
||||||
|
console.log(`\n${c.info('[INFO]')} Installation Directory:`);
|
||||||
|
console.log(` ${c.dim(installDir)}`);
|
||||||
|
|
||||||
|
// Database location
|
||||||
|
const dbPath = getDatabasePath();
|
||||||
|
const dbExists = fs.existsSync(dbPath);
|
||||||
|
console.log(`\n${c.info('[INFO]')} Database Location:`);
|
||||||
|
console.log(` ${c.dim(dbPath)}`);
|
||||||
|
console.log(` Status: ${dbExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not created yet (will be created on first run)')}`);
|
||||||
|
|
||||||
|
if (dbExists) {
|
||||||
|
const stats = fs.statSync(dbPath);
|
||||||
|
console.log(` Size: ${c.dim((stats.size / 1024).toFixed(2) + ' KB')}`);
|
||||||
|
console.log(` Modified: ${c.dim(stats.mtime.toLocaleString())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
|
console.log(`\n${c.info('[INFO]')} Configuration:`);
|
||||||
|
console.log(` PORT: ${c.bright(process.env.PORT || '3001')} ${c.dim(process.env.PORT ? '' : '(default)')}`);
|
||||||
|
console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);
|
||||||
|
console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);
|
||||||
|
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
|
||||||
|
|
||||||
|
// Claude projects folder
|
||||||
|
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
|
||||||
|
const projectsExists = fs.existsSync(claudeProjectsPath);
|
||||||
|
console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`);
|
||||||
|
console.log(` ${c.dim(claudeProjectsPath)}`);
|
||||||
|
console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`);
|
||||||
|
|
||||||
|
// Config file location
|
||||||
|
const envFilePath = path.join(__dirname, '../.env');
|
||||||
|
const envExists = fs.existsSync(envFilePath);
|
||||||
|
console.log(`\n${c.info('[INFO]')} Configuration File:`);
|
||||||
|
console.log(` ${c.dim(envFilePath)}`);
|
||||||
|
console.log(` Status: ${envExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found (using defaults)')}`);
|
||||||
|
|
||||||
|
console.log('\n' + c.dim('═'.repeat(60)));
|
||||||
|
console.log(`\n${c.tip('[TIP]')} Hints:`);
|
||||||
|
console.log(` ${c.dim('>')} Set DATABASE_PATH env variable to use a custom database location`);
|
||||||
|
console.log(` ${c.dim('>')} Create .env file in installation directory for persistent config`);
|
||||||
|
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`);
|
||||||
|
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show help
|
||||||
|
function showHelp() {
|
||||||
|
console.log(`
|
||||||
|
╔═══════════════════════════════════════════════════════════════╗
|
||||||
|
║ Claude Code UI - Command Line Tool ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
claude-code-ui [command]
|
||||||
|
cloudcli [command]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
start Start the Claude Code UI server (default)
|
||||||
|
status Show configuration and data locations
|
||||||
|
help Show this help information
|
||||||
|
version Show version information
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$ claude-code-ui # Start the server
|
||||||
|
$ cloudcli status # Show configuration
|
||||||
|
$ cloudcli help # Show help
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
PORT Set server port (default: 3001)
|
||||||
|
DATABASE_PATH Set custom database location
|
||||||
|
CLAUDE_CLI_PATH Set custom Claude CLI path
|
||||||
|
CONTEXT_WINDOW Set context window size (default: 160000)
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
Create a .env file in the installation directory to set
|
||||||
|
persistent environment variables. Use 'cloudcli status' to
|
||||||
|
see the installation directory path.
|
||||||
|
|
||||||
|
Documentation:
|
||||||
|
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
|
||||||
|
|
||||||
|
Report Issues:
|
||||||
|
${packageJson.bugs?.url || 'https://github.com/siteboon/claudecodeui/issues'}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show version
|
||||||
|
function showVersion() {
|
||||||
|
console.log(`${packageJson.version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
async function startServer() {
|
||||||
|
// Import and run the server
|
||||||
|
await import('./index.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main CLI handler
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0] || 'start';
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'start':
|
||||||
|
await startServer();
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
case 'info':
|
||||||
|
showStatus();
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
case '-h':
|
||||||
|
case '--help':
|
||||||
|
showHelp();
|
||||||
|
break;
|
||||||
|
case 'version':
|
||||||
|
case '-v':
|
||||||
|
case '--version':
|
||||||
|
showVersion();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error(`\n❌ Unknown command: ${command}`);
|
||||||
|
console.log(' Run "cloudcli help" for usage information.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the CLI
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('\n❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -8,6 +8,20 @@ import { dirname } from 'path';
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// ANSI color codes for terminal output
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bright: '\x1b[1m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
||||||
|
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
||||||
|
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||||
|
};
|
||||||
|
|
||||||
// Use DATABASE_PATH environment variable if set, otherwise use default location
|
// Use DATABASE_PATH environment variable if set, otherwise use default location
|
||||||
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
|
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
|
||||||
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
|
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
|
||||||
@@ -28,7 +42,18 @@ if (process.env.DATABASE_PATH) {
|
|||||||
|
|
||||||
// Create database connection
|
// Create database connection
|
||||||
const db = new Database(DB_PATH);
|
const db = new Database(DB_PATH);
|
||||||
console.log(`Connected to SQLite database at: ${DB_PATH}`);
|
|
||||||
|
// Show app installation path prominently
|
||||||
|
const appInstallPath = path.join(__dirname, '../..');
|
||||||
|
console.log('');
|
||||||
|
console.log(c.dim('═'.repeat(60)));
|
||||||
|
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
|
||||||
|
console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
|
||||||
|
if (process.env.DATABASE_PATH) {
|
||||||
|
console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
|
||||||
|
}
|
||||||
|
console.log(c.dim('═'.repeat(60)));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// Initialize database with schema
|
// Initialize database with schema
|
||||||
const initializeDatabase = async () => {
|
const initializeDatabase = async () => {
|
||||||
|
|||||||
@@ -8,6 +8,26 @@ import { dirname } from 'path';
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// ANSI color codes for terminal output
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bright: '\x1b[1m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
||||||
|
ok: (text) => `${colors.green}${text}${colors.reset}`,
|
||||||
|
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
|
||||||
|
tip: (text) => `${colors.blue}${text}${colors.reset}`,
|
||||||
|
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
||||||
|
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const envPath = path.join(__dirname, '../.env');
|
const envPath = path.join(__dirname, '../.env');
|
||||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||||
@@ -116,7 +136,7 @@ async function setupProjectsWatcher() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error handling project changes:', error);
|
console.error('[ERROR] Error handling project changes:', error);
|
||||||
}
|
}
|
||||||
}, 300); // 300ms debounce (slightly faster than before)
|
}, 300); // 300ms debounce (slightly faster than before)
|
||||||
};
|
};
|
||||||
@@ -129,13 +149,13 @@ async function setupProjectsWatcher() {
|
|||||||
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
|
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
|
||||||
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
|
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
|
||||||
.on('error', (error) => {
|
.on('error', (error) => {
|
||||||
console.error('❌ Chokidar watcher error:', error);
|
console.error('[ERROR] Chokidar watcher error:', error);
|
||||||
})
|
})
|
||||||
.on('ready', () => {
|
.on('ready', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to setup projects watcher:', error);
|
console.error('[ERROR] Failed to setup projects watcher:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,13 +177,13 @@ const wss = new WebSocketServer({
|
|||||||
// Verify token
|
// Verify token
|
||||||
const user = authenticateWebSocket(token);
|
const user = authenticateWebSocket(token);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log('❌ WebSocket authentication failed');
|
console.log('[WARN] WebSocket authentication failed');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store user info in the request for later use
|
// Store user info in the request for later use
|
||||||
info.req.user = user;
|
info.req.user = user;
|
||||||
console.log('✅ WebSocket authenticated for user:', user.username);
|
console.log('[OK] WebSocket authenticated for user:', user.username);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -462,7 +482,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
|
|||||||
const { projectName } = req.params;
|
const { projectName } = req.params;
|
||||||
const { filePath } = req.query;
|
const { filePath } = req.query;
|
||||||
|
|
||||||
console.log('📄 File read request:', projectName, filePath);
|
console.log('[DEBUG] File read request:', projectName, filePath);
|
||||||
|
|
||||||
// Security: ensure the requested path is inside the project root
|
// Security: ensure the requested path is inside the project root
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -503,7 +523,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
|
|||||||
const { projectName } = req.params;
|
const { projectName } = req.params;
|
||||||
const { path: filePath } = req.query;
|
const { path: filePath } = req.query;
|
||||||
|
|
||||||
console.log('🖼️ Binary file serve request:', projectName, filePath);
|
console.log('[DEBUG] Binary file serve request:', projectName, filePath);
|
||||||
|
|
||||||
// Security: ensure the requested path is inside the project root
|
// Security: ensure the requested path is inside the project root
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -557,7 +577,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
|
|||||||
const { projectName } = req.params;
|
const { projectName } = req.params;
|
||||||
const { filePath, content } = req.body;
|
const { filePath, content } = req.body;
|
||||||
|
|
||||||
console.log('💾 File save request:', projectName, filePath);
|
console.log('[DEBUG] File save request:', projectName, filePath);
|
||||||
|
|
||||||
// Security: ensure the requested path is inside the project root
|
// Security: ensure the requested path is inside the project root
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -628,7 +648,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
|||||||
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
||||||
res.json(files);
|
res.json(files);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ File tree error:', error.message);
|
console.error('[ERROR] File tree error:', error.message);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -636,7 +656,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
|||||||
// WebSocket connection handler that routes based on URL path
|
// WebSocket connection handler that routes based on URL path
|
||||||
wss.on('connection', (ws, request) => {
|
wss.on('connection', (ws, request) => {
|
||||||
const url = request.url;
|
const url = request.url;
|
||||||
console.log('🔗 Client connected to:', url);
|
console.log('[INFO] Client connected to:', url);
|
||||||
|
|
||||||
// Parse URL to get pathname without query parameters
|
// Parse URL to get pathname without query parameters
|
||||||
const urlObj = new URL(url, 'http://localhost');
|
const urlObj = new URL(url, 'http://localhost');
|
||||||
@@ -647,14 +667,14 @@ wss.on('connection', (ws, request) => {
|
|||||||
} else if (pathname === '/ws') {
|
} else if (pathname === '/ws') {
|
||||||
handleChatConnection(ws);
|
handleChatConnection(ws);
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Unknown WebSocket path:', pathname);
|
console.log('[WARN] Unknown WebSocket path:', pathname);
|
||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle chat WebSocket connections
|
// Handle chat WebSocket connections
|
||||||
function handleChatConnection(ws) {
|
function handleChatConnection(ws) {
|
||||||
console.log('💬 Chat WebSocket connected');
|
console.log('[INFO] Chat WebSocket connected');
|
||||||
|
|
||||||
// Add to connected clients for project updates
|
// Add to connected clients for project updates
|
||||||
connectedClients.add(ws);
|
connectedClients.add(ws);
|
||||||
@@ -664,28 +684,28 @@ function handleChatConnection(ws) {
|
|||||||
const data = JSON.parse(message);
|
const data = JSON.parse(message);
|
||||||
|
|
||||||
if (data.type === 'claude-command') {
|
if (data.type === 'claude-command') {
|
||||||
console.log('💬 User message:', data.command || '[Continue/Resume]');
|
console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');
|
||||||
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
|
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
|
||||||
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, ws);
|
||||||
} else if (data.type === 'cursor-command') {
|
} else if (data.type === 'cursor-command') {
|
||||||
console.log('🖱️ 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, ws);
|
||||||
} 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('🖱️ Cursor resume session (compat):', data.sessionId);
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
||||||
await spawnCursor('', {
|
await spawnCursor('', {
|
||||||
sessionId: data.sessionId,
|
sessionId: data.sessionId,
|
||||||
resume: true,
|
resume: true,
|
||||||
cwd: data.options?.cwd
|
cwd: data.options?.cwd
|
||||||
}, ws);
|
}, ws);
|
||||||
} else if (data.type === 'abort-session') {
|
} else if (data.type === 'abort-session') {
|
||||||
console.log('🛑 Abort session request:', data.sessionId);
|
console.log('[DEBUG] Abort session request:', data.sessionId);
|
||||||
const provider = data.provider || 'claude';
|
const provider = data.provider || 'claude';
|
||||||
let success;
|
let success;
|
||||||
|
|
||||||
@@ -703,7 +723,7 @@ function handleChatConnection(ws) {
|
|||||||
success
|
success
|
||||||
}));
|
}));
|
||||||
} else if (data.type === 'cursor-abort') {
|
} else if (data.type === 'cursor-abort') {
|
||||||
console.log('🛑 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({
|
ws.send(JSON.stringify({
|
||||||
type: 'session-aborted',
|
type: 'session-aborted',
|
||||||
@@ -742,7 +762,7 @@ function handleChatConnection(ws) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Chat WebSocket error:', error.message);
|
console.error('[ERROR] Chat WebSocket error:', error.message);
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: error.message
|
error: error.message
|
||||||
@@ -776,7 +796,7 @@ function handleShellConnection(ws) {
|
|||||||
const initialCommand = data.initialCommand;
|
const initialCommand = data.initialCommand;
|
||||||
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
||||||
|
|
||||||
console.log('🚀 Starting shell in:', projectPath);
|
console.log('[INFO] Starting shell in:', projectPath);
|
||||||
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
||||||
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
||||||
if (initialCommand) {
|
if (initialCommand) {
|
||||||
@@ -889,7 +909,7 @@ function handleShellConnection(ws) {
|
|||||||
let match;
|
let match;
|
||||||
while ((match = pattern.exec(data)) !== null) {
|
while ((match = pattern.exec(data)) !== null) {
|
||||||
const url = match[1];
|
const url = match[1];
|
||||||
console.log('🔗 Detected URL for opening:', url);
|
console.log('[DEBUG] Detected URL for opening:', url);
|
||||||
|
|
||||||
// Send URL opening message to client
|
// Send URL opening message to client
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
@@ -899,7 +919,7 @@ function handleShellConnection(ws) {
|
|||||||
|
|
||||||
// Replace the OPEN_URL pattern with a user-friendly message
|
// Replace the OPEN_URL pattern with a user-friendly message
|
||||||
if (pattern.source.includes('OPEN_URL')) {
|
if (pattern.source.includes('OPEN_URL')) {
|
||||||
outputData = outputData.replace(match[0], `🌐 Opening in browser: ${url}`);
|
outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -925,7 +945,7 @@ function handleShellConnection(ws) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (spawnError) {
|
} catch (spawnError) {
|
||||||
console.error('❌ Error spawning process:', spawnError);
|
console.error('[ERROR] Error spawning process:', spawnError);
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'output',
|
type: 'output',
|
||||||
data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
|
data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
|
||||||
@@ -951,7 +971,7 @@ function handleShellConnection(ws) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Shell WebSocket error:', error.message);
|
console.error('[ERROR] Shell WebSocket error:', error.message);
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'output',
|
type: 'output',
|
||||||
@@ -970,7 +990,7 @@ function handleShellConnection(ws) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
ws.on('error', (error) => {
|
||||||
console.error('❌ Shell WebSocket error:', error);
|
console.error('[ERROR] Shell WebSocket error:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Audio transcription endpoint
|
// Audio transcription endpoint
|
||||||
@@ -1411,28 +1431,37 @@ async function startServer() {
|
|||||||
try {
|
try {
|
||||||
// Initialize authentication database
|
// Initialize authentication database
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
console.log('✅ Database initialization skipped (testing)');
|
|
||||||
|
|
||||||
// Check if running in production mode (dist folder exists)
|
// Check if running in production mode (dist folder exists)
|
||||||
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
||||||
const isProduction = fs.existsSync(distIndexPath);
|
const isProduction = fs.existsSync(distIndexPath);
|
||||||
|
|
||||||
// Log Claude implementation mode
|
// Log Claude implementation mode
|
||||||
console.log('🚀 Using Claude Agents SDK for Claude integration');
|
console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
|
||||||
console.log(`📦 Running in ${isProduction ? 'PRODUCTION' : 'DEVELOPMENT'} mode`);
|
console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
|
||||||
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
console.log(`⚠️ Note: Requests will be proxied to Vite dev server at http://localhost:${process.env.VITE_PORT || 5173}`);
|
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', async () => {
|
server.listen(PORT, '0.0.0.0', async () => {
|
||||||
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
|
const appInstallPath = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(c.dim('═'.repeat(63)));
|
||||||
|
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
|
||||||
|
console.log(c.dim('═'.repeat(63)));
|
||||||
|
console.log('');
|
||||||
|
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
|
||||||
|
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
||||||
|
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// Start watching the projects folder for changes
|
// Start watching the projects folder for changes
|
||||||
await setupProjectsWatcher();
|
await setupProjectsWatcher();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to start server:', error);
|
console.error('[ERROR] Failed to start server:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4451,6 +4451,51 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000}
|
total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Slash commands button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const isOpening = !showCommandMenu;
|
||||||
|
setShowCommandMenu(isOpening);
|
||||||
|
setCommandQuery('');
|
||||||
|
setSelectedCommandIndex(-1);
|
||||||
|
|
||||||
|
// When opening, ensure all commands are shown
|
||||||
|
if (isOpening) {
|
||||||
|
setFilteredCommands(slashCommands);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
|
||||||
|
title="Show all commands"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Command count badge */}
|
||||||
|
{slashCommands.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
||||||
|
style={{ fontSize: '10px' }}
|
||||||
|
>
|
||||||
|
{slashCommands.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Clear input button - positioned to the right of token pie, only shows when there's input */}
|
{/* Clear input button - positioned to the right of token pie, only shows when there's input */}
|
||||||
{input.trim() && (
|
{input.trim() && (
|
||||||
<button
|
<button
|
||||||
@@ -4629,57 +4674,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
{/* Mic button - HIDDEN */}
|
{/* Mic button - HIDDEN */}
|
||||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
||||||
<MicButton
|
<MicButton
|
||||||
onTranscript={handleTranscript}
|
onTranscript={handleTranscript}
|
||||||
className="w-10 h-10 sm:w-10 sm:h-10"
|
className="w-10 h-10 sm:w-10 sm:h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slash commands button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const isOpening = !showCommandMenu;
|
|
||||||
setShowCommandMenu(isOpening);
|
|
||||||
setCommandQuery('');
|
|
||||||
setSelectedCommandIndex(-1);
|
|
||||||
|
|
||||||
// When opening, ensure all commands are shown
|
|
||||||
if (isOpening) {
|
|
||||||
setFilteredCommands(slashCommands);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="absolute right-14 sm:right-36 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-10 sm:h-10 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 relative z-10"
|
|
||||||
title="Show all commands"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/* Command count badge */}
|
|
||||||
{slashCommands.length > 0 && (
|
|
||||||
<span
|
|
||||||
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
|
||||||
style={{ fontSize: '10px' }}
|
|
||||||
>
|
|
||||||
{slashCommands.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Send button */}
|
{/* Send button */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
Reference in New Issue
Block a user