Compare commits

..

2 Commits

Author SHA1 Message Date
Haileyesus
3bbd56e8e9 fix: remove unnecessary child_process imports 2026-04-10 16:50:50 +03:00
Haileyesus
11733918e5 fix: replace child_process with cross-spawn 2026-04-10 16:37:47 +03:00
81 changed files with 666 additions and 701 deletions

View File

@@ -1,4 +1,4 @@
# CloudCLI — Docker Sandbox Templates # Claude Code UI — Docker Sandbox Templates
Run AI coding agents with a full web IDE inside [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/). Run AI coding agents with a full web IDE inside [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/).
@@ -62,11 +62,11 @@ docker build -f docker/gemini/Dockerfile -t cloudcli-sandbox:gemini docker/
Each template extends Docker's official sandbox base image and adds: Each template extends Docker's official sandbox base image and adds:
1. **Node.js 22** — Runtime for CloudCLI 1. **Node.js 22** — Runtime for Claude Code UI
2. **CloudCLI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli` 2. **Claude Code UI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli`
3. **Auto-start** — The UI server starts in the background when the sandbox shell opens (port 3001) 3. **Auto-start** — The UI server starts in the background when the sandbox shell opens (port 3001)
The agent (Claude Code, Codex, or Gemini) comes from the base image. CloudCLI connects to it and provides the web interface on top. The agent (Claude Code, Codex, or Gemini) comes from the base image. Claude Code UI connects to it and provides the web interface on top.
## Configuration ## Configuration
@@ -86,4 +86,4 @@ sbx policy allow network "localhost:3001"
## License ## License
These templates are free and open-source under the same license as CloudCLI (AGPL-3.0-or-later). These templates are free and open-source under the same license as Claude Code UI (AGPL-3.0-or-later).

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Auto-start CloudCLI server in background if not already running. # Auto-start Claude Code UI server in background if not already running.
# This script is sourced from ~/.bashrc on sandbox shell open. # This script is sourced from ~/.bashrc on sandbox shell open.
if ! pgrep -f "server/index.js" > /dev/null 2>&1; then if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
@@ -13,7 +13,7 @@ if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
disown disown
echo "" echo ""
echo " CloudCLI is starting on port 3001..." echo " Claude Code UI is starting on port 3001..."
echo "" echo ""
echo " To access the web UI, forward the port:" echo " To access the web UI, forward the port:"
echo " sbx ports \$(hostname) --publish 3001:3001" echo " sbx ports \$(hostname) --publish 3001:3001"

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CloudCLI - API Documentation</title> <title>Claude Code UI - API Documentation</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
@@ -418,7 +418,7 @@
</svg> </svg>
</div> </div>
<div class="brand-text"> <div class="brand-text">
<h1>CloudCLI</h1> <h1>Claude Code UI</h1>
<div class="subtitle">API Documentation</div> <div class="subtitle">API Documentation</div>
</div> </div>
</div> </div>
@@ -585,7 +585,7 @@
<p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p> <p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p>
<h4>Response (Non-Streaming)</h4> <h4>Response (Non-Streaming)</h4>
<p>JSON object containing session details and assistant messages only (filtered). Content-Type: <code>application/json</code></p> <p>JSON object containing session details, assistant messages only (filtered), and token usage summary. Content-Type: <code>application/json</code></p>
<h4>Error Response</h4> <h4>Error Response</h4>
<p>Returns error details with appropriate HTTP status code.</p> <p>Returns error details with appropriate HTTP status code.</p>
@@ -674,10 +674,21 @@ data: {"type":"done"}</code></pre>
"type": "text", "type": "text",
"text": "I've completed the task..." "text": "I've completed the task..."
} }
] ],
"usage": {
"input_tokens": 150,
"output_tokens": 50
}
} }
} }
], ],
"tokens": {
"inputTokens": 150,
"outputTokens": 50,
"cacheReadTokens": 0,
"cacheCreationTokens": 0,
"totalTokens": 200
},
"projectPath": "/path/to/project", "projectPath": "/path/to/project",
"branch": { "branch": {
"name": "fix-authentication-bug-abc123", "name": "fix-authentication-bug-abc123",

View File

@@ -1,4 +1,4 @@
// Service Worker for CloudCLI PWA // Service Worker for Claude Code UI PWA
// Cache only manifest (needed for PWA install). HTML and JS are never pre-cached // Cache only manifest (needed for PWA install). HTML and JS are never pre-cached
// so a rebuild + refresh always picks up the latest assets. // so a rebuild + refresh always picks up the latest assets.
const CACHE_NAME = 'claude-ui-v2'; const CACHE_NAME = 'claude-ui-v2';
@@ -79,7 +79,7 @@ self.addEventListener('push', event => {
try { try {
payload = event.data.json(); payload = event.data.json();
} catch { } catch {
payload = { title: 'CloudCLI', body: event.data.text() }; payload = { title: 'Claude Code UI', body: event.data.text() };
} }
const options = { const options = {
@@ -92,7 +92,7 @@ self.addEventListener('push', event => {
}; };
event.waitUntil( event.waitUntil(
self.registration.showNotification(payload.title || 'CloudCLI', options) self.registration.showNotification(payload.title || 'Claude Code UI', options)
); );
}); });

View File

@@ -274,6 +274,46 @@ function transformMessage(sdkMessage) {
return sdkMessage; return sdkMessage;
} }
/**
* Extracts token usage from SDK result messages
* @param {Object} resultMessage - SDK result message
* @returns {Object|null} Token budget object or null
*/
function extractTokenBudget(resultMessage) {
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
return null;
}
// Get the first model's usage data
const modelKey = Object.keys(resultMessage.modelUsage)[0];
const modelData = resultMessage.modelUsage[modelKey];
if (!modelData) {
return null;
}
// Use cumulative tokens if available (tracks total for the session)
// Otherwise fall back to per-request tokens
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
// Total used = input + output + cache tokens
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
// Use configured context window budget from environment (default 160000)
// This is the user's budget limit, not the model's context window
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
// Token calc logged via token-budget WS event
return {
used: totalUsed,
total: contextWindow
};
}
/** /**
* Handles image processing for SDK queries * Handles image processing for SDK queries
* Saves base64 images to temporary files and returns modified prompt with file paths * Saves base64 images to temporary files and returns modified prompt with file paths
@@ -617,6 +657,18 @@ async function queryClaudeSDK(command, options = {}, ws) {
} }
ws.send(msg); ws.send(msg);
} }
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
if (models.length > 0) {
// Model info available in result message
}
const tokenBudgetData = extractTokenBudget(message);
if (tokenBudgetData) {
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
}
}
} }
// Clean up session on completion // Clean up session on completion

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* CloudCLI CLI * Claude Code UI CLI
* *
* Provides command-line utilities for managing CloudCLI * Provides command-line utilities for managing Claude Code UI
* *
* Commands: * Commands:
* (no args) - Start the server (default) * (no args) - Start the server (default)
@@ -84,7 +84,7 @@ function getInstallDir() {
// Show status command // Show status command
function showStatus() { function showStatus() {
console.log(`\n${c.bright('CloudCLI UI - Status')}\n`); console.log(`\n${c.bright('Claude Code UI - Status')}\n`);
console.log(c.dim('═'.repeat(60))); console.log(c.dim('═'.repeat(60)));
// Version info // Version info
@@ -141,7 +141,7 @@ function showStatus() {
function showHelp() { function showHelp() {
console.log(` console.log(`
╔═══════════════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════════════╗
║ CloudCLI - Command Line Tool ║ ║ Claude Code UI - Command Line Tool ║
╚═══════════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════════╝
Usage: Usage:
@@ -149,7 +149,7 @@ Usage:
cloudcli [command] [options] cloudcli [command] [options]
Commands: Commands:
start Start the CloudCLI 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 update Update to the latest version
help Show this help information help Show this help information

View File

@@ -1,12 +1,8 @@
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { cursorAdapter } from './providers/cursor/adapter.js'; import { cursorAdapter } from './providers/cursor/adapter.js';
import { createNormalizedMessage } from './providers/types.js'; import { createNormalizedMessage } from './providers/types.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeCursorProcesses = new Map(); // Track active processes by session ID let activeCursorProcesses = new Map(); // Track active processes by session ID
const WORKSPACE_TRUST_PATTERNS = [ const WORKSPACE_TRUST_PATTERNS = [
@@ -122,7 +118,7 @@ async function spawnCursor(command, options = {}, ws) {
console.log('Working directory:', workingDir); console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const cursorProcess = spawnFunction('cursor-agent', args, { const cursorProcess = spawn('cursor-agent', args, {
cwd: workingDir, cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables env: { ...process.env } // Inherit all environment variables

View File

@@ -1,8 +1,4 @@
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import crossSpawn from 'cross-spawn';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
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';
@@ -168,7 +164,7 @@ async function spawnGemini(command, options = {}, ws) {
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, { const geminiProcess = spawn(spawnCmd, spawnArgs, {
cwd: workingDir, cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables env: { ...process.env } // Inherit all environment variables

View File

@@ -39,7 +39,7 @@ import os from 'os';
import http from 'http'; import http from 'http';
import cors from 'cors'; import cors from 'cors';
import { promises as fsPromises } from 'fs'; import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import pty from 'node-pty'; import pty from 'node-pty';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import mime from 'mime-types'; import mime from 'mime-types';
@@ -2218,6 +2218,194 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
} }
}); });
// Get token usage for a specific session
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const { provider = 'claude' } = req.query;
const homeDir = os.homedir();
// Allow only safe characters in sessionId
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeSessionId || safeSessionId !== String(sessionId)) {
return res.status(400).json({ error: 'Invalid sessionId' });
}
// Handle Cursor sessions - they use SQLite and don't have token usage info
if (provider === 'cursor') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Cursor sessions'
});
}
// Handle Gemini sessions - they are raw logs in our current setup
if (provider === 'gemini') {
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking not available for Gemini sessions'
});
}
// Handle Codex sessions
if (provider === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
// Find the session file by searching for the session ID
const findSessionFile = async (dir) => {
try {
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = await findSessionFile(fullPath);
if (found) return found;
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
return fullPath;
}
}
} catch (error) {
// Skip directories we can't read
}
return null;
};
const sessionFilePath = await findSessionFile(codexSessionsDir);
if (!sessionFilePath) {
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
}
// Read and parse the Codex JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
}
throw error;
}
const lines = fileContent.trim().split('\n');
let totalTokens = 0;
let contextWindow = 200000; // Default for Codex/OpenAI
// Find the latest token_count event with info (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Codex stores token info in event_msg with type: "token_count"
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const tokenInfo = entry.payload.info;
if (tokenInfo.total_token_usage) {
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
}
if (tokenInfo.model_context_window) {
contextWindow = tokenInfo.model_context_window;
}
break; // Stop after finding the latest token count
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
return res.json({
used: totalTokens,
total: contextWindow
});
}
// Handle Claude sessions (default)
// Extract actual project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
console.error('Error extracting project directory:', error);
return res.status(500).json({ error: 'Failed to determine project path' });
}
// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces any non-alphanumeric character (except -) with -
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Read and parse the JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
}
throw error; // Re-throw other errors to be caught by outer try-catch
}
const lines = fileContent.trim().split('\n');
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0;
let cacheCreationTokens = 0;
let cacheReadTokens = 0;
// Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
// Only count assistant messages which have usage data
if (entry.type === 'assistant' && entry.message?.usage) {
const usage = entry.message.usage;
// Use token counts from latest assistant message only
inputTokens = usage.input_tokens || 0;
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break; // Stop after finding the latest assistant message
}
} catch (parseError) {
// Skip lines that can't be parsed
continue;
}
}
// Calculate total context usage (excluding output_tokens, as per ccusage)
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
res.json({
used: totalUsed,
total: contextWindow,
breakdown: {
input: inputTokens,
cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
}
});
} catch (error) {
console.error('Error reading session token usage:', error);
res.status(500).json({ error: 'Failed to read session token usage' });
}
});
// Serve React app for all other routes (excluding static files) // Serve React app for all other routes (excluding static files)
app.get('*', (req, res) => { app.get('*', (req, res) => {
// Skip requests for static assets (files with extensions) // Skip requests for static assets (files with extensions)
@@ -2360,7 +2548,7 @@ async function startServer() {
console.log(''); console.log('');
console.log(c.dim('═'.repeat(63))); console.log(c.dim('═'.repeat(63)));
console.log(` ${c.bright('CloudCLI Server - Ready')}`); console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
console.log(c.dim('═'.repeat(63))); console.log(c.dim('═'.repeat(63)));
console.log(''); console.log('');
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`); console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`);

View File

@@ -129,7 +129,8 @@ function transformCodexEvent(event) {
case 'turn.completed': case 'turn.completed':
return { return {
type: 'turn_complete' type: 'turn_complete',
usage: event.usage
}; };
case 'turn.failed': case 'turn.failed':
@@ -278,6 +279,12 @@ export async function queryCodex(command, options = {}, ws) {
error: terminalFailure error: terminalFailure
}); });
} }
// Extract and send token usage if available (normalized to match Claude format)
if (event.type === 'turn.completed' && event.usage) {
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' }));
}
} }
// Send completion event // Send completion event

View File

@@ -1618,6 +1618,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
} }
const messages = []; const messages = [];
let tokenUsage = null;
const fileStream = fsSync.createReadStream(sessionFilePath); const fileStream = fsSync.createReadStream(sessionFilePath);
const rl = readline.createInterface({ const rl = readline.createInterface({
input: fileStream, input: fileStream,
@@ -1646,6 +1647,17 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
try { try {
const entry = JSON.parse(line); const entry = JSON.parse(line);
// Extract token usage from token_count events (keep latest)
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const info = entry.payload.info;
if (info.total_token_usage) {
tokenUsage = {
used: info.total_token_usage.total_tokens || 0,
total: info.model_context_window || 200000
};
}
}
// Use event_msg.user_message for user-visible inputs. // Use event_msg.user_message for user-visible inputs.
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) { if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
messages.push({ messages.push({
@@ -1808,10 +1820,11 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
hasMore, hasMore,
offset, offset,
limit, limit,
tokenUsage
}; };
} }
return { messages }; return { messages, tokenUsage };
} catch (error) { } catch (error) {
console.error(`Error reading Codex session messages for ${sessionId}:`, error); console.error(`Error reading Codex session messages for ${sessionId}:`, error);

View File

@@ -214,6 +214,7 @@ export const codexAdapter = {
const rawMessages = Array.isArray(result) ? result : (result.messages || []); const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0); const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore); const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = result.tokenUsage || null;
const normalized = []; const normalized = [];
for (const raw of rawMessages) { for (const raw of rawMessages) {
@@ -241,6 +242,7 @@ export const codexAdapter = {
hasMore, hasMore,
offset, offset,
limit, limit,
tokenUsage,
}; };
}, },
}; };

View File

@@ -53,7 +53,14 @@ export function normalizeMessage(raw, sessionId) {
} }
if (raw.type === 'result') { if (raw.type === 'result') {
return [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })]; const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
if (raw.stats?.total_tokens) {
msgs.push(createNormalizedMessage({
sessionId, timestamp: ts, provider: PROVIDER,
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
}));
}
return msgs;
} }
if (raw.type === 'error') { if (raw.type === 'error') {

View File

@@ -41,7 +41,7 @@
* - stream_end: (no extra fields) * - stream_end: (no extra fields)
* - error: content * - error: content
* - complete: (no extra fields) * - complete: (no extra fields)
* - status: text, canInterrupt? * - status: text, tokens?, canInterrupt?
* - permission_request: requestId, toolName, input, context? * - permission_request: requestId, toolName, input, context?
* - permission_cancelled: requestId * - permission_cancelled: requestId
* - session_created: newSessionId * - session_created: newSessionId
@@ -66,6 +66,7 @@
* @property {boolean} hasMore - Whether more messages exist before the current page * @property {boolean} hasMore - Whether more messages exist before the current page
* @property {number} offset - Current offset * @property {number} offset - Current offset
* @property {number|null} limit - Page size used * @property {number|null} limit - Page size used
* @property {object} [tokenUsage] - Token usage data (provider-specific)
*/ */
// ─── Provider Adapter Interface ────────────────────────────────────────────── // ─── Provider Adapter Interface ──────────────────────────────────────────────

