Compare commits

..

2 Commits

Author SHA1 Message Date
Haileyesus
50b3b90235 feat(cursor): update fallback models 2026-05-28 20:54:16 +03:00
Haileyesus
dd6614bca3 fix: remove the hide cursor on windows logic 2026-05-28 20:50:46 +03:00
50 changed files with 1250 additions and 1461 deletions

View File

@@ -3,25 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### New Features
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
### Bug Fixes
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
### Styling
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13) ## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
### Bug Fixes ### Bug Fixes

792
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.33.0", "version": "1.32.0",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "dist-server/server/index.js", "main": "dist-server/server/index.js",
@@ -67,7 +67,7 @@
"author": "CloudCLI UI Contributors", "author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.165", "@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
@@ -96,7 +96,6 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",

View File

@@ -11,7 +11,7 @@ export const CLAUDE_MODELS = {
{ {
value: "default", value: "default",
label: "Default (recommended)", label: "Default (recommended)",
description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok", description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
}, },
{ {
value: "sonnet", value: "sonnet",

View File

@@ -285,68 +285,43 @@ function transformMessage(sdkMessage) {
return sdkMessage; return sdkMessage;
} }
function readNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/** /**
* Extracts token usage from SDK messages. * Extracts token usage from SDK result messages
* Prefers per-step `message.usage` (Claude message payload), then falls back * @param {Object} resultMessage - SDK result message
* to result-level usage/modelUsage for compatibility across SDK versions.
* @param {Object} sdkMessage - SDK stream message
* @returns {Object|null} Token budget object or null * @returns {Object|null} Token budget object or null
*/ */
function extractTokenBudget(sdkMessage) { function extractTokenBudget(resultMessage) {
if (!sdkMessage || typeof sdkMessage !== 'object') { if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
return null; return null;
} }
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage; // Get the first model's usage data
if (messageUsage && typeof messageUsage === 'object') { const modelKey = Object.keys(resultMessage.modelUsage)[0];
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens); const modelData = resultMessage.modelUsage[modelKey];
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
return { if (!modelData) {
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
return null; return null;
} }
// Fallback for older SDK messages with only modelUsage // Use cumulative tokens if available (tracks total for the session)
const modelKey = Object.keys(sdkMessage.modelUsage)[0]; // Otherwise fall back to per-request tokens
const modelData = sdkMessage.modelUsage[modelKey]; 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;
if (!modelData || typeof modelData !== 'object') { // Total used = input + output + cache tokens
return null; const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
}
const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens); // Use configured context window budget from environment (default 160000)
const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens); // This is the user's budget limit, not the model's context window
const totalUsed = inputTokens + outputTokens; const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
// Token calc logged via token-budget WS event
return { return {
used: totalUsed, used: totalUsed,
total: contextWindow, total: contextWindow
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
}; };
} }
@@ -709,10 +684,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
ws.send(msg); ws.send(msg);
} }
// Extract and send token budget updates from assistant/result usage payloads // Extract and send token budget updates from result messages
const tokenBudgetData = extractTokenBudget(message); if (message.type === 'result') {
if (tokenBudgetData) { const models = Object.keys(message.modelUsage || {});
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); 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' }));
}
} }
} }

View File

@@ -1,32 +1,5 @@
// Gemini Response Handler - JSON Stream processing // Gemini Response Handler - JSON Stream processing
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
function buildGeminiTokenBudget(tokens) {
if (!tokens || typeof tokens !== 'object') {
return null;
}
const parsedInputTokens = Number(tokens.input);
const parsedOutputTokens = Number(tokens.output);
const inputTokens = Number.isFinite(parsedInputTokens) ? parsedInputTokens : 0;
const outputTokens = Number.isFinite(parsedOutputTokens) ? parsedOutputTokens : 0;
const parsedUsed = Number(tokens.total);
const used = Number.isFinite(parsedUsed) ? parsedUsed : inputTokens + outputTokens;
if (!Number.isFinite(used) || used <= 0) {
return null;
}
return {
used,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
class GeminiResponseHandler { class GeminiResponseHandler {
constructor(ws, options = {}) { constructor(ws, options = {}) {
@@ -87,17 +60,6 @@ class GeminiResponseHandler {
for (const msg of normalized) { for (const msg of normalized) {
this.ws.send(msg); this.ws.send(msg);
} }
const tokenBudget = buildGeminiTokenBudget(event.tokens);
if (tokenBudget) {
this.ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget,
sessionId: sid,
provider: 'gemini',
}));
}
} }
forceFlush() { forceFlush() {

View File

@@ -10,9 +10,8 @@ import { spawn } from 'child_process';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import mime from 'mime-types'; import mime from 'mime-types';
import Database from 'better-sqlite3';
import { AppError, WORKSPACES_ROOT, getOpenCodeDatabasePath, validateWorkspacePath } from '@/shared/utils.js'; import { AppError, WORKSPACES_ROOT, validateWorkspacePath } from '@/shared/utils.js';
import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js'; import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js';
import { createWebSocketServer } from '@/modules/websocket/index.js'; import { createWebSocketServer } from '@/modules/websocket/index.js';
@@ -73,7 +72,7 @@ import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js'; import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js'; import providerRoutes from './modules/providers/provider.routes.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js'; import { initializeDatabase, projectsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js'; import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js'; import { IS_PLATFORM } from './constants/config.js';
@@ -1142,127 +1141,33 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
return res.json({ return res.json({
used: 0, used: 0,
total: 0, total: 0,
inputTokens: 0, breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true, unsupported: true,
message: 'Token usage tracking not available for Cursor sessions' message: 'Token usage tracking not available for Cursor sessions'
}); });
} }
// Handle Gemini sessions - they are raw logs in our current setup
if (provider === 'gemini') { if (provider === 'gemini') {
const session = sessionsDb.getSessionById(safeSessionId);
const sessionFilePath = session?.jsonl_path;
if (!sessionFilePath) {
return res.json({
used: 0,
inputTokens: 0,
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true,
message: 'Token usage tracking not available for this Gemini session'
});
}
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 inputTokens = 0;
let outputTokens = 0;
let totalTokens = 0;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (!entry.tokens || typeof entry.tokens !== 'object') {
continue;
}
inputTokens = Number(entry.tokens.input || 0);
outputTokens = Number(entry.tokens.output || 0);
totalTokens = Number(entry.tokens.total || inputTokens + outputTokens || 0);
break;
} catch {
continue;
}
}
return res.json({ return res.json({
used: totalTokens, used: 0,
inputTokens, total: 0,
outputTokens, breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
breakdown: { unsupported: true,
input: inputTokens, message: 'Token usage tracking not available for Gemini sessions'
output: outputTokens
}
}); });
} }
// OpenCode token totals are surfaced through provider history reads.
// This legacy endpoint only knows file-backed session formats.
if (provider === 'opencode') { if (provider === 'opencode') {
const dbPath = getOpenCodeDatabasePath(); return res.json({
if (!fs.existsSync(dbPath)) { used: 0,
return res.status(404).json({ error: 'OpenCode database not found' }); total: 0,
} breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
const db = new Database(dbPath, { readonly: true, fileMustExist: true }); message: 'Token usage tracking is available in OpenCode session history, not this legacy endpoint'
try { });
const columns = db.prepare('PRAGMA table_info(session)').all();
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return res.json({
used: 0,
inputTokens: 0,
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true,
message: 'Token usage tracking is not available in this OpenCode database schema'
});
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(safeSessionId);
if (!row) {
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
}
const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
const outputTokens = Number(row.outputTokens || 0);
const totalUsed = Number(row.inputTokens || 0)
+ outputTokens
+ Number(row.reasoningTokens || 0)
+ Number(row.cacheReadTokens || 0)
+ Number(row.cacheWriteTokens || 0);
return res.json({
used: totalUsed,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens
}
});
} finally {
db.close();
}
} }
// Handle Codex sessions // Handle Codex sessions
@@ -1305,8 +1210,6 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
throw error; throw error;
} }
const lines = fileContent.trim().split('\n'); const lines = fileContent.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let totalTokens = 0; let totalTokens = 0;
let contextWindow = 200000; // Default for Codex/OpenAI let contextWindow = 200000; // Default for Codex/OpenAI
@@ -1319,9 +1222,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const tokenInfo = entry.payload.info; const tokenInfo = entry.payload.info;
if (tokenInfo.total_token_usage) { if (tokenInfo.total_token_usage) {
inputTokens = tokenInfo.total_token_usage.input_tokens || 0; totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
outputTokens = tokenInfo.total_token_usage.output_tokens || 0;
totalTokens = tokenInfo.total_token_usage.total_tokens || inputTokens + outputTokens;
} }
if (tokenInfo.model_context_window) { if (tokenInfo.model_context_window) {
contextWindow = tokenInfo.model_context_window; contextWindow = tokenInfo.model_context_window;
@@ -1336,13 +1237,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
return res.json({ return res.json({
used: totalTokens, used: totalTokens,
total: contextWindow, total: contextWindow
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens
}
}); });
} }
@@ -1385,7 +1280,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000; const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0; let inputTokens = 0;
let outputTokens = 0; let cacheCreationTokens = 0;
let cacheReadTokens = 0;
// Find the latest assistant message with usage data (scan from end) // Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) { for (let i = lines.length - 1; i >= 0; i--) {
@@ -1398,7 +1294,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
// Use token counts from latest assistant message only // Use token counts from latest assistant message only
inputTokens = usage.input_tokens || 0; inputTokens = usage.input_tokens || 0;
outputTokens = usage.output_tokens || 0; cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break; // Stop after finding the latest assistant message break; // Stop after finding the latest assistant message
} }
@@ -1408,16 +1305,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
} }
} }
const totalUsed = inputTokens + outputTokens; // Calculate total context usage (excluding output_tokens, as per ccusage)
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
res.json({ res.json({
used: totalUsed, used: totalUsed,
total: contextWindow, total: contextWindow,
inputTokens,
outputTokens,
breakdown: { breakdown: {
input: inputTokens, input: inputTokens,
output: outputTokens cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
} }
}); });
} catch (error) { } catch (error) {

View File

@@ -16,10 +16,6 @@ type ClaudeCredentialsStatus = {
error?: string; error?: string;
}; };
const hasErrorCode = (error: unknown, code: string): boolean => (
error instanceof Error && 'code' in error && error.code === code
);
export class ClaudeProviderAuth implements IProviderAuth { export class ClaudeProviderAuth implements IProviderAuth {
/** /**
* Checks whether the Claude Code CLI is available on this host. * Checks whether the Claude Code CLI is available on this host.
@@ -81,12 +77,6 @@ export class ClaudeProviderAuth implements IProviderAuth {
* Checks Claude credentials in the same priority order used by Claude Code. * Checks Claude credentials in the same priority order used by Claude Code.
*/ */
private async checkCredentials(): Promise<ClaudeCredentialsStatus> { private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
}
if (process.env.ANTHROPIC_API_KEY?.trim()) { if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
} }
@@ -120,33 +110,15 @@ export class ClaudeProviderAuth implements IProviderAuth {
return { return {
authenticated: false, authenticated: false,
email: null, email,
method: null, method: 'credentials_file',
error: 'Claude login has expired. Run claude /login again.', error: 'OAuth token has expired. Please re-authenticate with claude login',
}; };
} }
return { return { authenticated: false, email: null, method: null };
authenticated: false, } catch {
email: null, return { authenticated: false, email: null, method: null };
method: null,
error: missingCredentialsError,
};
} catch (error) {
let errorMessage = 'Unable to read Claude credentials. Run claude /login again.';
if (hasErrorCode(error, 'ENOENT')) {
errorMessage = missingCredentialsError;
} else if (error instanceof SyntaxError) {
errorMessage = 'Claude credentials are unreadable. Run claude /login again.';
}
return {
authenticated: false,
email: null,
method: null,
error: errorMessage,
};
} }
} }
} }