View File

@@ -1,5 +1,5 @@
import express from 'express'; import express from 'express';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
@@ -546,12 +546,7 @@ class ResponseCollector {
const parsed = JSON.parse(msg); const parsed = JSON.parse(msg);
// Only include claude-response messages with assistant type // Only include claude-response messages with assistant type
if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') { if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
const assistantMessage = { ...parsed.data }; assistantMessages.push(parsed.data);
if (assistantMessage.message?.usage) {
assistantMessage.message = { ...assistantMessage.message };
delete assistantMessage.message.usage;
}
assistantMessages.push(assistantMessage);
} }
} catch (e) { } catch (e) {
// Not JSON, skip // Not JSON, skip
@@ -561,6 +556,49 @@ class ResponseCollector {
return assistantMessages; return assistantMessages;
} }
/**
* Calculate total tokens from all messages
*/
getTotalTokens() {
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheCreation = 0;
for (const msg of this.messages) {
let data = msg;
// Parse if string
if (typeof msg === 'string') {
try {
data = JSON.parse(msg);
} catch (e) {
continue;
}
}
// Extract usage from claude-response messages
if (data && data.type === 'claude-response' && data.data) {
const msgData = data.data;
if (msgData.message && msgData.message.usage) {
const usage = msgData.message.usage;
totalInput += usage.input_tokens || 0;
totalOutput += usage.output_tokens || 0;
totalCacheRead += usage.cache_read_input_tokens || 0;
totalCacheCreation += usage.cache_creation_input_tokens || 0;
}
}
}
return {
inputTokens: totalInput,
outputTokens: totalOutput,
cacheReadTokens: totalCacheRead,
cacheCreationTokens: totalCacheCreation,
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
};
}
} }
// =============================== // ===============================
@@ -751,6 +789,13 @@ class ResponseCollector {
* success: true, * success: true,
* sessionId: "session-123", * sessionId: "session-123",
* messages: [...], // Assistant messages only (filtered) * messages: [...], // Assistant messages only (filtered)
* tokens: {
* inputTokens: 150,
* outputTokens: 50,
* cacheReadTokens: 0,
* cacheCreationTokens: 0,
* totalTokens: 200
* },
* projectPath: "/path/to/project", * projectPath: "/path/to/project",
* branch: { // Only if createBranch=true * branch: { // Only if createBranch=true
* name: "feature/xyz", * name: "feature/xyz",
@@ -1080,7 +1125,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
} else { } else {
prBody += `Agent task: ${message}`; prBody += `Agent task: ${message}`;
} }
prBody += '\n\n---\n*This pull request was automatically created by CloudCLI.ai Agent.*'; prBody += '\n\n---\n*This pull request was automatically created by Claude Code UI Agent.*';
console.log(`📝 PR Title: ${prTitle}`); console.log(`📝 PR Title: ${prTitle}`);
@@ -1128,13 +1173,15 @@ router.post('/', validateExternalApiKey, async (req, res) => {
// Streaming mode: end the SSE stream // Streaming mode: end the SSE stream
writer.end(); writer.end();
} else { } else {
// Non-streaming mode: send filtered messages as JSON // Non-streaming mode: send filtered messages and token summary as JSON
const assistantMessages = writer.getAssistantMessages(); const assistantMessages = writer.getAssistantMessages();
const tokenSummary = writer.getTotalTokens();
const response = { const response = {
success: true, success: true,
sessionId: writer.getSessionId(), sessionId: writer.getSessionId(),
messages: assistantMessages, messages: assistantMessages,
tokens: tokenSummary,
projectPath: finalProjectPath projectPath: finalProjectPath
}; };

View File

@@ -1,5 +1,5 @@
import express from 'express'; import express from 'express';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';

View File

@@ -1,5 +1,5 @@
import express from 'express'; import express from 'express';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
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';

View File

@@ -97,6 +97,12 @@ const builtInCommands = [
namespace: 'builtin', namespace: 'builtin',
metadata: { type: 'builtin' } metadata: { type: 'builtin' }
}, },
{
name: '/cost',
description: 'Display token usage and cost information',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{ {
name: '/memory', name: '/memory',
description: 'Open CLAUDE.md memory file for editing', description: 'Open CLAUDE.md memory file for editing',
@@ -203,6 +209,86 @@ Custom commands can be created in:
}; };
}, },
'/cost': async (args, context) => {
const tokenUsage = context?.tokenUsage || {};
const provider = context?.provider || 'claude';
const model =
context?.model ||
(provider === 'cursor'
? CURSOR_MODELS.DEFAULT
: provider === 'codex'
? CODEX_MODELS.DEFAULT
: CLAUDE_MODELS.DEFAULT);
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
const total =
Number(
tokenUsage.total ??
tokenUsage.contextWindow ??
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
) || 160000;
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
const inputTokensRaw =
Number(
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.promptTokens ??
0,
) || 0;
const outputTokens =
Number(
tokenUsage.outputTokens ??
tokenUsage.output ??
tokenUsage.cumulativeOutputTokens ??
tokenUsage.completionTokens ??
0,
) || 0;
const cacheTokens =
Number(
tokenUsage.cacheReadTokens ??
tokenUsage.cacheCreationTokens ??
tokenUsage.cacheTokens ??
tokenUsage.cachedTokens ??
0,
) || 0;
// If we only have total used tokens, treat them as input for display/estimation.
const inputTokens =
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
// Rough default rates by provider (USD / 1M tokens).
const pricingByProvider = {
claude: { input: 3, output: 15 },
cursor: { input: 3, output: 15 },
codex: { input: 1.5, output: 6 },
};
const rates = pricingByProvider[provider] || pricingByProvider.claude;
const inputCost = (inputTokens / 1_000_000) * rates.input;
const outputCost = (outputTokens / 1_000_000) * rates.output;
const totalCost = inputCost + outputCost;
return {
type: 'builtin',
action: 'cost',
data: {
tokenUsage: {
used,
total,
percentage,
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
},
model,
},
};
},
'/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