View File

@@ -1,10 +1,14 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
import type { IProviderModels } from '@/shared/interfaces.js'; import type { IProviderModels } from '@/shared/interfaces.js';
import type { import type {
ProviderChangeActiveModelInput, ProviderChangeActiveModelInput,
ProviderCurrentActiveModel, ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition, ProviderModelsDefinition,
ProviderSessionActiveModelChange, ProviderSessionActiveModelChange,
} from '@/shared/types.js'; } from '@/shared/types.js';
@@ -15,29 +19,17 @@ import {
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [ OPTIONS: [
{ { value: 'default', label: 'Default (recommended)' },
value: 'default', { value: 'sonnet[1m]', label: 'Sonnet (1M context)' },
label: 'Default (recommended)', { value: 'opus', label: 'Opus' },
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok', { value: 'opus[1m]', label: 'Opus (1M context)' },
}, { value: 'haiku', label: 'Haiku' },
{ { value: 'sonnet', label: 'sonnet' },
value: 'sonnet',
label: 'Sonnet',
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
},
{
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
},
{
value: 'haiku',
label: 'Haiku',
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
},
], ],
DEFAULT: 'default', DEFAULT: 'default',
}; };
type ClaudeModelQueryOptions = Pick<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
type ClaudeInitEvent = { type ClaudeInitEvent = {
sessionId?: string; sessionId?: string;
session_id?: string; session_id?: string;
@@ -57,6 +49,46 @@ const ANSI_PATTERN = new RegExp(
'g', 'g',
); );
const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({
env: { ...process.env },
pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH),
permissionMode: 'default',
});
const mapClaudeModel = (model: ModelInfo): ProviderModelOption => ({
value: model.value,
label: model.displayName || model.value,
description: model.description || undefined,
});
const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = [];
const seenValues = new Set<string>();
for (const model of models) {
const mappedModel = mapClaudeModel(model);
if (seenValues.has(mappedModel.value)) {
continue;
}
seenValues.add(mappedModel.value);
options.push(mappedModel);
}
if (options.length === 0) {
return CLAUDE_FALLBACK_MODELS;
}
const defaultValue = options.find((option) => option.value === 'default')?.value
?? options[0]?.value
?? CLAUDE_FALLBACK_MODELS.DEFAULT;
return {
OPTIONS: options,
DEFAULT: defaultValue,
};
};
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => { const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
const eventSessionId = event.sessionId ?? event.session_id; const eventSessionId = event.sessionId ?? event.session_id;
if (eventSessionId && eventSessionId !== sessionId) { if (eventSessionId && eventSessionId !== sessionId) {
@@ -149,18 +181,25 @@ const readClaudeSessionModelFromJsonl = async (
export class ClaudeProviderModels implements IProviderModels { export class ClaudeProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> { async getSupportedModels(): Promise<ProviderModelsDefinition> {
// claude creates a new jsonl file as a separate session for this request. let queryInstance: ReturnType<typeof query> | null = null;
// As a result, it lists the workspace where this is invoked when it shouldn't.
// try {
// Disabled for now: // The SDK exposes its runtime model catalog on the initialized query
// const queryInstance = query({ // instance, so we create a lightweight query and immediately close it
// prompt: 'Get supported models', // after reading the control-plane metadata.
// options: buildClaudeQueryOptions(), queryInstance = query({
// }); prompt: 'Get supported models',
// const supportedModels = await queryInstance.supportedModels(); options: buildClaudeQueryOptions(),
// queryInstance.close(); });
// return buildClaudeModelsDefinition(supportedModels);
return CLAUDE_FALLBACK_MODELS; const supportedModels = await queryInstance.supportedModels();
return buildClaudeModelsDefinition(supportedModels);
} catch {
return CLAUDE_FALLBACK_MODELS;
} finally {
queryInstance?.close();
}
} }
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> { async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {

View File

@@ -88,15 +88,22 @@ function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
const record = tokens as AnyRecord; const record = tokens as AnyRecord;
const input = Number(record.input || 0); const input = Number(record.input || 0);
const output = Number(record.output || 0); const output = Number(record.output || 0);
const total = Number(record.total || input + output || 0); const cached = Number(record.cached || 0);
const thoughts = Number(record.thoughts || 0);
const tool = Number(record.tool || 0);
const totalFromFields = input + output + cached + thoughts + tool;
const total = Number(record.total || totalFromFields || 0);
return { return {
used: total, used: total,
inputTokens: input, total: total,
outputTokens: output,
breakdown: { breakdown: {
input, input,
output, output,
cached,
thoughts,
tool,
}, },
}; };
} }

View File

@@ -28,9 +28,9 @@ type OpenCodeHistoryRow = {
type OpenCodeTokenTotals = { type OpenCodeTokenTotals = {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
reasoningTokens: number;
cacheReadTokens: number; cacheReadTokens: number;
cacheWriteTokens: number; cacheCreationTokens: number;
reasoningTokens: number;
}; };
const openOpenCodeDatabase = (): Database.Database | null => { const openOpenCodeDatabase = (): Database.Database | null => {
@@ -106,13 +106,11 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
} }
const inputTokens = totals.inputTokens; const inputTokens = totals.inputTokens;
const displayInputTokens = inputTokens + totals.cacheReadTokens;
const outputTokens = totals.outputTokens; const outputTokens = totals.outputTokens;
const used = inputTokens const cacheReadTokens = totals.cacheReadTokens;
+ outputTokens const cacheCreationTokens = totals.cacheCreationTokens;
+ totals.reasoningTokens const reasoningTokens = totals.reasoningTokens;
+ totals.cacheReadTokens const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
+ totals.cacheWriteTokens;
if (used <= 0) { if (used <= 0) {
return undefined; return undefined;
@@ -120,50 +118,14 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
return { return {
used, used,
inputTokens: displayInputTokens, total: used,
inputTokens,
outputTokens, outputTokens,
breakdown: { cacheReadTokens,
input: displayInputTokens, cacheCreationTokens,
output: outputTokens,
},
}; };
}; };
const readOpenCodeSessionColumnTokenUsage = (
db: Database.Database,
sessionId: string,
): AnyRecord | undefined => {
const columns = db.prepare('PRAGMA table_info(session)').all() as { name: string }[];
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return undefined;
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(sessionId) as OpenCodeTokenTotals | undefined;
if (!row) {
return undefined;
}
return buildTokenUsage({
inputTokens: Number(row.inputTokens ?? 0),
outputTokens: Number(row.outputTokens ?? 0),
reasoningTokens: Number(row.reasoningTokens ?? 0),
cacheReadTokens: Number(row.cacheReadTokens ?? 0),
cacheWriteTokens: Number(row.cacheWriteTokens ?? 0),
});
};
/** /**
* OpenCode stores per-message token counts on assistant `message.data` objects * OpenCode stores per-message token counts on assistant `message.data` objects
* (see MessageV2.Assistant). Older DBs also had session-level counters; this * (see MessageV2.Assistant). Older DBs also had session-level counters; this
@@ -173,18 +135,13 @@ const aggregateOpenCodeSessionTokenUsage = (
db: Database.Database, db: Database.Database,
sessionId: string, sessionId: string,
): AnyRecord | undefined => { ): AnyRecord | undefined => {
const sessionColumnUsage = readOpenCodeSessionColumnTokenUsage(db, sessionId);
if (sessionColumnUsage) {
return sessionColumnUsage;
}
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[]; const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
let inputTokens = 0; let inputTokens = 0;
let outputTokens = 0; let outputTokens = 0;
let reasoningTokens = 0;
let cacheReadTokens = 0; let cacheReadTokens = 0;
let cacheWriteTokens = 0; let cacheCreationTokens = 0;
let reasoningTokens = 0;
for (const row of rows) { for (const row of rows) {
const info = readJsonRecord(row.data); const info = readJsonRecord(row.data);
@@ -202,15 +159,15 @@ const aggregateOpenCodeSessionTokenUsage = (
reasoningTokens += Number(tokens.reasoning ?? 0); reasoningTokens += Number(tokens.reasoning ?? 0);
const cache = readObjectRecord(tokens.cache); const cache = readObjectRecord(tokens.cache);
cacheReadTokens += Number(cache?.read ?? 0); cacheReadTokens += Number(cache?.read ?? 0);
cacheWriteTokens += Number(cache?.write ?? 0); cacheCreationTokens += Number(cache?.write ?? 0);
} }
return buildTokenUsage({ return buildTokenUsage({
inputTokens, inputTokens,
outputTokens, outputTokens,
reasoningTokens,
cacheReadTokens, cacheReadTokens,
cacheWriteTokens, cacheCreationTokens,
reasoningTokens,
}); });
}; };

View File

@@ -85,12 +85,6 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
path TEXT, path TEXT,
agent TEXT, agent TEXT,
model TEXT, model TEXT,
cost REAL NOT NULL DEFAULT 0,
tokens_input INTEGER NOT NULL DEFAULT 0,
tokens_output INTEGER NOT NULL DEFAULT 0,
tokens_reasoning INTEGER NOT NULL DEFAULT 0,
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
tokens_cache_write INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
); );
@@ -130,10 +124,9 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
); );
db.prepare(` db.prepare(`
INSERT INTO session ( INSERT INTO session (
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived, id, project_id, slug, directory, title, version, time_created, time_updated, time_archived
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
'open-session-1', 'open-session-1',
'project-1', 'project-1',
@@ -144,11 +137,6 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
1_700_000_000_000, 1_700_000_000_000,
1_700_000_004_000, 1_700_000_004_000,
null, null,
10,
20,
7,
3,
2,
); );
const userMessageData = JSON.stringify({ const userMessageData = JSON.stringify({
@@ -314,13 +302,12 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
assert.equal(history.messages[3]?.kind, 'tool_use'); assert.equal(history.messages[3]?.kind, 'tool_use');
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false }); assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
assert.deepEqual(history.tokenUsage, { assert.deepEqual(history.tokenUsage, {
used: 42, used: 35,
inputTokens: 13, total: 35,
inputTokens: 10,
outputTokens: 20, outputTokens: 20,
breakdown: { cacheReadTokens: 3,
input: 13, cacheCreationTokens: 2,
output: 20,
},
}); });
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 }); const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });

View File

@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`); console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
}); });
upstream.on('message', (data, isBinary) => { upstream.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) { if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data, { binary: isBinary }); clientWs.send(data);
} }
}); });
clientWs.on('message', (data, isBinary) => { clientWs.on('message', (data) => {
if (upstream.readyState === WebSocket.OPEN) { if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data, { binary: isBinary }); upstream.send(data);
} }
}); });

View File

@@ -18,7 +18,6 @@ type ShellIncomingMessage = {
provider?: string; provider?: string;
initialCommand?: string; initialCommand?: string;
isPlainShell?: boolean; isPlainShell?: boolean;
forceRestart?: boolean;
}; };
type PtySessionEntry = { type PtySessionEntry = {
@@ -181,7 +180,6 @@ export function handleShellConnection(
const hasSession = readBoolean(data.hasSession); const hasSession = readBoolean(data.hasSession);
const provider = readString(data.provider, 'claude'); const provider = readString(data.provider, 'claude');
const initialCommand = readString(data.initialCommand); const initialCommand = readString(data.initialCommand);
const forceRestart = readBoolean(data.forceRestart);
const isPlainShell = const isPlainShell =
readBoolean(data.isPlainShell) || readBoolean(data.isPlainShell) ||
(!!initialCommand && !hasSession) || (!!initialCommand && !hasSession) ||
@@ -202,7 +200,7 @@ export function handleShellConnection(
: ''; : '';
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`; ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
if (isLoginCommand || forceRestart) { if (isLoginCommand) {
const oldSession = ptySessionsMap.get(ptySessionKey); const oldSession = ptySessionsMap.get(ptySessionKey);
if (oldSession) { if (oldSession) {
if (oldSession.timeoutId) { if (oldSession.timeoutId) {
@@ -213,8 +211,7 @@ export function handleShellConnection(
} }
} }
const existingSession = const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
if (existingSession) { if (existingSession) {
shellProcess = existingSession.pty; shellProcess = existingSession.pty;
if (existingSession.timeoutId) { if (existingSession.timeoutId) {
@@ -371,10 +368,6 @@ export function handleShellConnection(
} }
const session = ptySessionsMap.get(ptySessionKey); const session = ptySessionsMap.get(ptySessionKey);
if (session && session.pty !== shellProcess) {
return;
}
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send( session.ws.send(
JSON.stringify({ JSON.stringify({
@@ -458,10 +451,6 @@ export function handleShellConnection(
session.ws = null; session.ws = null;
session.timeoutId = setTimeout(() => { session.timeoutId = setTimeout(() => {
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
return;
}
session.pty.kill(); session.pty.kill();
ptySessionsMap.delete(ptySessionKey as string); ptySessionsMap.delete(ptySessionKey as string);
}, PTY_SESSION_TIMEOUT); }, PTY_SESSION_TIMEOUT);

View File

@@ -31,24 +31,6 @@ export function createWebSocketServer(
}); });
wss.on('connection', (ws, request) => { wss.on('connection', (ws, request) => {
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
// are silently torn down even when the UI is active, causing repeated
// reconnect cycles. ws library heartbeat is opt-in.
const HEARTBEAT_INTERVAL_MS = 30_000;
const heartbeat = setInterval(() => {
if (ws.readyState === ws.OPEN) {
try {
ws.ping();
} catch {
// socket may have been closed concurrently — interval will be cleared below
}
}
}, HEARTBEAT_INTERVAL_MS);
const stopHeartbeat = () => clearInterval(heartbeat);
ws.on('close', stopHeartbeat);
ws.on('error', stopHeartbeat);
const incomingRequest = request as AuthenticatedWebSocketRequest; const incomingRequest = request as AuthenticatedWebSocketRequest;
const url = incomingRequest.url ?? '/'; const url = incomingRequest.url ?? '/';
const pathname = new URL(url, 'http://localhost').pathname; const pathname = new URL(url, 'http://localhost').pathname;

View File

@@ -23,34 +23,6 @@ import { createNormalizedMessage } from './shared/utils.js';
// Track active sessions // Track active sessions
const activeCodexSessions = new Map(); const activeCodexSessions = new Map();
function readUsageNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function extractCodexTokenBudget(event) {
const info = event?.info || event?.payload?.info || event?.usage?.info;
const usage = info?.total_token_usage || event?.usage?.total_token_usage || event?.usage;
if (!usage || typeof usage !== 'object') {
return null;
}
const inputTokens = readUsageNumber(usage.input_tokens);
const outputTokens = readUsageNumber(usage.output_tokens);
const used = readUsageNumber(usage.total_tokens) || inputTokens + outputTokens;
return {
used,
total: readUsageNumber(info?.model_context_window || event?.usage?.model_context_window) || 200000,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
/** /**
* Transform Codex SDK event to WebSocket message format * Transform Codex SDK event to WebSocket message format
* @param {object} event - SDK event * @param {object} event - SDK event
@@ -344,11 +316,9 @@ export async function queryCodex(command, options = {}, ws) {
} }
// Extract and send token usage if available (normalized to match Claude format) // Extract and send token usage if available (normalized to match Claude format)
if (event.type === 'turn.completed') { if (event.type === 'turn.completed' && event.usage) {
const tokenBudget = extractCodexTokenBudget(event); const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
if (tokenBudget) { sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
}
} }
} }

View File

@@ -1,14 +1,12 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import fsSync from 'node:fs';
import crossSpawn from 'cross-spawn'; import crossSpawn from 'cross-spawn';
import Database from 'better-sqlite3';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js'; import { createNormalizedMessage } from './shared/utils.js';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -22,66 +20,6 @@ function readOpenCodeSessionId(event) {
return event.sessionID || event.sessionId || null; return event.sessionID || event.sessionId || null;
} }
function readOpenCodeTokenUsage(sessionId) {
const dbPath = getOpenCodeDatabasePath();
if (!sessionId || !fsSync.existsSync(dbPath)) {
return null;
}
let db = null;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
const columns = db.prepare('PRAGMA table_info(session)').all();
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return null;
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(sessionId);
if (!row) {
return null;
}
const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
const outputTokens = Number(row.outputTokens || 0);
const used = Number(row.inputTokens || 0)
+ outputTokens
+ Number(row.reasoningTokens || 0)
+ Number(row.cacheReadTokens || 0)
+ Number(row.cacheWriteTokens || 0);
if (used <= 0) {
return null;
}
return {
used,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
} catch {
return null;
} finally {
if (db) {
db.close();
}
}
}
async function spawnOpenCode(command, options = {}, ws) { async function spawnOpenCode(command, options = {}, ws) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { sessionId, projectPath, cwd, model, sessionSummary } = options; const { sessionId, projectPath, cwd, model, sessionSummary } = options;
@@ -245,17 +183,6 @@ async function spawnOpenCode(command, options = {}, ws) {
stdoutLineBuffer = ''; stdoutLineBuffer = '';
} }
const tokenBudget = readOpenCodeTokenUsage(finalSessionId);
if (tokenBudget) {
ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget,
sessionId: finalSessionId,
provider: 'opencode',
}));
}
ws.send(createNormalizedMessage({ ws.send(createNormalizedMessage({
kind: 'complete', kind: 'complete',
exitCode: code, exitCode: code,

View File

@@ -174,7 +174,7 @@ const builtInCommands = [
}, },
{ {
name: "/cost", name: "/cost",
description: "Display token usage information", description: "Display token usage and cost information",
namespace: "builtin", namespace: "builtin",
metadata: { type: "builtin" }, metadata: { type: "builtin" },
}, },
@@ -258,7 +258,7 @@ Custom commands can be created in:
const catalog = (await providerModelsService.getProviderModels(provider)).models; const catalog = (await providerModelsService.getProviderModels(provider)).models;
const model = await resolveCommandModel(provider, catalog, context?.sessionId); const model = await resolveCommandModel(provider, catalog, context?.sessionId);
const reportedUsed = const used =
Number( Number(
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0, tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
) || 0; ) || 0;
@@ -266,15 +266,16 @@ Custom commands can be created in:
Number( Number(
tokenUsage.total ?? tokenUsage.total ??
tokenUsage.contextWindow ?? tokenUsage.contextWindow ??
0, parseInt(process.env.CONTEXT_WINDOW || "160000", 10),
) || 0; ) || 160000;
const percentage =
total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
const inputTokensRaw = const inputTokensRaw =
Number( Number(
tokenUsage.inputTokens ?? tokenUsage.inputTokens ??
tokenUsage.input ?? tokenUsage.input ??
tokenUsage.input_tokens ??
tokenUsage.cumulativeInputTokens ?? tokenUsage.cumulativeInputTokens ??
tokenUsage.breakdown?.input ??
tokenUsage.promptTokens ?? tokenUsage.promptTokens ??
0, 0,
) || 0; ) || 0;
@@ -282,14 +283,36 @@ Custom commands can be created in:
Number( Number(
tokenUsage.outputTokens ?? tokenUsage.outputTokens ??
tokenUsage.output ?? tokenUsage.output ??
tokenUsage.output_tokens ??
tokenUsage.cumulativeOutputTokens ?? tokenUsage.cumulativeOutputTokens ??
tokenUsage.breakdown?.output ??
tokenUsage.completionTokens ?? tokenUsage.completionTokens ??
0, 0,
) || 0; ) || 0;
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0; const cacheTokens =
const used = reportedUsed || inputTokensRaw + outputTokens; 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 { return {
type: "builtin", type: "builtin",
@@ -298,15 +321,18 @@ Custom commands can be created in:
tokenUsage: { tokenUsage: {
used, used,
total, total,
percentage,
},
tokenBreakdown: {
input: inputTokens,
output: outputTokens,
cache: cacheTokens,
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
}, },
...(hasTokenBreakdown
? {
tokenBreakdown: {
input: inputTokensRaw,
output: outputTokens,
},
}
: {}),
provider, provider,
model, model,
}, },

View File

@@ -1,6 +1,5 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, ProtectedRoute } from './components/auth'; import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TaskMasterProvider } from './contexts/TaskMasterContext';
@@ -10,99 +9,7 @@ import { PluginsProvider } from './contexts/PluginsContext';
import AppContent from './components/app/AppContent'; import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js'; import i18n from './i18n/config.js';
const DEPLOYMENT_ASSET_DIRECTORIES = new Set(['assets', 'static', 'icons', 'images']);
/**
* Detect the router basename from explicit runtime config or deployment hints.
*
* CloudCLI can be served from a path prefix by a reverse proxy, for example:
* /ai/manifest.json
* /ai/assets/index-abc123.js
* /ai/icons/icon-192x192.png
*
* React Router needs that prefix as its basename, but the packaged app should
* also keep working when served directly from the domain root. The direct-root
* case is easy to misread because asset URLs such as /icons/icon-192x192.png
* contain a directory even though there is no application basename.
*/
function detectRouterBasename() {
const explicitBasename = typeof window !== 'undefined' ? window.__ROUTER_BASENAME__ || '' : '';
if (explicitBasename) {
// Keep the deployment escape hatch authoritative. A trailing slash is
// harmless for humans but React Router expects a normalized basename.
return explicitBasename.replace(/\/+$/, '');
}
if (typeof window === 'undefined' || typeof document === 'undefined') {
return '';
}
const candidatePaths = [
{ kind: 'manifest' as const, value: document.querySelector('link[rel="manifest"]')?.getAttribute('href') },
{ kind: 'script' as const, value: document.querySelector('script[type="module"][src]')?.getAttribute('src') },
...Array.from(
document.querySelectorAll(
'link[rel~="icon"][href], link[rel="apple-touch-icon"][href], link[rel="apple-touch-icon-precomposed"][href], link[rel="mask-icon"][href]'
)
).map((node) => ({
kind: 'icon' as const,
value: node.getAttribute('href'),
})),
].filter((candidate): candidate is { kind: 'manifest' | 'script' | 'icon'; value: string } => Boolean(candidate.value));
let detectedBasename = '';
for (const candidate of candidatePaths) {
try {
const candidateUrl = new URL(candidate.value, document.baseURI || window.location.href);
if (candidateUrl.origin !== window.location.origin) {
continue;
}
const pathname = candidateUrl.pathname;
const normalizedPathname = pathname.replace(/\/+$/, '');
let normalized = '';
if (candidate.kind === 'script') {
const match = normalizedPathname.match(/^(.*)\/assets\//);
normalized = match?.[1] ? match[1].replace(/\/+$/, '') : '';
} else {
const manifestMatch = normalizedPathname.match(/^(.*)\/(?:manifest\.json|site\.webmanifest)$/);
const iconMatch = normalizedPathname.match(
/^(.*)\/(?:favicon(?:\.[^/]+)?|apple-touch-icon(?:-[^/]+)?(?:\.[^/]+)?|mask-icon(?:\.[^/]+)?|[^/]*icon[^/]*)$/
);
const match = candidate.kind === 'manifest' ? manifestMatch : iconMatch;
if (match?.[1]) {
const segments = match[1].split('/').filter(Boolean);
// Strip directories that describe where static files live, not where
// the app is mounted. This must also run for a single segment:
// /icons/icon-192x192.png -> ''
// /ai/icons/icon-192x192.png -> '/ai'
// The previous implementation only stripped while more than one
// segment remained, which incorrectly turned root deployments into a
// Router basename of /icons and caused a blank page after login.
while (segments.length > 0 && DEPLOYMENT_ASSET_DIRECTORIES.has(segments[segments.length - 1])) {
segments.pop();
}
normalized = segments.length > 0 ? `/${segments.join('/')}` : '';
}
}
if (normalized.length > detectedBasename.length) {
detectedBasename = normalized;
}
} catch {
// Ignore invalid candidate URLs and continue checking other hints.
}
}
return detectedBasename;
}
export default function App() { export default function App() {
const routerBasename = detectRouterBasename();
return ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<ThemeProvider> <ThemeProvider>
@@ -112,7 +19,7 @@ export default function App() {
<TasksSettingsProvider> <TasksSettingsProvider>
<TaskMasterProvider> <TaskMasterProvider>
<ProtectedRoute> <ProtectedRoute>
<Router basename={routerBasename}> <Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes> <Routes>
<Route path="/" element={<AppContent />} /> <Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} /> <Route path="/session/:sessionId" element={<AppContent />} />

View File

@@ -97,10 +97,17 @@ export type CostCommandData = {
tokenUsage?: { tokenUsage?: {
used?: number; used?: number;
total?: number; total?: number;
percentage?: number;
};
cost?: {
input?: string;
output?: string;
total?: string;
}; };
tokenBreakdown?: { tokenBreakdown?: {
input?: number; input?: number;
output?: number; output?: number;
cache?: number;
}; };
provider?: string; provider?: string;
model?: string; model?: string;

View File

@@ -624,23 +624,19 @@ 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]);
// Initial token usage fetch for providers with file-backed usage data. // Token usage fetch for Claude
useEffect(() => { useEffect(() => {
if (!selectedProject || !selectedSession?.id) { if (!selectedProject || !selectedSession?.id) {
setTokenBudget(null); setTokenBudget(null);
return; return;
} }
const sessionProvider = selectedSession.__provider || 'claude'; const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') { if (sessionProvider !== 'claude') return;
setTokenBudget(null);
return;
}
const fetchInitialTokenUsage = async () => { const fetchInitialTokenUsage = async () => {
try { try {
// Token usage endpoint is now keyed by the DB projectId. // Token usage endpoint is now keyed by the DB projectId.
const params = new URLSearchParams({ provider: sessionProvider }); const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
const response = await authenticatedFetch(url); const response = await authenticatedFetch(url);
if (response.ok) { if (response.ok) {
setTokenBudget(await response.json()); setTokenBudget(await response.json());

View File

@@ -18,7 +18,7 @@ import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment'; import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner'; import PermissionRequestsBanner from './PermissionRequestsBanner';
import ThinkingModeSelector from './ThinkingModeSelector'; import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsageSummary from './TokenUsageSummary'; import TokenUsagePie from './TokenUsagePie';
import { import {
PromptInput, PromptInput,
PromptInputHeader, PromptInputHeader,
@@ -60,7 +60,7 @@ interface ChatComposerProps {
onModeSwitch: () => void; onModeSwitch: () => void;
thinkingMode: string; thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>; setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: Record<string, unknown> | null; tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number; slashCommandsCount: number;
onToggleCommandMenu: () => void; onToggleCommandMenu: () => void;
hasInput: boolean; hasInput: boolean;
@@ -295,7 +295,6 @@ export default function ChatComposer({
<PromptInputTextarea <PromptInputTextarea
ref={textareaRef} ref={textareaRef}
dir="auto"
value={input} value={input}
onChange={onInputChange} onChange={onInputChange}
onClick={onTextareaClick} onClick={onTextareaClick}
@@ -362,7 +361,7 @@ export default function ChatComposer({
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" /> <ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)} )}
<TokenUsageSummary usage={tokenBudget} /> <TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<PromptInputButton <PromptInputButton
tooltip={{ content: t('input.showAllCommands') }} tooltip={{ content: t('input.showAllCommands') }}
@@ -402,6 +401,14 @@ export default function ChatComposer({
<PromptInputSubmit <PromptInputSubmit
disabled={!input.trim() || isLoading} disabled={!input.trim() || isLoading}
className="h-10 w-10 sm:h-10 sm:w-10" className="h-10 w-10 sm:h-10 sm:w-10"
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
}}
/> />
</div> </div>
</PromptInputFooter> </PromptInputFooter>

View File

@@ -6,6 +6,7 @@ import {
CircleHelp, CircleHelp,
Clipboard, Clipboard,
Coins, Coins,
Command as CommandIcon,
Cpu, Cpu,
Gauge, Gauge,
Package, Package,
@@ -16,6 +17,7 @@ import {
Timer, Timer,
RefreshCw, RefreshCw,
X, X,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui'; import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
@@ -82,7 +84,7 @@ const PROVIDER_LABELS: Record<string, string> = {
const FALLBACK_COMMANDS: CommandEntry[] = [ const FALLBACK_COMMANDS: CommandEntry[] = [
{ name: '/models', description: 'Browse available models for the active provider.' }, { name: '/models', description: 'Browse available models for the active provider.' },
{ name: '/cost', description: 'Review token usage for the active session.' }, { name: '/cost', description: 'Review context usage and estimated token spend.' },
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' }, { name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' }, { name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
{ name: '/config', description: 'Open settings and configuration.' }, { name: '/config', description: 'Open settings and configuration.' },
@@ -97,6 +99,13 @@ const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') =>
return PROVIDER_LABELS[provider] || provider; return PROVIDER_LABELS[provider] || provider;
}; };
const clampPercentage = (value: number) => {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(100, value));
};
const formatNumber = (value: number) => { const formatNumber = (value: number) => {
if (!Number.isFinite(value)) { if (!Number.isFinite(value)) {
return '0'; return '0';
@@ -104,6 +113,11 @@ const formatNumber = (value: number) => {
return value.toLocaleString(); return value.toLocaleString();
}; };
const formatCurrency = (value: number | string | undefined) => {
const numeric = Number(value ?? 0);
return `$${Number.isFinite(numeric) ? numeric.toFixed(4) : '0.0000'}`;
};
function MetricCard({ function MetricCard({
label, label,
value, value,
@@ -493,71 +507,62 @@ function ModelsContent({
function CostContent({ data }: { data: CostCommandData }) { function CostContent({ data }: { data: CostCommandData }) {
const used = Number(data.tokenUsage?.used ?? 0); const used = Number(data.tokenUsage?.used ?? 0);
const total = Number(data.tokenUsage?.total ?? 0); const total = Number(data.tokenUsage?.total ?? 0);
const percentage = clampPercentage(Number(data.tokenUsage?.percentage ?? 0));
const model = data.model || 'Unknown'; const model = data.model || 'Unknown';
const provider = getProviderLabel(data.provider, data.provider || 'Unknown'); const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
const hasBreakdown = const inputTokens = Number(data.tokenBreakdown?.input ?? 0);
typeof data.tokenBreakdown?.input === 'number' || const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
typeof data.tokenBreakdown?.output === 'number'; const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0);
const usageRows = [ const totalCost = Number(data.cost?.total ?? 0);
{ label: 'Total tokens used', value: formatNumber(used), icon: Activity },
...(hasBreakdown
? [
{
label: 'Input tokens',
value: formatNumber(Number(data.tokenBreakdown?.input ?? 0)),
icon: TerminalSquare,
},
{
label: 'Output tokens',
value: formatNumber(Number(data.tokenBreakdown?.output ?? 0)),
icon: Coins,
},
]
: [
{
label: 'Breakdown',
value: 'Unavailable',
icon: TerminalSquare,
},
]),
...(total > 0
? [{ label: 'Context window', value: formatNumber(total), icon: Gauge }]
: []),
];
return ( return (
<div className="space-y-4"> <div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
<div className="overflow-hidden rounded-2xl border border-border/70 bg-background/75"> <div className="rounded-3xl border border-primary/25 bg-primary/10 p-5 text-center">
{usageRows.map((row) => { <div
const Icon = row.icon; className="mx-auto grid h-40 w-40 place-items-center rounded-full p-2 shadow-inner"
style={{
return ( background: `conic-gradient(hsl(var(--primary)) ${percentage * 3.6}deg, hsl(var(--muted)) 0deg)`,
<div }}
key={row.label} >
className="flex items-center justify-between gap-4 border-b border-border/60 px-4 py-3 last:border-b-0" <div className="grid h-full w-full place-items-center rounded-full border border-border/70 bg-popover">
> <div>
<div className="flex min-w-0 items-center gap-3"> <p className="font-mono text-3xl font-semibold text-foreground">{percentage.toFixed(1)}%</p>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/10 text-primary"> <p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">context</p>
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-sm font-medium text-foreground">{row.label}</span>
</div>
<span className="shrink-0 font-mono text-sm font-semibold text-foreground">{row.value}</span>
</div> </div>
); </div>
})} </div>
<p className="mt-4 text-sm text-muted-foreground">
{formatNumber(used)} of {formatNumber(total)} tokens used
</p>
</div> </div>
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4"> <div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-3">
<div> <MetricCard label="Input" value={formatCurrency(data.cost?.input)} icon={Zap} />
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p> <MetricCard label="Output" value={formatCurrency(data.cost?.output)} icon={Activity} />
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p> <MetricCard label="Total" value={formatCurrency(totalCost)} icon={Coins} tone="primary" />
</div> </div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p> <div className="grid gap-3 sm:grid-cols-3">
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p> <MetricCard label="Input tokens" value={formatNumber(inputTokens)} icon={CommandIcon} />
<MetricCard label="Output tokens" value={formatNumber(outputTokens)} icon={TerminalSquare} />
<MetricCard label="Cache tokens" value={formatNumber(cacheTokens)} icon={Package} />
</div>
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
</div>
</div> </div>
<p className="mt-3 text-xs leading-5 text-muted-foreground">
Cost is an estimate based on the available token counters and default provider rates.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -631,8 +636,8 @@ export default function CommandResultModal({
}, },
cost: { cost: {
eyebrow: 'Session telemetry', eyebrow: 'Session telemetry',
title: 'Token Usage', title: 'Usage & Cost',
subtitle: 'Input, output, and total token counts for this session.', subtitle: 'Token budget, context pressure, and estimated spend for this session.',
icon: Coins, icon: Coins,
}, },
status: { status: {

View File

@@ -120,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
/* User message bubble on the right */ /* User message bubble on the right */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl"> <div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4"> <div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words text-sm"> <div className="whitespace-pre-wrap break-words text-sm">
{message.content} {message.content}
</div> </div>
{message.images && message.images.length > 0 && ( {message.images && message.images.length > 0 && (
@@ -405,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</ReasoningContent> </ReasoningContent>
</Reasoning> </Reasoning>
) : ( ) : (
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-gray-700 dark:text-gray-300">
{/* Reasoning accordion */} {/* Reasoning accordion */}
{showThinking && message.reasoning && ( {showThinking && message.reasoning && (
<Reasoning className="mb-3" defaultOpen={false}> <Reasoning className="mb-3" defaultOpen={false}>

View File

@@ -321,7 +321,6 @@ export default function ProviderSelectionEmptyState({
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60"> <p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
<Trans <Trans
ns="chat"
i18nKey="providerSelection.pressToSearch" i18nKey="providerSelection.pressToSearch"
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }} values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
components={{ components={{

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,53 +0,0 @@
import { ActivityIcon } from 'lucide-react';
type TokenUsageSummaryProps = {
usage: Record<string, unknown> | null;
};
const formatTokenCount = (value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return '0';
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
}
if (value >= 10_000) {
return `${Math.round(value / 1_000)}K`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}K`;
}
return value.toLocaleString();
};
const readUsageNumber = (value: unknown) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
const breakdown =
usage?.breakdown && typeof usage.breakdown === 'object'
? usage.breakdown as Record<string, unknown>
: null;
const inputTokens = readUsageNumber(usage?.inputTokens ?? breakdown?.input);
const outputTokens = readUsageNumber(usage?.outputTokens ?? breakdown?.output);
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
return (
<div
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground sm:gap-2 sm:px-2.5"
title={`${usedTokens.toLocaleString()} tokens used`}
>
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
<ActivityIcon className="h-3.5 w-3.5" />
</span>
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
</div>
);
}

View File

@@ -1,6 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
type Props = { type Props = {
@@ -12,48 +10,6 @@ type Props = {
// Module-level cache so repeated renders don't re-fetch // Module-level cache so repeated renders don't re-fetch
const svgCache = new Map<string, string>(); const svgCache = new Map<string, string>();
const FORBIDDEN_SVG_TAGS = [
'script',
'foreignObject',
'iframe',
'object',
'embed',
'link',
'meta',
'style',
'animate',
'set',
'animateTransform',
'animateMotion',
];
const FORBIDDEN_SVG_ATTRS = [
'href',
'xlink:href',
'src',
'style',
];
function sanitizeSvg(svgText: string): string | null {
const sanitized = DOMPurify.sanitize(svgText, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: FORBIDDEN_SVG_TAGS,
FORBID_ATTR: FORBIDDEN_SVG_ATTRS,
});
if (!sanitized) return null;
try {
const doc = new DOMParser().parseFromString(sanitized, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() !== 'svg') return null;
if (doc.querySelector('parsererror')) return null;
return sanitized;
} catch {
return null;
}
}
export default function PluginIcon({ pluginName, iconFile, className }: Props) { export default function PluginIcon({ pluginName, iconFile, className }: Props) {
const url = iconFile const url = iconFile
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}` ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
@@ -68,11 +24,9 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
return r.text(); return r.text();
}) })
.then((text) => { .then((text) => {
if (!text) return; if (text && text.trimStart().startsWith('<svg')) {
const sanitized = sanitizeSvg(text); svgCache.set(url, text);
if (sanitized) { setSvg(text);
svgCache.set(url, sanitized);
setSvg(sanitized);
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -81,6 +35,10 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
if (!svg) return <span className={className} />; if (!svg) return <span className={className} />;
return ( return (
<span className={className} dangerouslySetInnerHTML={{ __html: svg }} /> <span
className={className}
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
dangerouslySetInnerHTML={{ __html: svg }}
/>
); );
} }

View File

@@ -1,93 +1,12 @@
import { useState, type ReactNode } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
Activity,
BarChart3,
BookOpen,
Clock,
Download,
ExternalLink,
GitBranch,
Loader2,
RefreshCw,
ServerCrash,
ShieldAlert,
Terminal,
Trash2,
type LucideIcon,
} from 'lucide-react';
import { usePlugins } from '../../../contexts/PluginsContext'; import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../../contexts/PluginsContext'; import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon'; import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal'; const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
type PluginRecommendation = {
id: string;
translationKey: string;
repoUrl: string;
installedNames: string[];
icon: LucideIcon;
source: 'official' | 'unofficial';
};
const OFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
{
id: 'project-stats',
translationKey: 'starterPlugin',
repoUrl: STARTER_PLUGIN_URL,
installedNames: ['project-stats'],
icon: BarChart3,
source: 'official',
},
{
id: 'web-terminal',
translationKey: 'terminalPlugin',
repoUrl: TERMINAL_PLUGIN_URL,
installedNames: ['web-terminal'],
icon: Terminal,
source: 'official',
},
];
const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
{
id: 'cloudcli-claude-watch',
translationKey: 'claudeWatchPlugin',
repoUrl: CLAUDE_WATCH_PLUGIN_URL,
installedNames: ['cloudcli-claude-watch'],
icon: Activity,
source: 'unofficial',
},
{
id: 'workspace-scheduled-prompts',
translationKey: 'scheduledPromptPlugin',
repoUrl: SCHEDULED_PROMPT_PLUGIN_URL,
installedNames: ['workspace-scheduled-prompts'],
icon: Clock,
source: 'unofficial',
},
];
function repoSlug(repoUrl: string) {
return repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '');
}
function normalizeRepoUrl(repoUrl: string | null) {
return repoUrl?.replace(/\.git$/, '').replace(/\/$/, '').toLowerCase() ?? null;
}
function pluginMatchesRecommendation(plugin: Plugin, recommendation: PluginRecommendation) {
return (
recommendation.installedNames.includes(plugin.name)
|| normalizeRepoUrl(plugin.repoUrl) === normalizeRepoUrl(recommendation.repoUrl)
);
}
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */ /* ─── Toggle Switch ─────────────────────────────────────────────────────── */
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) { function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
@@ -289,95 +208,117 @@ function PluginCard({
); );
} }
/* ─── Recommendation Section ────────────────────────────────────────────── */ /* ─── Starter Plugin Card ───────────────────────────────────────────────── */
function RecommendationSection({ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return (
<section className="space-y-2">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</h4>
<p className="mt-0.5 text-xs text-muted-foreground/70">
{description}
</p>
</div>
<div className="space-y-2">
{children}
</div>
</section>
);
}
/* ─── Plugin Recommendation Card ────────────────────────────────────────── */
function PluginRecommendationCard({
recommendation,
onInstall,
disabled,
installing,
}: {
recommendation: PluginRecommendation;
onInstall: () => void;
disabled: boolean;
installing: boolean;
}) {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const Icon = recommendation.icon;
const isOfficial = recommendation.source === 'official';
const accentClass = isOfficial ? 'bg-blue-500/30' : 'bg-amber-500/40';
const hoverClass = isOfficial ? 'hover:border-blue-400 dark:hover:border-blue-500' : 'hover:border-amber-400 dark:hover:border-amber-500';
const iconClass = isOfficial ? 'text-blue-500' : 'text-amber-500';
return ( return (
<div className={`relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 ${hoverClass}`}> <div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className={`w-[3px] flex-shrink-0 ${accentClass}`} /> <div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4"> <div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<div className={`h-5 w-5 flex-shrink-0 ${iconClass}`}> <div className="h-5 w-5 flex-shrink-0 text-blue-500">
<Icon className="h-5 w-5" /> <BarChart3 className="h-5 w-5" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground"> <span className="text-sm font-semibold leading-none text-foreground">
{t(`pluginSettings.${recommendation.translationKey}.name`)} {t('pluginSettings.starterPlugin.name')}
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
{t('pluginSettings.starterPlugin.badge')}
</span> </span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')} {t('pluginSettings.tab')}
</span> </span>
</div> </div>
<p className="mt-1 text-sm leading-snug text-muted-foreground"> <p className="mt-1 text-sm leading-snug text-muted-foreground">
{t(`pluginSettings.${recommendation.translationKey}.description`)} {t('pluginSettings.starterPlugin.description')}
</p> </p>
<a <a
href={recommendation.repoUrl} href={STARTER_PLUGIN_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
> >
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{repoSlug(recommendation.repoUrl)} cloudcli-ai/cloudcli-plugin-starter
</a> </a>
</div> </div>
</div> </div>
<button <button
onClick={onInstall} onClick={onInstall}
disabled={disabled} disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50" className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
> >
{installing ? ( {installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
)} )}
{installing ? t('pluginSettings.installing') : t(`pluginSettings.${recommendation.translationKey}.install`)} {installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
</button>
</div>
</div>
</div>
);
}
/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */
function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
const { t } = useTranslation('settings');
return (
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M7 8l4 4-4 4"/>
<line x1="13" y1="16" x2="17" y2="16"/>
</svg>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
{t('pluginSettings.terminalPlugin.name')}
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
{t('pluginSettings.terminalPlugin.badge')}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')}
</span>
</div>
<p className="mt-1 text-sm leading-snug text-muted-foreground">
{t('pluginSettings.terminalPlugin.description')}
</p>
<a
href={TERMINAL_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
cloudcli-ai/cloudcli-plugin-terminal
</a>
</div>
</div>
<button
onClick={onInstall}
disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing ? t('pluginSettings.installing') : t('pluginSettings.terminalPlugin.install')}
</button> </button>
</div> </div>
</div> </div>
@@ -393,7 +334,8 @@ export default function PluginSettingsTab() {
const [gitUrl, setGitUrl] = useState(''); const [gitUrl, setGitUrl] = useState('');
const [installing, setInstalling] = useState(false); const [installing, setInstalling] = useState(false);
const [installingRecommendation, setInstallingRecommendation] = useState<string | null>(null); const [installingStarter, setInstallingStarter] = useState(false);
const [installingTerminal, setInstallingTerminal] = useState(false);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null); const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set()); const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
@@ -422,18 +364,24 @@ export default function PluginSettingsTab() {
setInstalling(false); setInstalling(false);
}; };
const handleInstallRecommendation = async (recommendation: PluginRecommendation) => { const handleInstallStarter = async () => {
if (installingRecommendation) return; setInstallingStarter(true);
setInstallingRecommendation(recommendation.id);
setInstallError(null); setInstallError(null);
try { const result = await installPlugin(STARTER_PLUGIN_URL);
const result = await installPlugin(recommendation.repoUrl); if (!result.success) {
if (!result.success) { setInstallError(result.error || t('pluginSettings.installFailed'));
setInstallError(result.error || t('pluginSettings.installFailed'));
}
} finally {
setInstallingRecommendation(null);
} }
setInstallingStarter(false);
};
const handleInstallTerminal = async () => {
setInstallingTerminal(true);
setInstallError(null);
const result = await installPlugin(TERMINAL_PLUGIN_URL);
if (!result.success) {
setInstallError(result.error || t('pluginSettings.installFailed'));
}
setInstallingTerminal(false);
}; };
const handleUninstall = async (name: string) => { const handleUninstall = async (name: string) => {
@@ -450,50 +398,8 @@ export default function PluginSettingsTab() {
} }
}; };
const isRecommendationInstalled = (recommendation: PluginRecommendation) => { const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
return plugins.some((plugin) => pluginMatchesRecommendation(plugin, recommendation)); const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal');
};
const isOfficialPlugin = (plugin: Plugin) => {
return OFFICIAL_PLUGIN_RECOMMENDATIONS.some((recommendation) => (
pluginMatchesRecommendation(plugin, recommendation)
));
};
const officialPlugins = plugins.filter(isOfficialPlugin);
const otherPlugins = plugins.filter((plugin) => !isOfficialPlugin(plugin));
const officialRecommendations = OFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
(recommendation) => !isRecommendationInstalled(recommendation),
);
const unofficialRecommendations = UNOFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
(recommendation) => !isRecommendationInstalled(recommendation),
);
const hasOfficialSection = officialPlugins.length > 0 || officialRecommendations.length > 0;
const hasOtherSection = otherPlugins.length > 0 || unofficialRecommendations.length > 0;
const renderPluginCard = (plugin: Plugin, index: number) => {
const handleToggle = async (enabled: boolean) => {
const r = await togglePlugin(plugin.name, enabled);
if (!r.success) {
setInstallError(r.error || t('pluginSettings.toggleFailed'));
}
};
return (
<PluginCard
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void handleToggle(enabled)}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)}
confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null}
/>
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -550,49 +456,51 @@ export default function PluginSettingsTab() {
</span> </span>
</p> </p>
{/* Plugin sections */} {/* Official plugin suggestions — above the list */}
{!loading && (!hasStarterInstalled || !hasTerminalInstalled) && (
<div className="space-y-2">
{!hasStarterInstalled && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
)}
{!hasTerminalInstalled && (
<TerminalPluginCard onInstall={handleInstallTerminal} installing={installingTerminal} />
)}
</div>
)}
{/* Plugin List */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t('pluginSettings.scanningPlugins')} {t('pluginSettings.scanningPlugins')}
</div> </div>
) : plugins.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-2">
{hasOfficialSection && ( {plugins.map((plugin, index) => {
<RecommendationSection const handleToggle = async (enabled: boolean) => {
title={t('pluginSettings.sections.officialTitle')} const r = await togglePlugin(plugin.name, enabled);
description={t('pluginSettings.sections.officialDescription')} if (!r.success) {
> setInstallError(r.error || t('pluginSettings.toggleFailed'));
{officialPlugins.map((plugin, index) => renderPluginCard(plugin, index))} }
{officialRecommendations.map((recommendation) => ( };
<PluginRecommendationCard
key={recommendation.id}
recommendation={recommendation}
onInstall={() => void handleInstallRecommendation(recommendation)}
disabled={!!installingRecommendation}
installing={installingRecommendation === recommendation.id}
/>
))}
</RecommendationSection>
)}
{hasOtherSection && ( return (
<RecommendationSection <PluginCard
title={t('pluginSettings.sections.unofficialTitle')} key={plugin.name}
description={t('pluginSettings.sections.unofficialDescription')} plugin={plugin}
> index={index}
{otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))} onToggle={(enabled) => void handleToggle(enabled)}
{unofficialRecommendations.map((recommendation) => ( onUpdate={() => void handleUpdate(plugin.name)}
<PluginRecommendationCard onUninstall={() => void handleUninstall(plugin.name)}
key={recommendation.id} updating={updatingPlugins.has(plugin.name)}
recommendation={recommendation} confirmingUninstall={confirmUninstall === plugin.name}
onInstall={() => void handleInstallRecommendation(recommendation)} onCancelUninstall={() => setConfirmUninstall(null)}
disabled={!!installingRecommendation} updateError={updateErrors[plugin.name] ?? null}
installing={installingRecommendation === recommendation.id} />
/> );
))} })}
</RecommendationSection>
)}
</div> </div>
)} )}

View File

@@ -70,39 +70,34 @@ export function useProviderAuthStatus(
})); }));
}, []); }, []);
const checkProviderAuthStatus = useCallback(async (provider: LLMProvider): Promise<ProviderAuthStatus> => { const checkProviderAuthStatus = useCallback(async (provider: LLMProvider) => {
setProviderLoading(provider); setProviderLoading(provider);
try { try {
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]); const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) { if (!response.ok) {
const status: ProviderAuthStatus = { setProviderStatus(provider, {
authenticated: false, authenticated: false,
email: null, email: null,
method: null, method: null,
loading: false, loading: false,
error: FALLBACK_STATUS_ERROR, error: FALLBACK_STATUS_ERROR,
}; });
setProviderStatus(provider, status); return;
return status;
} }
const payload = (await response.json()) as ProviderAuthStatusApiResponse; const payload = (await response.json()) as ProviderAuthStatusApiResponse;
const status = toProviderAuthStatus(payload.data); setProviderStatus(provider, toProviderAuthStatus(payload.data));
setProviderStatus(provider, status);
return status;
} catch (caughtError) { } catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError); console.error(`Error checking ${provider} auth status:`, caughtError);
const status: ProviderAuthStatus = { setProviderStatus(provider, {
authenticated: false, authenticated: false,
email: null, email: null,
method: null, method: null,
loading: false, loading: false,
error: toErrorMessage(caughtError), error: toErrorMessage(caughtError),
}; });
setProviderStatus(provider, status);
return status;
} }
}, [setProviderLoading, setProviderStatus]); }, [setProviderLoading, setProviderStatus]);

View File

@@ -213,19 +213,12 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
}, []); }, []);
const handleLoginComplete = useCallback((exitCode: number) => { const handleLoginComplete = useCallback((exitCode: number) => {
if (!loginProvider) { if (exitCode !== 0 || !loginProvider) {
return; return;
} }
void (async () => { setSaveStatus('success');
const authStatus = await checkProviderAuthStatus(loginProvider); void checkProviderAuthStatus(loginProvider);
if (exitCode !== 0) {
console.warn(`Login process exited with code ${exitCode}; refreshing auth status before setting save status.`);
}
setSaveStatus(authStatus.authenticated ? 'success' : 'error');
})();
}, [checkProviderAuthStatus, loginProvider]); }, [checkProviderAuthStatus, loginProvider]);
const saveSettings = useCallback(async () => { const saveSettings = useCallback(async () => {

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants'; import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket'; import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
@@ -32,8 +31,8 @@ type UseShellConnectionResult = {
isConnected: boolean; isConnected: boolean;
isConnecting: boolean; isConnecting: boolean;
closeSocket: () => void; closeSocket: () => void;
connectToShell: (options?: { forceRestart?: boolean }) => void; connectToShell: () => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; disconnectFromShell: () => void;
}; };
export function useShellConnection({ export function useShellConnection({
@@ -55,8 +54,6 @@ export function useShellConnection({
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const connectingRef = useRef(false); const connectingRef = useRef(false);
const forceRestartOnInitRef = useRef(false);
const suppressAutoConnectRef = useRef(false);
const handleProcessCompletion = useCallback( const handleProcessCompletion = useCallback(
(output: string) => { (output: string) => {
@@ -144,8 +141,6 @@ export function useShellConnection({
} }
currentFitAddon.fit(); currentFitAddon.fit();
const forceRestart = forceRestartOnInitRef.current;
forceRestartOnInitRef.current = false;
sendSocketMessage(socket, { sendSocketMessage(socket, {
type: 'init', type: 'init',
@@ -157,7 +152,6 @@ export function useShellConnection({
rows: currentTerminal.rows, rows: currentTerminal.rows,
initialCommand: initialCommandRef.current, initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current, isPlainShell: isPlainShellRef.current,
forceRestart,
}); });
}, TERMINAL_INIT_DELAY_MS); }, TERMINAL_INIT_DELAY_MS);
}; };
@@ -183,7 +177,6 @@ export function useShellConnection({
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false;
} }
}, },
[ [
@@ -202,40 +195,27 @@ export function useShellConnection({
], ],
); );
const connectToShell = useCallback((options?: { forceRestart?: boolean }) => { const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting || connectingRef.current) { if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
return; return;
} }
forceRestartOnInitRef.current = Boolean(options?.forceRestart);
suppressAutoConnectRef.current = false;
connectingRef.current = true; connectingRef.current = true;
setIsConnecting(true); setIsConnecting(true);
connectWebSocket(true); connectWebSocket(true);
}, [connectWebSocket, isConnected, isConnecting, isInitialized]); }, [connectWebSocket, isConnected, isConnecting, isInitialized]);
const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => { const disconnectFromShell = useCallback(() => {
if (options?.suppressAutoConnect) {
suppressAutoConnectRef.current = true;
}
closeSocket(); closeSocket();
clearTerminalScreen(); clearTerminalScreen();
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false;
setAuthUrl(''); setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]); }, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => { useEffect(() => {
if ( if (!autoConnect || !isInitialized || isConnecting || isConnected) {
!autoConnect ||
suppressAutoConnectRef.current ||
!isInitialized ||
isConnecting ||
isConnected
) {
return; return;
} }

View File

@@ -1,7 +1,6 @@
import type { MutableRefObject, RefObject } from 'react'; import type { MutableRefObject, RefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed'; export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
@@ -16,7 +15,6 @@ export type ShellInitMessage = {
rows: number; rows: number;
initialCommand: string | null | undefined; initialCommand: string | null | undefined;
isPlainShell: boolean; isPlainShell: boolean;
forceRestart?: boolean;
}; };
export type ShellResizeMessage = { export type ShellResizeMessage = {
@@ -71,8 +69,8 @@ export type UseShellRuntimeResult = {
isConnecting: boolean; isConnecting: boolean;
authUrl: string; authUrl: string;
authUrlVersion: number; authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void; connectToShell: () => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; disconnectFromShell: () => void;
openAuthUrlInBrowser: (url?: string) => boolean; openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>; copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
}; };

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
import { import {
@@ -14,7 +13,6 @@ import {
import { useShellRuntime } from '../hooks/useShellRuntime'; import { useShellRuntime } from '../hooks/useShellRuntime';
import { sendSocketMessage } from '../utils/socket'; import { sendSocketMessage } from '../utils/socket';
import { getSessionDisplayName } from '../utils/auth'; import { getSessionDisplayName } from '../utils/auth';
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay'; import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
import ShellEmptyState from './subcomponents/ShellEmptyState'; import ShellEmptyState from './subcomponents/ShellEmptyState';
import ShellHeader from './subcomponents/ShellHeader'; import ShellHeader from './subcomponents/ShellHeader';
@@ -48,8 +46,6 @@ export default function Shell({
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null); const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const restartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const restartAfterInitRef = useRef(false);
const onOutputRef = useRef<(() => void) | null>(null); const onOutputRef = useRef<(() => void) | null>(null);
const { const {
@@ -144,7 +140,6 @@ export default function Shell({
useEffect(() => { useEffect(() => {
return () => { return () => {
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current); if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
}; };
}, []); }, []);
@@ -195,42 +190,12 @@ export default function Shell({
); );
const handleRestartShell = useCallback(() => { const handleRestartShell = useCallback(() => {
restartAfterInitRef.current = true;
setIsRestarting(true); setIsRestarting(true);
if (restartTimerRef.current) { window.setTimeout(() => {
clearTimeout(restartTimerRef.current);
}
restartTimerRef.current = setTimeout(() => {
setIsRestarting(false); setIsRestarting(false);
restartTimerRef.current = null;
}, SHELL_RESTART_DELAY_MS); }, SHELL_RESTART_DELAY_MS);
}, []); }, []);
const handleDisconnectShell = useCallback(() => {
restartAfterInitRef.current = false;
if (restartTimerRef.current) {
clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
}
setIsRestarting(false);
disconnectFromShell({ suppressAutoConnect: true });
}, [disconnectFromShell]);
useEffect(() => {
if (
!restartAfterInitRef.current ||
isRestarting ||
!isInitialized ||
isConnected ||
isConnecting
) {
return;
}
restartAfterInitRef.current = false;
connectToShell({ forceRestart: true });
}, [connectToShell, isConnected, isConnecting, isInitialized, isRestarting]);
if (!selectedProject) { if (!selectedProject) {
return ( return (
<ShellEmptyState <ShellEmptyState
@@ -289,7 +254,7 @@ export default function Shell({
isRestarting={isRestarting} isRestarting={isRestarting}
hasSession={Boolean(selectedSession)} hasSession={Boolean(selectedSession)}
sessionDisplayNameShort={sessionDisplayNameShort} sessionDisplayNameShort={sessionDisplayNameShort}
onDisconnect={handleDisconnectShell} onDisconnect={disconnectFromShell}
onRestart={handleRestartShell} onRestart={handleRestartShell}
statusNewSessionText={t('shell.status.newSession')} statusNewSessionText={t('shell.status.newSession')}
statusInitializingText={t('shell.status.initializing')} statusInitializingText={t('shell.status.initializing')}
@@ -298,7 +263,7 @@ export default function Shell({
disconnectTitle={t('shell.actions.disconnectTitle')} disconnectTitle={t('shell.actions.disconnectTitle')}
restartLabel={t('shell.actions.restart')} restartLabel={t('shell.actions.restart')}
restartTitle={t('shell.actions.restartTitle')} restartTitle={t('shell.actions.restartTitle')}
disableRestart={isRestarting || !isInitialized} disableRestart={isRestarting || isConnected}
/> />
<div className="relative flex-1 overflow-hidden p-2"> <div className="relative flex-1 overflow-hidden p-2">
@@ -316,7 +281,7 @@ export default function Shell({
connectLabel={t('shell.actions.connect')} connectLabel={t('shell.actions.connect')}
connectTitle={t('shell.actions.connectTitle')} connectTitle={t('shell.actions.connectTitle')}
connectingLabel={t('shell.connecting')} connectingLabel={t('shell.connecting')}
onConnect={handleRestartShell} onConnect={connectToShell}
/> />
)} )}

View File

@@ -1,5 +1,3 @@
import { Loader2, RotateCcw } from 'lucide-react';
type ShellConnectionOverlayProps = { type ShellConnectionOverlayProps = {
mode: 'loading' | 'connect' | 'connecting'; mode: 'loading' | 'connect' | 'connecting';
description: string; description: string;
@@ -21,42 +19,40 @@ export default function ShellConnectionOverlay({
}: ShellConnectionOverlayProps) { }: ShellConnectionOverlayProps) {
if (mode === 'loading') { if (mode === 'loading') {
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="inline-flex items-center gap-2 text-sm font-medium text-gray-100"> <div className="text-white">{loadingLabel}</div>
<Loader2 className="h-4 w-4 animate-spin text-blue-300" aria-hidden="true" />
<span>{loadingLabel}</span>
</div>
</div> </div>
); );
} }
if (mode === 'connect') { if (mode === 'connect') {
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center"> <div className="w-full max-w-sm text-center">
<button <button
type="button"
onClick={onConnect} onClick={onConnect}
className="pointer-events-auto inline-flex min-h-12 w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-md bg-emerald-600 px-5 py-3 text-base font-semibold text-white shadow-lg shadow-emerald-950/30 transition-colors hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-300 focus:ring-offset-2 focus:ring-offset-gray-950 active:bg-emerald-700" className="flex w-full items-center justify-center space-x-2 rounded-lg bg-green-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-green-700 sm:w-auto"
title={connectTitle} title={connectTitle}
> >
<RotateCcw className="h-4 w-4" aria-hidden="true" /> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span className="min-w-0 truncate">{connectLabel}</span> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{connectLabel}</span>
</button> </button>
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p> <p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center"> <div className="w-full max-w-sm text-center">
<div className="flex items-center justify-center gap-3 text-yellow-300"> <div className="flex items-center justify-center space-x-3 text-yellow-400">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">{connectingLabel}</span> <span className="text-base font-medium">{connectingLabel}</span>
</div> </div>
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p> <p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,3 @@
import { RotateCcw, X } from 'lucide-react';
type ShellHeaderProps = { type ShellHeaderProps = {
isConnected: boolean; isConnected: boolean;
isInitialized: boolean; isInitialized: boolean;
@@ -52,27 +50,34 @@ export default function ShellHeader({
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>} {isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center space-x-3">
{isConnected && ( {isConnected && (
<button <button
type="button"
onClick={onDisconnect} onClick={onDisconnect}
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-red-600 px-3 text-xs font-medium text-white transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70 focus:ring-offset-2 focus:ring-offset-gray-800" className="flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
title={disconnectTitle} title={disconnectTitle}
> >
<X className="h-3.5 w-3.5" aria-hidden="true" /> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{disconnectLabel}</span> <span>{disconnectLabel}</span>
</button> </button>
)} )}
<button <button
type="button"
onClick={onRestart} onClick={onRestart}
disabled={disableRestart} disabled={disableRestart}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-gray-600/80 bg-gray-700/70 px-3 text-xs font-medium text-gray-100 transition-colors hover:border-blue-400/70 hover:bg-blue-600/80 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-400/70 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:cursor-not-allowed disabled:border-transparent disabled:bg-transparent disabled:text-gray-500 disabled:opacity-60" className="flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
title={restartTitle} title={restartTitle}
> >
<RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" /> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{restartLabel}</span> <span>{restartLabel}</span>
</button> </button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { Check, ChevronDown, ChevronRight, Edit3, Star, Trash2, X } from 'lucide-react'; import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui'; import { Button } from '../../../../shared/view/ui';
@@ -131,28 +131,18 @@ export default function SidebarProjectItem({
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<button <div
className={cn( className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border', 'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
isStarred isExpanded ? 'bg-primary/10' : 'bg-muted',
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
)} )}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
> >
<Star {isExpanded ? (
className={cn( <FolderOpen className="h-4 w-4 text-primary" />
'w-4 h-4 transition-colors', ) : (
isStarred <Folder className="h-4 w-4 text-muted-foreground" />
? 'text-yellow-600 dark:text-yellow-400 fill-current' )}
: 'text-gray-600 dark:text-gray-400', </div>
)}
/>
</button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{isEditing ? ( {isEditing ? (
@@ -222,6 +212,29 @@ export default function SidebarProjectItem({
</> </>
) : ( ) : (
<> <>
<button
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',
isStarred
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-4 h-4 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-gray-600 dark:text-gray-400',
)}
/>
</button>
<button <button
className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30" className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
onClick={(event) => { onClick={(event) => {
@@ -268,28 +281,11 @@ export default function SidebarProjectItem({
onClick={selectAndToggleProject} onClick={selectAndToggleProject}
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<div {isExpanded ? (
className={cn( <FolderOpen className="h-4 w-4 flex-shrink-0 text-primary" />
'w-6 h-6 flex items-center justify-center rounded cursor-pointer transition-all duration-200', ) : (
isStarred <Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20' )}
: 'opacity-40 hover:opacity-100 hover:bg-accent',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-3 h-3 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-muted-foreground',
)}
/>
</div>
<div className="min-w-0 flex-1 text-left"> <div className="min-w-0 flex-1 text-left">
{isEditing ? ( {isEditing ? (
<div className="space-y-1"> <div className="space-y-1">
@@ -356,6 +352,26 @@ export default function SidebarProjectItem({
</> </>
) : ( ) : (
<> <>
<div
className={cn(
'w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100',
isStarred ? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100' : 'hover:bg-accent',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-3 h-3 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-muted-foreground',
)}
/>
</div>
<div <div
className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100" className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100"
onClick={(event) => { onClick={(event) => {

View File

@@ -1,8 +1,7 @@
import { useEffect, useRef } from 'react';
import { Check, Edit2, Trash2, X } from 'lucide-react'; import { Check, Edit2, Trash2, X } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Badge, Button, Tooltip } from '../../../../shared/view/ui'; import { Badge, Button } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils'; import { cn } from '../../../../lib/utils';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { SessionWithProvider } from '../../types/types'; import type { SessionWithProvider } from '../../types/types';
@@ -77,28 +76,7 @@ export default function SidebarSessionItem({
}: SidebarSessionItemProps) { }: SidebarSessionItemProps) {
const sessionView = createSessionViewModel(session, currentTime, t); const sessionView = createSessionViewModel(session, currentTime, t);
const isSelected = selectedSession?.id === session.id; const isSelected = selectedSession?.id === session.id;
const isEditing = editingSession === session.id;
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
const editingContainerRef = useRef<HTMLDivElement>(null);
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row
// would visually hide it. While editing, dismiss only when the user clicks outside
// the panel (matches Escape / cancel-button behaviour).
useEffect(() => {
if (!isEditing) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
const container = editingContainerRef.current;
if (container && !container.contains(event.target as Node)) {
onCancelEditingSession();
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [isEditing, onCancelEditingSession]);
// Sessions are owned by a project identified by `projectId` (DB primary key) // Sessions are owned by a project identified by `projectId` (DB primary key)
// after the projectName → projectId migration. // after the projectName → projectId migration.
@@ -119,13 +97,7 @@ export default function SidebarSessionItem({
<div className="group relative"> <div className="group relative">
{sessionView.isActive && ( {sessionView.isActive && (
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"> <div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right"> <div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
<div
role="status"
aria-label={t('tooltips.activeSessionIndicator')}
className="h-2 w-2 animate-pulse rounded-full bg-green-500"
/>
</Tooltip>
</div> </div>
)} )}
@@ -196,12 +168,7 @@ export default function SidebarSessionItem({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div> <div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
{compactSessionAge && ( {compactSessionAge && (
<span <span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
className={cn(
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
)}
>
{compactSessionAge} {compactSessionAge}
</span> </span>
)} )}
@@ -213,14 +180,8 @@ export default function SidebarSessionItem({
</div> </div>
</Button> </Button>
<div <div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 opacity-0 transition-all duration-200 group-hover:opacity-100">
ref={editingContainerRef} {editingSession === session.id ? (
className={cn(
'absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 transition-all duration-200',
isEditing ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
>
{isEditing ? (
<> <>
<input <input
type="text" type="text"

View File

@@ -36,12 +36,8 @@ const useWebSocketProviderState = (): WebSocketContextType => {
const { token } = useAuth(); const { token } = useAuth();
useEffect(() => { useEffect(() => {
// The cleanup below sets unmountedRef = true. Without this reset, every
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
// at its unmounted guard and leave the socket permanently disconnected.
unmountedRef.current = false;
connect(); connect();
return () => { return () => {
unmountedRef.current = true; unmountedRef.current = true;
if (reconnectTimeoutRef.current) { if (reconnectTimeoutRef.current) {

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Aus Favoriten entfernen", "removeFromFavorites": "Aus Favoriten entfernen",
"editSessionName": "Sitzungsname manuell bearbeiten", "editSessionName": "Sitzungsname manuell bearbeiten",
"deleteSession": "Diese Sitzung dauerhaft löschen", "deleteSession": "Diese Sitzung dauerhaft löschen",
"activeSessionIndicator": "Kürzlich aktive Sitzung (letzte 10 Minuten)",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"clearSearch": "Suche leeren", "clearSearch": "Suche leeren",

View File

@@ -229,7 +229,7 @@
"disconnect": "Disconnect", "disconnect": "Disconnect",
"disconnectTitle": "Disconnect from shell", "disconnectTitle": "Disconnect from shell",
"restart": "Restart", "restart": "Restart",
"restartTitle": "Restart Shell", "restartTitle": "Restart Shell (disconnect first)",
"connect": "Continue in Shell", "connect": "Continue in Shell",
"connectTitle": "Connect to shell" "connectTitle": "Connect to shell"
}, },

View File

@@ -472,12 +472,6 @@
"starterPluginLabel": "Starter Plugin", "starterPluginLabel": "Starter Plugin",
"starter": "Starter", "starter": "Starter",
"docs": "Docs", "docs": "Docs",
"sections": {
"officialTitle": "Official Plugins",
"officialDescription": "Maintained by the CloudCLI team and ready for direct install.",
"unofficialTitle": "Other Plugins",
"unofficialDescription": "Unofficial plugins and integrations from other users. Review the source before installing."
},
"starterPlugin": { "starterPlugin": {
"name": "Project Stats", "name": "Project Stats",
"badge": "starter", "badge": "starter",
@@ -490,18 +484,6 @@
"description": "Integrated terminal with full shell access directly within the interface.", "description": "Integrated terminal with full shell access directly within the interface.",
"install": "Install" "install": "Install"
}, },
"scheduledPromptPlugin": {
"name": "Scheduled Prompts",
"badge": "unofficial",
"description": "Schedule workspace prompts, review run history, and manage recurring local tasks.",
"install": "Install"
},
"claudeWatchPlugin": {
"name": "Claude Watch",
"badge": "unofficial",
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
"install": "Install"
},
"morePlugins": "More", "morePlugins": "More",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Remove from favorites", "removeFromFavorites": "Remove from favorites",
"editSessionName": "Manually edit session name", "editSessionName": "Manually edit session name",
"deleteSession": "Delete this session permanently", "deleteSession": "Delete this session permanently",
"activeSessionIndicator": "Recently active session (last 10 minutes)",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"clearSearch": "Clear search", "clearSearch": "Clear search",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Rimuovi dai preferiti", "removeFromFavorites": "Rimuovi dai preferiti",
"editSessionName": "Modifica manualmente il nome della sessione", "editSessionName": "Modifica manualmente il nome della sessione",
"deleteSession": "Elimina questa sessione permanentemente", "deleteSession": "Elimina questa sessione permanentemente",
"activeSessionIndicator": "Sessione attiva di recente (ultimi 10 minuti)",
"save": "Salva", "save": "Salva",
"cancel": "Annulla", "cancel": "Annulla",
"clearSearch": "Cancella ricerca", "clearSearch": "Cancella ricerca",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "お気に入りから削除", "removeFromFavorites": "お気に入りから削除",
"editSessionName": "セッション名を手動で編集", "editSessionName": "セッション名を手動で編集",
"deleteSession": "このセッションを完全に削除", "deleteSession": "このセッションを完全に削除",
"activeSessionIndicator": "最近アクティブなセッション過去10分以内",
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
"openCommandPalette": "コマンドパレットを開く" "openCommandPalette": "コマンドパレットを開く"

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "즐겨찾기에서 제거", "removeFromFavorites": "즐겨찾기에서 제거",
"editSessionName": "세션 이름 직접 편집", "editSessionName": "세션 이름 직접 편집",
"deleteSession": "이 세션 영구 삭제", "deleteSession": "이 세션 영구 삭제",
"activeSessionIndicator": "최근 활성 세션 (지난 10분)",
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
"openCommandPalette": "명령 팔레트 열기" "openCommandPalette": "명령 팔레트 열기"

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Удалить из избранного", "removeFromFavorites": "Удалить из избранного",
"editSessionName": "Вручную редактировать имя сеанса", "editSessionName": "Вручную редактировать имя сеанса",
"deleteSession": "Удалить этот сеанс навсегда", "deleteSession": "Удалить этот сеанс навсегда",
"activeSessionIndicator": "Недавно активный сеанс (последние 10 минут)",
"save": "Сохранить", "save": "Сохранить",
"cancel": "Отмена", "cancel": "Отмена",
"clearSearch": "Очистить поиск", "clearSearch": "Очистить поиск",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Favorilerden çıkar", "removeFromFavorites": "Favorilerden çıkar",
"editSessionName": "Oturum adını elle düzenle", "editSessionName": "Oturum adını elle düzenle",
"deleteSession": "Bu oturumu kalıcı olarak sil", "deleteSession": "Bu oturumu kalıcı olarak sil",
"activeSessionIndicator": "Yakın zamanda etkin oturum (son 10 dakika)",
"save": "Kaydet", "save": "Kaydet",
"cancel": "İptal", "cancel": "İptal",
"clearSearch": "Aramayı temizle", "clearSearch": "Aramayı temizle",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "从收藏移除", "removeFromFavorites": "从收藏移除",
"editSessionName": "手动编辑会话名称", "editSessionName": "手动编辑会话名称",
"deleteSession": "永久删除此会话", "deleteSession": "永久删除此会话",
"activeSessionIndicator": "最近活跃的会话(最近 10 分钟)",
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"clearSearch": "清除搜索", "clearSearch": "清除搜索",

View File

@@ -37,10 +37,6 @@ export default defineConfig(({ mode }) => {
'/shell': { '/shell': {
target: `ws://${proxyHost}:${serverPort}`, target: `ws://${proxyHost}:${serverPort}`,
ws: true ws: true
},
'/plugin-ws': {
target: `ws://${proxyHost}:${serverPort}`,
ws: true
} }
} }
}, },