@@ -2,7 +2,7 @@ import express from 'express';
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 { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
import { open } from 'sqlite'; import { open } from 'sqlite';
import crypto from 'crypto'; import crypto from 'crypto';

View File

@@ -1,5 +1,5 @@
import express from 'express'; import express from 'express';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import path from 'path'; import path from 'path';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js'; import { extractProjectDirectory } from '../projects.js';

View File

@@ -4,7 +4,7 @@ import path from 'path';
import os from 'os'; import os from 'os';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
const router = express.Router(); const router = express.Router();
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);

View File

@@ -1,7 +1,7 @@
import express from 'express'; import express from 'express';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import os from 'os'; import os from 'os';
import { addProjectManually } from '../projects.js'; import { addProjectManually } from '../projects.js';

View File

@@ -12,7 +12,7 @@ import express from 'express';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { promises as fsPromises } from 'fs'; import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
import os from 'os'; import os from 'os';

View File

@@ -2,7 +2,7 @@ import express from 'express';
import { userDb } from '../database/db.js'; import { userDb } from '../database/db.js';
import { authenticateToken } from '../middleware/auth.js'; import { authenticateToken } from '../middleware/auth.js';
import { getSystemGitConfig } from '../utils/gitConfig.js'; import { getSystemGitConfig } from '../utils/gitConfig.js';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
const router = express.Router(); const router = express.Router();

View File

@@ -125,7 +125,7 @@ function buildPushBody(event) {
const message = CODE_MAP[event.code] || 'You have a new notification'; const message = CODE_MAP[event.code] || 'You have a new notification';
return { return {
title: sessionName || 'CloudCLI', title: sessionName || 'Claude Code UI',
body: `${providerLabel}: ${message}`, body: `${providerLabel}: ${message}`,
data: { data: {
sessionId: event.sessionId || null, sessionId: event.sessionId || null,

View File

@@ -1,4 +1,4 @@
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
function spawnAsync(command, args) { function spawnAsync(command, args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins'); const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');

View File

@@ -1,4 +1,4 @@
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import path from 'path'; import path from 'path';
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'; import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';

View File

@@ -12,7 +12,7 @@ export default function AuthLoadingScreen() {
</div> </div>
</div> </div>
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1> <h1 className="mb-2 text-2xl font-bold text-foreground">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
{loadingDotAnimationDelays.map((delay) => ( {loadingDotAnimationDelays.map((delay) => (

View File

@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { MessageSquare } from 'lucide-react'; import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../../../constants/config';
type AuthScreenLayoutProps = { type AuthScreenLayoutProps = {
title: string; title: string;
@@ -38,22 +37,6 @@ export default function AuthScreenLayout({
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground">{footerText}</p> <p className="text-sm text-muted-foreground">{footerText}</p>
</div> </div>
{!IS_PLATFORM && (
<div className="flex items-center justify-center gap-1.5 pt-2">
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
<a
href="https://github.com/siteboon/claudecodeui"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 transition-colors hover:text-muted-foreground"
>
CloudCLI is open source
</a>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -58,7 +58,7 @@ export default function LoginForm() {
<AuthScreenLayout <AuthScreenLayout
title={t('login.title')} title={t('login.title')}
description={t('login.description')} description={t('login.description')}
footerText="Enter your credentials to access CloudCLI" footerText="Enter your credentials to access Claude Code UI"
> >
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField <AuthInputField

View File

@@ -82,7 +82,7 @@ export default function SetupForm() {
return ( return (
<AuthScreenLayout <AuthScreenLayout
title="Welcome to CloudCLI" title="Welcome to Claude Code UI"
description="Set up your account to get started" description="Set up your account to get started"
footerText="This is a single-user system. Only one account can be created." footerText="This is a single-user system. Only one account can be created."
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />} logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}

View File

@@ -42,6 +42,7 @@ interface UseChatComposerStateArgs {
geminiModel: string; geminiModel: string;
isLoading: boolean; isLoading: boolean;
canAbortSession: boolean; canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void; sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void; onSessionActive?: (sessionId?: string | null) => void;
@@ -56,7 +57,7 @@ interface UseChatComposerStateArgs {
rewindMessages: (count: number) => void; rewindMessages: (count: number) => void;
setIsLoading: (loading: boolean) => void; setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void; setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; can_interrupt: boolean } | null) => void; setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setIsUserScrolledUp: (isScrolledUp: boolean) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
} }
@@ -113,6 +114,7 @@ export function useChatComposerState({
geminiModel, geminiModel,
isLoading, isLoading,
canAbortSession, canAbortSession,
tokenBudget,
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive, onSessionActive,
@@ -174,6 +176,12 @@ export function useChatComposerState({
}); });
break; break;
case 'cost': {
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
break;
}
case 'status': { case 'status': {
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() }); addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
@@ -274,6 +282,7 @@ export function useChatComposerState({
sessionId: currentSessionId, sessionId: currentSessionId,
provider, provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel, model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
tokenUsage: tokenBudget,
}; };
const response = await authenticatedFetch('/api/commands/execute', { const response = await authenticatedFetch('/api/commands/execute', {
@@ -330,6 +339,7 @@ export function useChatComposerState({
provider, provider,
selectedProject, selectedProject,
addMessage, addMessage,
tokenBudget,
], ],
); );
@@ -533,6 +543,7 @@ export function useChatComposerState({
setCanAbortSession(true); setCanAbortSession(true);
setClaudeStatus({ setClaudeStatus({
text: 'Processing', text: 'Processing',
tokens: 0,
can_interrupt: true, can_interrupt: true,
}); });

View File

@@ -38,7 +38,9 @@ type LatestChatMessage = {
provider?: string; provider?: string;
content?: string; content?: string;
text?: string; text?: string;
tokens?: number;
canInterrupt?: boolean; canInterrupt?: boolean;
tokenBudget?: unknown;
newSessionId?: string; newSessionId?: string;
aborted?: boolean; aborted?: boolean;
[key: string]: any; [key: string]: any;
@@ -53,7 +55,8 @@ interface UseChatRealtimeHandlersArgs {
setCurrentSessionId: (sessionId: string | null) => void; setCurrentSessionId: (sessionId: string | null) => void;
setIsLoading: (loading: boolean) => void; setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void; setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; can_interrupt: boolean } | null) => void; setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>; pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
streamBufferRef: MutableRefObject<string>; streamBufferRef: MutableRefObject<string>;
@@ -82,6 +85,7 @@ export function useChatRealtimeHandlers({
setIsLoading, setIsLoading,
setCanAbortSession, setCanAbortSession,
setClaudeStatus, setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef, pendingViewSessionRef,
streamBufferRef, streamBufferRef,
@@ -136,6 +140,7 @@ export function useChatRealtimeHandlers({
if (status) { if (status) {
const statusInfo = { const statusInfo = {
text: status.text || 'Working...', text: status.text || 'Working...',
tokens: status.tokens || 0,
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true, can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
}; };
setClaudeStatus(statusInfo); setClaudeStatus(statusInfo);
@@ -306,7 +311,7 @@ export function useChatRealtimeHandlers({
}); });
setIsLoading(true); setIsLoading(true);
setCanAbortSession(true); setCanAbortSession(true);
setClaudeStatus({ text: 'Waiting for permission', can_interrupt: true }); setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
break; break;
} }
@@ -318,9 +323,12 @@ export function useChatRealtimeHandlers({
} }
case 'status': { case 'status': {
if (msg.text) { if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text) {
setClaudeStatus({ setClaudeStatus({
text: msg.text, text: msg.text,
tokens: msg.tokens || 0,
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true, can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
}); });
setIsLoading(true); setIsLoading(true);
@@ -344,6 +352,7 @@ export function useChatRealtimeHandlers({
setIsLoading, setIsLoading,
setCanAbortSession, setCanAbortSession,
setClaudeStatus, setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef, pendingViewSessionRef,
streamBufferRef, streamBufferRef,

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types'; import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
@@ -107,8 +108,9 @@ export function useChatSessionState({
const [totalMessages, setTotalMessages] = useState(0); const [totalMessages, setTotalMessages] = useState(0);
const [canAbortSession, setCanAbortSession] = useState(false); const [canAbortSession, setCanAbortSession] = useState(false);
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
const [claudeStatus, setClaudeStatus] = useState<{ text: string; can_interrupt: boolean } | null>(null); const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false); const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false); const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false); const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
@@ -317,6 +319,7 @@ export function useChatSessionState({
messagesOffsetRef.current = 0; messagesOffsetRef.current = 0;
setHasMoreMessages(false); setHasMoreMessages(false);
setTotalMessages(0); setTotalMessages(0);
setTokenBudget(null);
lastLoadedSessionKeyRef.current = null; lastLoadedSessionKeyRef.current = null;
return; return;
} }
@@ -352,6 +355,7 @@ export function useChatSessionState({
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
if (sessionChanged) { if (sessionChanged) {
setTokenBudget(null);
setIsLoading(false); setIsLoading(false);
} }
@@ -379,6 +383,7 @@ export function useChatSessionState({
if (slot) { if (slot) {
setHasMoreMessages(slot.hasMore); setHasMoreMessages(slot.hasMore);
setTotalMessages(slot.total); setTotalMessages(slot.total);
if (slot.tokenUsage) setTokenBudget(slot.tokenUsage as Record<string, unknown>);
} }
setIsLoadingSessionMessages(false); setIsLoadingSessionMessages(false);
}).catch(() => { }).catch(() => {
@@ -534,6 +539,31 @@ export function useChatSessionState({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]); }, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
// Token usage fetch for Claude
useEffect(() => {
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
setTokenBudget(null);
return;
}
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'claude') return;
const fetchInitialTokenUsage = async () => {
try {
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
const response = await authenticatedFetch(url);
if (response.ok) {
setTokenBudget(await response.json());
} else {
setTokenBudget(null);
}
} catch (error) {
console.error('Failed to fetch initial token usage:', error);
}
};
fetchInitialTokenUsage();
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
const visibleMessages = useMemo(() => { const visibleMessages = useMemo(() => {
if (chatMessages.length <= visibleMessageCount) return chatMessages; if (chatMessages.length <= visibleMessageCount) return chatMessages;
return chatMessages.slice(-visibleMessageCount); return chatMessages.slice(-visibleMessageCount);
@@ -683,6 +713,8 @@ export function useChatSessionState({
setCanAbortSession, setCanAbortSession,
isUserScrolledUp, isUserScrolledUp,
setIsUserScrolledUp, setIsUserScrolledUp,
tokenBudget,
setTokenBudget,
visibleMessageCount, visibleMessageCount,
visibleMessages, visibleMessages,
loadEarlierMessages, loadEarlierMessages,

View File

@@ -96,6 +96,8 @@ function ChatInterface({
setCanAbortSession, setCanAbortSession,
isUserScrolledUp, isUserScrolledUp,
setIsUserScrolledUp, setIsUserScrolledUp,
tokenBudget,
setTokenBudget,
visibleMessageCount, visibleMessageCount,
visibleMessages, visibleMessages,
loadEarlierMessages, loadEarlierMessages,
@@ -181,6 +183,7 @@ function ChatInterface({
geminiModel, geminiModel,
isLoading, isLoading,
canAbortSession, canAbortSession,
tokenBudget,
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive, onSessionActive,
@@ -224,6 +227,7 @@ function ChatInterface({
setIsLoading, setIsLoading,
setCanAbortSession, setCanAbortSession,
setClaudeStatus, setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests, setPendingPermissionRequests,
pendingViewSessionRef, pendingViewSessionRef,
streamBufferRef, streamBufferRef,
@@ -348,6 +352,7 @@ function ChatInterface({
onModeSwitch={cyclePermissionMode} onModeSwitch={cyclePermissionMode}
thinkingMode={thinkingMode} thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode} setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount} slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu} onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())} hasInput={Boolean(input.trim())}

View File

@@ -41,7 +41,7 @@ interface ChatComposerProps {
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown }, decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void; ) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean }; handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
claudeStatus: { text: string; can_interrupt: boolean } | null; claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
isLoading: boolean; isLoading: boolean;
onAbortSession: () => void; onAbortSession: () => void;
provider: Provider | string; provider: Provider | string;
@@ -49,6 +49,7 @@ interface ChatComposerProps {
onModeSwitch: () => void; onModeSwitch: () => void;
thinkingMode: string; thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>; setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number; slashCommandsCount: number;
onToggleCommandMenu: () => void; onToggleCommandMenu: () => void;
hasInput: boolean; hasInput: boolean;
@@ -105,6 +106,7 @@ export default function ChatComposer({
onModeSwitch, onModeSwitch,
thinkingMode, thinkingMode,
setThinkingMode, setThinkingMode,
tokenBudget,
slashCommandsCount, slashCommandsCount,
onToggleCommandMenu, onToggleCommandMenu,
hasInput, hasInput,
@@ -192,6 +194,7 @@ export default function ChatComposer({
provider={provider} provider={provider}
thinkingMode={thinkingMode} thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode} setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount} slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={onToggleCommandMenu} onToggleCommandMenu={onToggleCommandMenu}
hasInput={hasInput} hasInput={hasInput}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { PermissionMode, Provider } from '../../types/types'; import type { PermissionMode, Provider } from '../../types/types';
import ThinkingModeSelector from './ThinkingModeSelector'; import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
interface ChatInputControlsProps { interface ChatInputControlsProps {
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
@@ -9,6 +10,7 @@ interface ChatInputControlsProps {
provider: Provider | string; provider: Provider | string;
thinkingMode: string; thinkingMode: string;
setThinkingMode: React.Dispatch<React.SetStateAction<string>>; setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number; slashCommandsCount: number;
onToggleCommandMenu: () => void; onToggleCommandMenu: () => void;
hasInput: boolean; hasInput: boolean;
@@ -24,6 +26,7 @@ export default function ChatInputControls({
provider, provider,
thinkingMode, thinkingMode,
setThinkingMode, setThinkingMode,
tokenBudget,
slashCommandsCount, slashCommandsCount,
onToggleCommandMenu, onToggleCommandMenu,
hasInput, hasInput,
@@ -75,6 +78,8 @@ export default function ChatInputControls({
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" /> <ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)} )}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<button <button
type="button" type="button"
onClick={onToggleCommandMenu} onClick={onToggleCommandMenu}

View File

@@ -6,6 +6,7 @@ import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'
type ClaudeStatusProps = { type ClaudeStatusProps = {
status: { status: {
text?: string; text?: string;
tokens?: number;
can_interrupt?: boolean; can_interrupt?: boolean;
} | null; } | null;
onAbort?: () => void; onAbort?: () => void;

View File

@@ -0,0 +1,54 @@
type TokenUsagePieProps = {
used: number;
total: number;
};
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
// Token usage visualization component
// Only bail out on missing values or nonpositive totals; allow used===0 to render 0%
if (used == null || total == null || total <= 0) return null;
const percentage = Math.min(100, (used / total) * 100);
const radius = 10;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
// Color based on usage level
const getColor = () => {
if (percentage < 50) return '#3b82f6'; // blue
if (percentage < 75) return '#f59e0b'; // orange
return '#ef4444'; // red
};
return (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
{/* Background circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-gray-300 dark:text-gray-600"
/>
{/* Progress circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke={getColor()}
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
/>
</svg>
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
{percentage.toFixed(1)}%
</span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about'; export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date'; export type ProjectSortOrder = 'name' | 'date';

View File

@@ -1,46 +0,0 @@
import { ExternalLink, Lock } from 'lucide-react';
import type { ReactNode } from 'react';
const CLOUDCLI_URL = 'https://cloudcli.ai';
type PremiumFeatureCardProps = {
icon: ReactNode;
title: string;
description: string;
ctaText?: string;
};
export default function PremiumFeatureCard({
icon,
title,
description,
ctaText = 'Available with CloudCLI Pro',
}: PremiumFeatureCardProps) {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground">
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground">{title}</h4>
<Lock className="h-3 w-3 text-muted-foreground/60" />
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{description}
</p>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
{ctaText}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
);
}

View File

@@ -12,7 +12,6 @@ import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab'; import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab'; import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab'; import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
import AboutTab from '../view/tabs/AboutTab';
import { useSettingsController } from '../hooks/useSettingsController'; import { useSettingsController } from '../hooks/useSettingsController';
import { useWebPush } from '../../../hooks/useWebPush'; import { useWebPush } from '../../../hooks/useWebPush';
import type { SettingsProps } from '../types/types'; import type { SettingsProps } from '../types/types';
@@ -207,8 +206,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'api' && <CredentialsSettingsTab />} {activeTab === 'api' && <CredentialsSettingsTab />}
{activeTab === 'plugins' && <PluginSettingsTab />} {activeTab === 'plugins' && <PluginSettingsTab />}
{activeTab === 'about' && <AboutTab />}
</div> </div>
</main> </main>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { GitBranch, Info, Key, Puzzle } from 'lucide-react'; import { GitBranch, Key, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SettingsMainTab } from '../types/types'; import type { SettingsMainTab } from '../types/types';
@@ -22,7 +22,6 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'tasks', labelKey: 'mainTabs.tasks' }, { id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'notifications', labelKey: 'mainTabs.notifications' }, { id: 'notifications', labelKey: 'mainTabs.notifications' },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle }, { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },
]; ];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) { export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {

View File

@@ -1,4 +1,4 @@
import { Bell, Bot, GitBranch, Info, Key, ListChecks, Palette, Puzzle } from 'lucide-react'; import { Bell, Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import { PillBar, Pill } from '../../../shared/view/ui'; import { PillBar, Pill } from '../../../shared/view/ui';
@@ -23,7 +23,6 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks }, { id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle }, { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell }, { id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },
]; ];
export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) { export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {

View File

@@ -1,166 +0,0 @@
import { ExternalLink, MessageSquare, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../../../../constants/config';
import { useVersionCheck } from '../../../../hooks/useVersionCheck';
import PremiumFeatureCard from '../PremiumFeatureCard';
import { Cloud, Users } from 'lucide-react';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
const DOCS_URL = 'https://cloudcli.ai/docs/plugin-overview';
const CLOUDCLI_URL = 'https://cloudcli.ai';
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
function DiscordIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
export default function AboutTab() {
const { t } = useTranslation('settings');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const releasesUrl = releaseInfo?.htmlUrl || `${GITHUB_REPO_URL}/releases`;
return (
<div className="space-y-6">
{/* Logo + name + version */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary/90 shadow-sm">
<MessageSquare className="h-5 w-5 text-primary-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-base font-semibold text-foreground">CloudCLI</span>
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
>
v{currentVersion}
</a>
{updateAvailable && latestVersion && (
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
>
{t('apiKeys.version.updateAvailable', { version: latestVersion })}
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
<p className="mt-0.5 text-sm text-muted-foreground">
Open-source AI coding assistant interface
</p>
</div>
</div>
{/* Star on GitHub button */}
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-background px-3.5 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<GitHubIcon className="h-4 w-4" />
<Star className="h-3.5 w-3.5" />
<span>Star on GitHub</span>
</a>
{/* Links */}
<div className="flex flex-wrap gap-4 text-sm">
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<GitHubIcon className="h-4 w-4" />
GitHub
</a>
<a
href={DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<DiscordIcon className="h-4 w-4" />
Discord
</a>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
Docs
</a>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
cloudcli.ai
</a>
</div>
{/* Hosted CTA (OSS mode only) */}
{!IS_PLATFORM && (
<div className="rounded-xl border border-primary/10 bg-primary/5 p-4">
<h4 className="text-sm font-medium text-foreground">Try CloudCLI Hosted</h4>
<p className="mt-1 text-xs text-muted-foreground">
Team collaboration, shared MCP configs, settings sync across environments, and managed infrastructure.
</p>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
Learn more
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
{/* Premium feature placeholders (OSS mode only) */}
{!IS_PLATFORM && (
<div className="space-y-4 border-t border-border/50 pt-6">
<h3 className="text-sm font-medium text-foreground">CloudCLI Pro Features</h3>
<PremiumFeatureCard
icon={<Cloud className="h-5 w-5" />}
title="Sync Settings"
description="Keep your preferences, MCP configs, and theme in sync across all your environments."
/>
<PremiumFeatureCard
icon={<Users className="h-5 w-5" />}
title="Team Management"
description="Multiple users, role-based access, and shared projects for your team."
/>
</div>
)}
{/* License */}
<div className="border-t border-border/50 pt-4">
<p className="text-xs text-muted-foreground/60">
Licensed under AGPL-3.0
</p>
</div>
</div>
);
}

View File

@@ -1,8 +1,6 @@
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react'; import { Edit3, Globe, Plus, Server, Terminal, Trash2, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge, Button } from '../../../../../../../shared/view/ui'; import { Badge, Button } from '../../../../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../../../../constants/config';
import PremiumFeatureCard from '../../../../PremiumFeatureCard';
import type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types'; import type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types';
const getTransportIcon = (type: string | undefined) => { const getTransportIcon = (type: string | undefined) => {
@@ -181,14 +179,6 @@ function ClaudeMcpServers({
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div> <div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)} )}
</div> </div>
{!IS_PLATFORM && (
<PremiumFeatureCard
icon={<Users className="h-5 w-5" />}
title="Team MCP Configs"
description="Share MCP server configurations across your team. Everyone stays in sync automatically."
/>
)}
</div> </div>
); );
} }

View File

@@ -1,11 +1,14 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useVersionCheck } from '../../../../../hooks/useVersionCheck';
import { useCredentialsSettings } from '../../../hooks/useCredentialsSettings'; import { useCredentialsSettings } from '../../../hooks/useCredentialsSettings';
import ApiKeysSection from './sections/ApiKeysSection'; import ApiKeysSection from './sections/ApiKeysSection';
import GithubCredentialsSection from './sections/GithubCredentialsSection'; import GithubCredentialsSection from './sections/GithubCredentialsSection';
import NewApiKeyAlert from './sections/NewApiKeyAlert'; import NewApiKeyAlert from './sections/NewApiKeyAlert';
import VersionInfoSection from './sections/VersionInfoSection';
export default function CredentialsSettingsTab() { export default function CredentialsSettingsTab() {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const { const {
apiKeys, apiKeys,
githubCredentials, githubCredentials,
@@ -86,6 +89,12 @@ export default function CredentialsSettingsTab() {
onDeleteGithubCredential={deleteGithubCredential} onDeleteGithubCredential={deleteGithubCredential}
/> />
<VersionInfoSection
currentVersion={currentVersion}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
releaseInfo={releaseInfo}
/>
</div> </div>
); );
} }

View File

@@ -1,29 +1,7 @@
import { ExternalLink, Star, MessageSquare } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../../../../../../constants/config';
import type { ReleaseInfo } from '../../../../../../types/sharedTypes'; import type { ReleaseInfo } from '../../../../../../types/sharedTypes';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
const DOCS_URL = 'https://cloudcli.ai/docs/plugin-overview';
const CLOUDCLI_URL = 'https://cloudcli.ai';
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
function DiscordIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
type VersionInfoSectionProps = { type VersionInfoSectionProps = {
currentVersion: string; currentVersion: string;
updateAvailable: boolean; updateAvailable: boolean;
@@ -38,115 +16,29 @@ export default function VersionInfoSection({
releaseInfo, releaseInfo,
}: VersionInfoSectionProps) { }: VersionInfoSectionProps) {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const releasesUrl = releaseInfo?.htmlUrl || `${GITHUB_REPO_URL}/releases`; const releasesUrl = releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases';
return ( return (
<div className="border-t border-border/50 pt-6"> <div className="border-t border-border/50 pt-6">
{/* About CloudCLI */} <div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
<div className="space-y-4">
{/* Logo + name + version */}
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-primary/90 shadow-sm">
<MessageSquare className="h-4.5 w-4.5 text-primary-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">CloudCLI</span>
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground transition-colors hover:text-foreground"
>
v{currentVersion}
</a>
{updateAvailable && latestVersion && (
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
>
{t('apiKeys.version.updateAvailable', { version: latestVersion })}
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Open-source AI coding assistant interface
</p>
</div>
</div>
{/* Star on GitHub button */}
<a <a
href={GITHUB_REPO_URL} href={releasesUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground" className="transition-colors hover:text-muted-foreground"
> >
<GitHubIcon className="h-4 w-4" /> v{currentVersion}
<Star className="h-3.5 w-3.5" />
<span>Star on GitHub</span>
</a> </a>
{updateAvailable && latestVersion && (
{/* Links */}
<div className="flex flex-wrap gap-3 text-xs">
<a <a
href={GITHUB_REPO_URL} href={releasesUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground" className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-2 py-0.5 font-medium not-italic text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400"
> >
<GitHubIcon className="h-3.5 w-3.5" /> <span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
GitHub <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
<a
href={DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<DiscordIcon className="h-3.5 w-3.5" />
Discord
</a>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
Docs
</a>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
cloudcli.ai
</a>
</div>
{/* Hosted CTA (OSS mode only) */}
{!IS_PLATFORM && (
<div className="rounded-xl border border-primary/10 bg-primary/5 p-4">
<h4 className="text-sm font-medium text-foreground">Try CloudCLI Hosted</h4>
<p className="mt-1 text-xs text-muted-foreground">
Team collaboration, shared MCP configs, settings sync across environments, and managed infrastructure.
</p>
<a
href={CLOUDCLI_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
Learn more
<ExternalLink className="h-3 w-3" />
</a>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -266,7 +266,6 @@ function Sidebar({
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
releaseInfo={releaseInfo} releaseInfo={releaseInfo}
latestVersion={latestVersion} latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={() => setShowVersionModal(true)} onShowVersionModal={() => setShowVersionModal(true)}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
projectListProps={projectListProps} projectListProps={projectListProps}

View File

@@ -1,48 +0,0 @@
import { Star, X } from 'lucide-react';
import { useGitHubStars } from '../../../../hooks/useGitHubStars';
import { IS_PLATFORM } from '../../../../constants/config';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
export default function GitHubStarBadge() {
const { formattedCount, isDismissed, dismiss } = useGitHubStars('siteboon', 'claudecodeui');
if (IS_PLATFORM || isDismissed) return null;
return (
<div className="group/star relative hidden md:block">
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<GitHubIcon className="h-3.5 w-3.5" />
<Star className="h-3 w-3" />
<span className="font-medium">Star</span>
{formattedCount && (
<span className="border-l border-border/50 pl-1.5 tabular-nums">{formattedCount}</span>
)}
</a>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
dismiss();
}}
className="absolute -right-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border border-border/50 bg-muted text-muted-foreground transition-colors hover:text-foreground group-hover/star:flex"
aria-label="Dismiss"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
);
}

View File

@@ -1,8 +1,7 @@
import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react'; import { Settings, Sparkles, PanelLeftOpen } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE'; const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
const GITHUB_ISSUES_URL = 'https://github.com/siteboon/claudecodeui/issues/new';
function DiscordIcon({ className }: { className?: string }) { function DiscordIcon({ className }: { className?: string }) {
return ( return (
@@ -51,18 +50,6 @@ export default function SidebarCollapsed({
<Settings className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" /> <Settings className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</button> </button>
{/* Report Issue */}
<a
href={GITHUB_ISSUES_URL}
target="_blank"
rel="noopener noreferrer"
className="group flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-accent/80"
aria-label={t('actions.reportIssue')}
title={t('actions.reportIssue')}
>
<Bug className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
{/* Discord */} {/* Discord */}
<a <a
href={DISCORD_INVITE_URL} href={DISCORD_INVITE_URL}

View File

@@ -56,7 +56,6 @@ type SidebarContentProps = {
updateAvailable: boolean; updateAvailable: boolean;
releaseInfo: ReleaseInfo | null; releaseInfo: ReleaseInfo | null;
latestVersion: string | null; latestVersion: string | null;
currentVersion: string;
onShowVersionModal: () => void; onShowVersionModal: () => void;
onShowSettings: () => void; onShowSettings: () => void;
projectListProps: SidebarProjectListProps; projectListProps: SidebarProjectListProps;
@@ -84,7 +83,6 @@ export default function SidebarContent({
updateAvailable, updateAvailable,
releaseInfo, releaseInfo,
latestVersion, latestVersion,
currentVersion,
onShowVersionModal, onShowVersionModal,
onShowSettings, onShowSettings,
projectListProps, projectListProps,
@@ -219,7 +217,6 @@ export default function SidebarContent({
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
releaseInfo={releaseInfo} releaseInfo={releaseInfo}
latestVersion={latestVersion} latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={onShowVersionModal} onShowVersionModal={onShowVersionModal}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
t={t} t={t}

View File

@@ -1,11 +1,7 @@
import { Settings, ArrowUpCircle, Bug } from 'lucide-react'; import { Settings, ArrowUpCircle } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { IS_PLATFORM } from '../../../../constants/config';
import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ReleaseInfo } from '../../../../types/sharedTypes';
const GITHUB_ISSUES_URL = 'https://github.com/siteboon/claudecodeui/issues/new';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE'; const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
function DiscordIcon({ className }: { className?: string }) { function DiscordIcon({ className }: { className?: string }) {
@@ -20,7 +16,6 @@ type SidebarFooterProps = {
updateAvailable: boolean; updateAvailable: boolean;
releaseInfo: ReleaseInfo | null; releaseInfo: ReleaseInfo | null;
latestVersion: string | null; latestVersion: string | null;
currentVersion: string;
onShowVersionModal: () => void; onShowVersionModal: () => void;
onShowSettings: () => void; onShowSettings: () => void;
t: TFunction; t: TFunction;
@@ -30,7 +25,6 @@ export default function SidebarFooter({
updateAvailable, updateAvailable,
releaseInfo, releaseInfo,
latestVersion, latestVersion,
currentVersion,
onShowVersionModal, onShowVersionModal,
onShowSettings, onShowSettings,
t, t,
@@ -85,24 +79,11 @@ export default function SidebarFooter({
</> </>
)} )}
{/* Community + Settings */} {/* Discord + Settings */}
<div className="nav-divider" /> <div className="nav-divider" />
{/* Desktop Report Issue */}
<div className="hidden px-2 pt-1.5 md:block">
<a
href={GITHUB_ISSUES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<Bug className="h-3.5 w-3.5" />
<span className="text-sm">{t('actions.reportIssue')}</span>
</a>
</div>
{/* Desktop Discord */} {/* Desktop Discord */}
<div className="hidden px-2 md:block"> <div className="hidden px-2 pt-1.5 md:block">
<a <a
href={DISCORD_INVITE_URL} href={DISCORD_INVITE_URL}
target="_blank" target="_blank"
@@ -125,37 +106,8 @@ export default function SidebarFooter({
</button> </button>
</div> </div>
{/* Desktop version brand line (OSS mode only) */}
{!IS_PLATFORM && (
<div className="hidden px-3 py-2 text-center md:block">
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-muted-foreground/40 transition-colors hover:text-muted-foreground"
>
CloudCLI v{currentVersion} {t('branding.openSource')}
</a>
</div>
)}
{/* Mobile Report Issue */}
<div className="px-3 pt-3 md:hidden">
<a
href={GITHUB_ISSUES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
>
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-background/80">
<Bug className="w-4.5 h-4.5 text-muted-foreground" />
</div>
<span className="text-base font-medium text-foreground">{t('actions.reportIssue')}</span>
</a>
</div>
{/* Mobile Discord */} {/* Mobile Discord */}
<div className="px-3 pt-2 md:hidden"> <div className="px-3 pt-3 md:hidden">
<a <a
href={DISCORD_INVITE_URL} href={DISCORD_INVITE_URL}
target="_blank" target="_blank"

View File

@@ -3,7 +3,6 @@ import type { TFunction } from 'i18next';
import { Button, Input } from '../../../../shared/view/ui'; import { Button, Input } from '../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../constants/config'; import { IS_PLATFORM } from '../../../../constants/config';
import { cn } from '../../../../lib/utils'; import { cn } from '../../../../lib/utils';
import GitHubStarBadge from './GitHubStarBadge';
type SearchMode = 'projects' | 'conversations'; type SearchMode = 'projects' | 'conversations';
@@ -107,8 +106,6 @@ export default function SidebarHeader({
</div> </div>
</div> </div>
<GitHubStarBadge />
{/* Search bar */} {/* Search bar */}
{projectsCount > 0 && !isLoading && ( {projectsCount > 0 && !isLoading && (
<div className="mt-2.5 space-y-2"> <div className="mt-2.5 space-y-2">

View File

@@ -1,77 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
const CACHE_KEY = 'CLOUDCLI_GITHUB_STARS';
const DISMISS_KEY = 'CLOUDCLI_HIDE_GITHUB_STAR';
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
type CachedStars = {
count: number;
timestamp: number;
};
export const useGitHubStars = (owner: string, repo: string) => {
const [starCount, setStarCount] = useState<number | null>(null);
const [isDismissed, setIsDismissed] = useState(() => {
try {
return localStorage.getItem(DISMISS_KEY) === 'true';
} catch {
return false;
}
});
useEffect(() => {
if (isDismissed) return;
// Check cache first
try {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const parsed: CachedStars = JSON.parse(cached);
if (Date.now() - parsed.timestamp < CACHE_TTL) {
setStarCount(parsed.count);
return;
}
}
} catch {
// ignore
}
const fetchStars = async () => {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
if (!response.ok) return;
const data = await response.json();
const count = data.stargazers_count;
if (typeof count === 'number') {
setStarCount(count);
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ count, timestamp: Date.now() }));
} catch {
// ignore
}
}
} catch {
// silent fail
}
};
void fetchStars();
}, [owner, repo, isDismissed]);
const dismiss = useCallback(() => {
setIsDismissed(true);
try {
localStorage.setItem(DISMISS_KEY, 'true');
} catch {
// ignore
}
}, []);
const formattedCount = starCount !== null
? starCount >= 1000
? `${(starCount / 1000).toFixed(1)}k`
: `${starCount}`
: null;
return { starCount, formattedCount, isDismissed, dismiss };
};

View File

@@ -1,7 +1,7 @@
{ {
"login": { "login": {
"title": "Willkommen zurück", "title": "Willkommen zurück",
"description": "Meld dich bei deinem CloudCLI-Konto an", "description": "Meld dich bei deinem Claude Code UI-Konto an",
"username": "Benutzername", "username": "Benutzername",
"password": "Passwort", "password": "Passwort",
"submit": "Anmelden", "submit": "Anmelden",

View File

@@ -84,7 +84,7 @@
"openInEditor": "Im Editor öffnen" "openInEditor": "Im Editor öffnen"
}, },
"mainContent": { "mainContent": {
"loading": "CloudCLI wird geladen", "loading": "Claude Code UI wird geladen",
"settingUpWorkspace": "Arbeitsbereich wird eingerichtet...", "settingUpWorkspace": "Arbeitsbereich wird eingerichtet...",
"chooseProject": "Projekt auswählen", "chooseProject": "Projekt auswählen",
"selectProjectDescription": "Wähl ein Projekt aus der Seitenleiste, um mit Claude zu programmieren. Jedes Projekt enthält deine Chat-Sitzungen und den Dateiverlauf.", "selectProjectDescription": "Wähl ein Projekt aus der Seitenleiste, um mit Claude zu programmieren. Jedes Projekt enthält deine Chat-Sitzungen und den Dateiverlauf.",

View File

@@ -105,8 +105,7 @@
"git": "Git", "git": "Git",
"apiTokens": "API & Token", "apiTokens": "API & Token",
"tasks": "Aufgaben", "tasks": "Aufgaben",
"plugins": "Plugins", "plugins": "Plugins"
"about": "Info"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "Führ Claude CLI in einem Projektverzeichnis aus, um zu beginnen" "runClaudeCli": "Führ Claude CLI in einem Projektverzeichnis aus, um zu beginnen"
}, },
"app": { "app": {
"title": "CloudCLI", "title": "Claude Code UI",
"subtitle": "KI-Programmierassistent-Oberfläche" "subtitle": "KI-Programmierassistent-Oberfläche"
}, },
"sessions": { "sessions": {
@@ -65,12 +65,7 @@
"save": "Speichern", "save": "Speichern",
"delete": "Löschen", "delete": "Löschen",
"rename": "Umbenennen", "rename": "Umbenennen",
"joinCommunity": "Community beitreten", "joinCommunity": "Community beitreten"
"reportIssue": "Problem melden",
"starOnGithub": "Stern auf GitHub"
},
"branding": {
"openSource": "Open Source"
}, },
"status": { "status": {
"active": "Aktiv", "active": "Aktiv",

View File

@@ -1,7 +1,7 @@
{ {
"login": { "login": {
"title": "Welcome Back", "title": "Welcome Back",
"description": "Sign in to your CloudCLI self-hosted account", "description": "Sign in to your Claude Code UI account",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"submit": "Sign In", "submit": "Sign In",

View File

@@ -84,7 +84,7 @@
"openInEditor": "Open in Editor" "openInEditor": "Open in Editor"
}, },
"mainContent": { "mainContent": {
"loading": "Loading CloudCLI", "loading": "Loading Claude Code UI",
"settingUpWorkspace": "Setting up your workspace...", "settingUpWorkspace": "Setting up your workspace...",
"chooseProject": "Choose Your Project", "chooseProject": "Choose Your Project",
"selectProjectDescription": "Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.", "selectProjectDescription": "Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.",

View File

@@ -106,8 +106,8 @@
"apiTokens": "API & Tokens", "apiTokens": "API & Tokens",
"tasks": "Tasks", "tasks": "Tasks",
"notifications": "Notifications", "notifications": "Notifications",
"plugins": "Plugins", "plugins": "Plugins"
"about": "About"
}, },
"notifications": { "notifications": {
"title": "Notifications", "title": "Notifications",

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "Run Claude CLI in a project directory to get started" "runClaudeCli": "Run Claude CLI in a project directory to get started"
}, },
"app": { "app": {
"title": "CloudCLI", "title": "Claude Code UI",
"subtitle": "AI coding assistant interface" "subtitle": "AI coding assistant interface"
}, },
"sessions": { "sessions": {
@@ -65,12 +65,7 @@
"save": "Save", "save": "Save",
"delete": "Delete", "delete": "Delete",
"rename": "Rename", "rename": "Rename",
"joinCommunity": "Join Community", "joinCommunity": "Join Community"
"reportIssue": "Report Issue",
"starOnGithub": "Star on GitHub"
},
"branding": {
"openSource": "Open Source"
}, },
"status": { "status": {
"active": "Active", "active": "Active",

View File

@@ -1,7 +1,7 @@
{ {
"login": { "login": {
"title": "おかえりなさい", "title": "おかえりなさい",
"description": "CloudCLIアカウントにサインイン", "description": "Claude Code UIアカウントにサインイン",
"username": "ユーザー名", "username": "ユーザー名",
"password": "パスワード", "password": "パスワード",
"submit": "サインイン", "submit": "サインイン",

View File

@@ -84,7 +84,7 @@
"openInEditor": "エディタで開く" "openInEditor": "エディタで開く"
}, },
"mainContent": { "mainContent": {
"loading": "CloudCLI を読み込んでいます", "loading": "Claude Code UI を読み込んでいます",
"settingUpWorkspace": "ワークスペースを準備しています...", "settingUpWorkspace": "ワークスペースを準備しています...",
"chooseProject": "プロジェクトを選択", "chooseProject": "プロジェクトを選択",
"selectProjectDescription": "サイドバーからプロジェクトを選択して、Claudeとコーディングを始めましょう。各プロジェクトにはチャットセッションとファイル履歴が含まれています。", "selectProjectDescription": "サイドバーからプロジェクトを選択して、Claudeとコーディングを始めましょう。各プロジェクトにはチャットセッションとファイル履歴が含まれています。",

View File

@@ -106,8 +106,8 @@
"apiTokens": "API & トークン", "apiTokens": "API & トークン",
"tasks": "タスク", "tasks": "タスク",
"notifications": "通知", "notifications": "通知",
"plugins": "プラグイン", "plugins": "プラグイン"
"about": "概要"
}, },
"notifications": { "notifications": {
"title": "通知", "title": "通知",

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "プロジェクトディレクトリでClaude CLIを実行して始めましょう" "runClaudeCli": "プロジェクトディレクトリでClaude CLIを実行して始めましょう"
}, },
"app": { "app": {
"title": "CloudCLI", "title": "Claude Code UI",
"subtitle": "AIコーディングアシスタント" "subtitle": "AIコーディングアシスタント"
}, },
"sessions": { "sessions": {
@@ -64,12 +64,7 @@
"save": "保存", "save": "保存",
"delete": "削除", "delete": "削除",
"rename": "名前の変更", "rename": "名前の変更",
"joinCommunity": "コミュニティに参加", "joinCommunity": "コミュニティに参加"
"reportIssue": "問題を報告",
"starOnGithub": "GitHubでスター"
},
"branding": {
"openSource": "オープンソース"
}, },
"status": { "status": {
"active": "アクティブ", "active": "アクティブ",

View File

@@ -1,7 +1,7 @@
{ {
"login": { "login": {
"title": "다시 오신 것을 환영합니다", "title": "다시 오신 것을 환영합니다",
"description": "CloudCLI 계정에 로그인하세요", "description": "Claude Code UI 계정에 로그인하세요",
"username": "사용자명", "username": "사용자명",
"password": "비밀번호", "password": "비밀번호",
"submit": "로그인", "submit": "로그인",

View File

@@ -84,7 +84,7 @@
"openInEditor": "에디터에서 열기" "openInEditor": "에디터에서 열기"
}, },
"mainContent": { "mainContent": {
"loading": "CloudCLI 로딩 중", "loading": "Claude Code UI 로딩 중",
"settingUpWorkspace": "워크스페이스 설정 중...", "settingUpWorkspace": "워크스페이스 설정 중...",
"chooseProject": "프로젝트 선택", "chooseProject": "프로젝트 선택",
"selectProjectDescription": "사이드바에서 프로젝트를 선택하여 Claude와 코딩을 시작하세요. 각 프로젝트에는 채팅 세션과 파일 히스토리가 포함됩니다.", "selectProjectDescription": "사이드바에서 프로젝트를 선택하여 Claude와 코딩을 시작하세요. 각 프로젝트에는 채팅 세션과 파일 히스토리가 포함됩니다.",

View File

@@ -106,8 +106,8 @@
"apiTokens": "API & 토큰", "apiTokens": "API & 토큰",
"tasks": "작업", "tasks": "작업",
"notifications": "알림", "notifications": "알림",
"plugins": "플러그인", "plugins": "플러그인"
"about": "정보"
}, },
"notifications": { "notifications": {
"title": "알림", "title": "알림",

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "프로젝트 디렉토리에서 Claude CLI를 실행하여 시작하세요" "runClaudeCli": "프로젝트 디렉토리에서 Claude CLI를 실행하여 시작하세요"
}, },
"app": { "app": {
"title": "CloudCLI", "title": "Claude Code UI",
"subtitle": "AI 코딩 어시스턴트 UI" "subtitle": "AI 코딩 어시스턴트 UI"
}, },
"sessions": { "sessions": {
@@ -64,12 +64,7 @@
"save": "저장", "save": "저장",
"delete": "삭제", "delete": "삭제",
"rename": "이름 변경", "rename": "이름 변경",
"joinCommunity": "커뮤니티 참여", "joinCommunity": "커뮤니티 참여"
"reportIssue": "문제 신고",
"starOnGithub": "GitHub에서 스타"
},
"branding": {
"openSource": "오픈 소스"
}, },
"status": { "status": {
"active": "활성", "active": "활성",

View File

@@ -1,7 +1,7 @@
{ {
"login": { "login": {
"title": "Добро пожаловать", "title": "Добро пожаловать",
"description": "Войдите в свой аккаунт CloudCLI", "description": "Войдите в свой аккаунт Claude Code UI",
"username": "Имя пользователя", "username": "Имя пользователя",
"password": "Пароль", "password": "Пароль",
"submit": "Войти", "submit": "Войти",

View File

@@ -84,7 +84,7 @@
"openInEditor": "Открыть в редакторе" "openInEditor": "Открыть в редакторе"
}, },
"mainContent": { "mainContent": {
"loading": "Загрузка CloudCLI", "loading": "Загрузка Claude Code UI",
"settingUpWorkspace": "Настройка рабочего пространства...", "settingUpWorkspace": "Настройка рабочего пространства...",
"chooseProject": "Выберите проект", "chooseProject": "Выберите проект",
"selectProjectDescription": "Выберите проект на боковой панели, чтобы начать работу с Claude. Каждый проект содержит ваши сеансы чата и историю файлов.", "selectProjectDescription": "Выберите проект на боковой панели, чтобы начать работу с Claude. Каждый проект содержит ваши сеансы чата и историю файлов.",

View File

@@ -105,8 +105,7 @@
"git": "Git", "git": "Git",
"apiTokens": "API и токены", "apiTokens": "API и токены",
"tasks": "Задачи", "tasks": "Задачи",
"plugins": "Плагины", "plugins": "Плагины"
"about": "О программе"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "Запустите Claude CLI в каталоге проекта для начала работы" "runClaudeCli": "Запустите Claude CLI в каталоге проекта для начала работы"
}, },
"app": { "app": {
"title": "CloudCLI", "title": "Claude Code UI",
"subtitle": "Интерфейс AI помощника для программирования" "subtitle": "Интерфейс AI помощника для программирования"
}, },
"sessions": { "sessions": {
@@ -65,12 +65,7 @@
"save": "Сохранить", "save": "Сохранить",
"delete": "Удалить", "delete": "Удалить",
"rename": "Переименовать", "rename": "Переименовать",
"joinCommunity": "Присоединиться к сообществу", "joinCommunity": "Присоединиться к сообществу"
"reportIssue": "Сообщить о проблеме",
"starOnGithub": "Звезда на GitHub"
},
"branding": {
"openSource": "Открытый исходный код"
}, },
"status": { "status": {
"active": "Активен", "active": "Активен",

View File

@@ -1,7 +1,7 @@
{ {
"login": { "login": {
"title": "欢迎回来", "title": "欢迎回来",
"description": "登录您的 CloudCLI 账户", "description": "登录您的 Claude Code UI 账户",
"username": "用户名", "username": "用户名",
"password": "密码", "password": "密码",
"submit": "登录", "submit": "登录",

View File

@@ -84,7 +84,7 @@
"openInEditor": "在编辑器中打开" "openInEditor": "在编辑器中打开"
}, },
"mainContent": { "mainContent": {
"loading": "正在加载 CloudCLI", "loading": "正在加载 Claude Code UI",
"settingUpWorkspace": "正在设置您的工作空间...", "settingUpWorkspace": "正在设置您的工作空间...",
"chooseProject": "选择您的项目", "chooseProject": "选择您的项目",
"selectProjectDescription": "从侧边栏选择一个项目以开始使用 Claude 进行编程。每个项目包含您的聊天会话和文件历史。", "selectProjectDescription": "从侧边栏选择一个项目以开始使用 Claude 进行编程。每个项目包含您的聊天会话和文件历史。",

View File

@@ -106,8 +106,8 @@
"apiTokens": "API 和令牌", "apiTokens": "API 和令牌",
"tasks": "任务", "tasks": "任务",
"notifications": "通知", "notifications": "通知",
"plugins": "插件", "plugins": "插件"
"about": "关于"
}, },
"notifications": { "notifications": {
"title": "通知", "title": "通知",

View File

@@ -20,7 +20,7 @@
"runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用" "runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用"
}, },
"app": { "app": {
"title": "CloudCLI", "title": "Claude Code UI",
"subtitle": "AI 编程助手" "subtitle": "AI 编程助手"
}, },
"sessions": { "sessions": {
@@ -65,12 +65,7 @@
"save": "保存", "save": "保存",
"delete": "删除", "delete": "删除",
"rename": "重命名", "rename": "重命名",
"joinCommunity": "加入社区", "joinCommunity": "加入社区"
"reportIssue": "报告问题",
"starOnGithub": "在GitHub上加星"
},
"branding": {
"openSource": "开源"
}, },
"status": { "status": {
"active": "活动", "active": "活动",

View File

@@ -46,7 +46,9 @@ export interface NormalizedMessage {
toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null; toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null;
isError?: boolean; isError?: boolean;
text?: string; text?: string;
tokens?: number;
canInterrupt?: boolean; canInterrupt?: boolean;
tokenBudget?: unknown;
requestId?: string; requestId?: string;
input?: unknown; input?: unknown;
context?: unknown; context?: unknown;
@@ -79,6 +81,7 @@ export interface SessionSlot {
total: number; total: number;
hasMore: boolean; hasMore: boolean;
offset: number; offset: number;
tokenUsage: unknown;
} }
const EMPTY: NormalizedMessage[] = []; const EMPTY: NormalizedMessage[] = [];
@@ -95,6 +98,7 @@ function createEmptySlot(): SessionSlot {
total: 0, total: 0,
hasMore: false, hasMore: false,
offset: 0, offset: 0,
tokenUsage: null,
}; };
} }
@@ -204,6 +208,9 @@ export function useSessionStore() {
slot.fetchedAt = Date.now(); slot.fetchedAt = Date.now();
slot.status = 'idle'; slot.status = 'idle';
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
if (data.tokenUsage) {
slot.tokenUsage = data.tokenUsage;
}
notify(sessionId); notify(sessionId);
return slot; return slot;