mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-31 00:55:42 +08:00
Compare commits
7 Commits
fix/plugin
...
fix/use-fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d7419a3c | ||
|
|
d0cc85e76b | ||
|
|
661b8bd137 | ||
|
|
b80c7105d4 | ||
|
|
6f8fd37ab0 | ||
|
|
50b3b90235 | ||
|
|
dd6614bca3 |
@@ -285,43 +285,68 @@ function transformMessage(sdkMessage) {
|
||||
return sdkMessage;
|
||||
}
|
||||
|
||||
function readNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts token usage from SDK result messages
|
||||
* @param {Object} resultMessage - SDK result message
|
||||
* Extracts token usage from SDK messages.
|
||||
* Prefers per-step `message.usage` (Claude message payload), then falls back
|
||||
* to result-level usage/modelUsage for compatibility across SDK versions.
|
||||
* @param {Object} sdkMessage - SDK stream message
|
||||
* @returns {Object|null} Token budget object or null
|
||||
*/
|
||||
function extractTokenBudget(resultMessage) {
|
||||
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
||||
function extractTokenBudget(sdkMessage) {
|
||||
if (!sdkMessage || typeof sdkMessage !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first model's usage data
|
||||
const modelKey = Object.keys(resultMessage.modelUsage)[0];
|
||||
const modelData = resultMessage.modelUsage[modelKey];
|
||||
|
||||
if (!modelData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use cumulative tokens if available (tracks total for the session)
|
||||
// Otherwise fall back to per-request tokens
|
||||
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
|
||||
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
|
||||
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
|
||||
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
|
||||
|
||||
// Total used = input + output + cache tokens
|
||||
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
|
||||
// Use configured context window budget from environment (default 160000)
|
||||
// This is the user's budget limit, not the model's context window
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
||||
|
||||
// Token calc logged via token-budget WS event
|
||||
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||
if (messageUsage && typeof messageUsage === 'object') {
|
||||
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||
|
||||
return {
|
||||
used: totalUsed,
|
||||
total: contextWindow
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fallback for older SDK messages with only modelUsage
|
||||
const modelKey = Object.keys(sdkMessage.modelUsage)[0];
|
||||
const modelData = sdkMessage.modelUsage[modelKey];
|
||||
|
||||
if (!modelData || typeof modelData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens);
|
||||
const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens);
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||
|
||||
return {
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -684,18 +709,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
ws.send(msg);
|
||||
}
|
||||
|
||||
// Extract and send token budget updates from result messages
|
||||
if (message.type === 'result') {
|
||||
const models = Object.keys(message.modelUsage || {});
|
||||
if (models.length > 0) {
|
||||
// Model info available in result message
|
||||
}
|
||||
// Extract and send token budget updates from assistant/result usage payloads
|
||||
const tokenBudgetData = extractTokenBudget(message);
|
||||
if (tokenBudgetData) {
|
||||
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up session on completion
|
||||
if (capturedSessionId) {
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
// Gemini Response Handler - JSON Stream processing
|
||||
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 {
|
||||
constructor(ws, options = {}) {
|
||||
@@ -60,6 +87,17 @@ class GeminiResponseHandler {
|
||||
for (const msg of normalized) {
|
||||
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() {
|
||||
|
||||
147
server/index.js
147
server/index.js
@@ -10,8 +10,9 @@ import { spawn } from 'child_process';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import mime from 'mime-types';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { AppError, WORKSPACES_ROOT, validateWorkspacePath } from '@/shared/utils.js';
|
||||
import { AppError, WORKSPACES_ROOT, getOpenCodeDatabasePath, validateWorkspacePath } from '@/shared/utils.js';
|
||||
import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js';
|
||||
import { createWebSocketServer } from '@/modules/websocket/index.js';
|
||||
|
||||
@@ -72,7 +73,7 @@ import geminiRoutes from './routes/gemini.js';
|
||||
import pluginsRoutes from './routes/plugins.js';
|
||||
import providerRoutes from './modules/providers/provider.routes.js';
|
||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||
import { initializeDatabase, projectsDb } from './modules/database/index.js';
|
||||
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
@@ -1141,35 +1142,129 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
breakdown: { input: 0, output: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Cursor sessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Gemini sessions - they are raw logs in our current setup
|
||||
if (provider === 'gemini') {
|
||||
const session = sessionsDb.getSessionById(safeSessionId);
|
||||
const sessionFilePath = session?.jsonl_path;
|
||||
if (!sessionFilePath) {
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
breakdown: { input: 0, output: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Gemini sessions'
|
||||
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({
|
||||
used: totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// OpenCode token totals are surfaced through provider history reads.
|
||||
// This legacy endpoint only knows file-backed session formats.
|
||||
if (provider === 'opencode') {
|
||||
const dbPath = getOpenCodeDatabasePath();
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return res.status(404).json({ error: 'OpenCode database not found' });
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
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,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
breakdown: { input: 0, output: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking is available in OpenCode session history, not this legacy endpoint'
|
||||
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
|
||||
if (provider === 'codex') {
|
||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
||||
@@ -1210,6 +1305,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
throw error;
|
||||
}
|
||||
const lines = fileContent.trim().split('\n');
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let totalTokens = 0;
|
||||
let contextWindow = 200000; // Default for Codex/OpenAI
|
||||
|
||||
@@ -1222,7 +1319,9 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const tokenInfo = entry.payload.info;
|
||||
if (tokenInfo.total_token_usage) {
|
||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
||||
inputTokens = tokenInfo.total_token_usage.input_tokens || 0;
|
||||
outputTokens = tokenInfo.total_token_usage.output_tokens || 0;
|
||||
totalTokens = tokenInfo.total_token_usage.total_tokens || inputTokens + outputTokens;
|
||||
}
|
||||
if (tokenInfo.model_context_window) {
|
||||
contextWindow = tokenInfo.model_context_window;
|
||||
@@ -1237,7 +1336,13 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
|
||||
return res.json({
|
||||
used: totalTokens,
|
||||
total: contextWindow
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1280,8 +1385,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||
let inputTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
// Find the latest assistant message with usage data (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
@@ -1294,8 +1398,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
|
||||
// Use token counts from latest assistant message only
|
||||
inputTokens = usage.input_tokens || 0;
|
||||
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
||||
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||
outputTokens = usage.output_tokens || 0;
|
||||
|
||||
break; // Stop after finding the latest assistant message
|
||||
}
|
||||
@@ -1305,16 +1408,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
||||
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
|
||||
res.json({
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
cacheCreation: cacheCreationTokens,
|
||||
cacheRead: cacheReadTokens
|
||||
output: outputTokens
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
|
||||
},
|
||||
],
|
||||
DEFAULT: 'default',
|
||||
DEFAULT: 'sonnet',
|
||||
};
|
||||
type ClaudeInitEvent = {
|
||||
sessionId?: string;
|
||||
|
||||
@@ -88,22 +88,15 @@ function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
|
||||
const record = tokens as AnyRecord;
|
||||
const input = Number(record.input || 0);
|
||||
const output = Number(record.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);
|
||||
const total = Number(record.total || input + output || 0);
|
||||
|
||||
return {
|
||||
used: total,
|
||||
total: total,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
breakdown: {
|
||||
input,
|
||||
output,
|
||||
cached,
|
||||
thoughts,
|
||||
tool,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ type OpenCodeHistoryRow = {
|
||||
type OpenCodeTokenTotals = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
reasoningTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
};
|
||||
|
||||
const openOpenCodeDatabase = (): Database.Database | null => {
|
||||
@@ -106,11 +106,13 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
|
||||
}
|
||||
|
||||
const inputTokens = totals.inputTokens;
|
||||
const displayInputTokens = inputTokens + totals.cacheReadTokens;
|
||||
const outputTokens = totals.outputTokens;
|
||||
const cacheReadTokens = totals.cacheReadTokens;
|
||||
const cacheCreationTokens = totals.cacheCreationTokens;
|
||||
const reasoningTokens = totals.reasoningTokens;
|
||||
const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
|
||||
const used = inputTokens
|
||||
+ outputTokens
|
||||
+ totals.reasoningTokens
|
||||
+ totals.cacheReadTokens
|
||||
+ totals.cacheWriteTokens;
|
||||
|
||||
if (used <= 0) {
|
||||
return undefined;
|
||||
@@ -118,14 +120,50 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
|
||||
|
||||
return {
|
||||
used,
|
||||
total: used,
|
||||
inputTokens,
|
||||
inputTokens: displayInputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
breakdown: {
|
||||
input: displayInputTokens,
|
||||
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
|
||||
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
|
||||
@@ -135,13 +173,18 @@ const aggregateOpenCodeSessionTokenUsage = (
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
): 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 }[];
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheWriteTokens = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const info = readJsonRecord(row.data);
|
||||
@@ -159,15 +202,15 @@ const aggregateOpenCodeSessionTokenUsage = (
|
||||
reasoningTokens += Number(tokens.reasoning ?? 0);
|
||||
const cache = readObjectRecord(tokens.cache);
|
||||
cacheReadTokens += Number(cache?.read ?? 0);
|
||||
cacheCreationTokens += Number(cache?.write ?? 0);
|
||||
cacheWriteTokens += Number(cache?.write ?? 0);
|
||||
}
|
||||
|
||||
return buildTokenUsage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -85,6 +85,12 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
|
||||
path TEXT,
|
||||
agent 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
|
||||
);
|
||||
|
||||
@@ -124,9 +130,10 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
|
||||
);
|
||||
db.prepare(`
|
||||
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(
|
||||
'open-session-1',
|
||||
'project-1',
|
||||
@@ -137,6 +144,11 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
|
||||
1_700_000_000_000,
|
||||
1_700_000_004_000,
|
||||
null,
|
||||
10,
|
||||
20,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
);
|
||||
|
||||
const userMessageData = JSON.stringify({
|
||||
@@ -302,12 +314,13 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
|
||||
assert.equal(history.messages[3]?.kind, 'tool_use');
|
||||
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
|
||||
assert.deepEqual(history.tokenUsage, {
|
||||
used: 35,
|
||||
total: 35,
|
||||
inputTokens: 10,
|
||||
used: 42,
|
||||
inputTokens: 13,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 3,
|
||||
cacheCreationTokens: 2,
|
||||
breakdown: {
|
||||
input: 13,
|
||||
output: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
|
||||
|
||||
@@ -23,6 +23,34 @@ import { createNormalizedMessage } from './shared/utils.js';
|
||||
// Track active sessions
|
||||
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
|
||||
* @param {object} event - SDK event
|
||||
@@ -316,9 +344,11 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
// Extract and send token usage if available (normalized to match Claude format)
|
||||
if (event.type === 'turn.completed' && event.usage) {
|
||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
||||
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
||||
if (event.type === 'turn.completed') {
|
||||
const tokenBudget = extractCodexTokenBudget(event);
|
||||
if (tokenBudget) {
|
||||
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { spawn } from 'child_process';
|
||||
import fsSync from 'node:fs';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
@@ -20,6 +22,66 @@ function readOpenCodeSessionId(event) {
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
||||
@@ -183,6 +245,17 @@ async function spawnOpenCode(command, options = {}, ws) {
|
||||
stdoutLineBuffer = '';
|
||||
}
|
||||
|
||||
const tokenBudget = readOpenCodeTokenUsage(finalSessionId);
|
||||
if (tokenBudget) {
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'status',
|
||||
text: 'token_budget',
|
||||
tokenBudget,
|
||||
sessionId: finalSessionId,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: code,
|
||||
|
||||
@@ -174,7 +174,7 @@ const builtInCommands = [
|
||||
},
|
||||
{
|
||||
name: "/cost",
|
||||
description: "Display token usage and cost information",
|
||||
description: "Display token usage information",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
},
|
||||
@@ -258,7 +258,7 @@ Custom commands can be created in:
|
||||
const catalog = (await providerModelsService.getProviderModels(provider)).models;
|
||||
const model = await resolveCommandModel(provider, catalog, context?.sessionId);
|
||||
|
||||
const used =
|
||||
const reportedUsed =
|
||||
Number(
|
||||
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
|
||||
) || 0;
|
||||
@@ -266,16 +266,15 @@ Custom commands can be created in:
|
||||
Number(
|
||||
tokenUsage.total ??
|
||||
tokenUsage.contextWindow ??
|
||||
parseInt(process.env.CONTEXT_WINDOW || "160000", 10),
|
||||
) || 160000;
|
||||
const percentage =
|
||||
total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
|
||||
|
||||
0,
|
||||
) || 0;
|
||||
const inputTokensRaw =
|
||||
Number(
|
||||
tokenUsage.inputTokens ??
|
||||
tokenUsage.input ??
|
||||
tokenUsage.input_tokens ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.breakdown?.input ??
|
||||
tokenUsage.promptTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
@@ -283,36 +282,14 @@ Custom commands can be created in:
|
||||
Number(
|
||||
tokenUsage.outputTokens ??
|
||||
tokenUsage.output ??
|
||||
tokenUsage.output_tokens ??
|
||||
tokenUsage.cumulativeOutputTokens ??
|
||||
tokenUsage.breakdown?.output ??
|
||||
tokenUsage.completionTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const cacheTokens =
|
||||
Number(
|
||||
tokenUsage.cacheReadTokens ??
|
||||
tokenUsage.cacheCreationTokens ??
|
||||
tokenUsage.cacheTokens ??
|
||||
tokenUsage.cachedTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
|
||||
// If we only have total used tokens, treat them as input for display/estimation.
|
||||
const inputTokens =
|
||||
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0
|
||||
? inputTokensRaw + cacheTokens
|
||||
: used;
|
||||
|
||||
// Rough default rates by provider (USD / 1M tokens).
|
||||
const pricingByProvider = {
|
||||
claude: { input: 3, output: 15 },
|
||||
cursor: { input: 3, output: 15 },
|
||||
codex: { input: 1.5, output: 6 },
|
||||
};
|
||||
const rates = pricingByProvider[provider] || pricingByProvider.claude;
|
||||
|
||||
const inputCost = (inputTokens / 1_000_000) * rates.input;
|
||||
const outputCost = (outputTokens / 1_000_000) * rates.output;
|
||||
const totalCost = inputCost + outputCost;
|
||||
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
|
||||
const used = reportedUsed || inputTokensRaw + outputTokens;
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
@@ -321,18 +298,15 @@ Custom commands can be created in:
|
||||
tokenUsage: {
|
||||
used,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
...(hasTokenBreakdown
|
||||
? {
|
||||
tokenBreakdown: {
|
||||
input: inputTokens,
|
||||
input: inputTokensRaw,
|
||||
output: outputTokens,
|
||||
cache: cacheTokens,
|
||||
},
|
||||
cost: {
|
||||
input: inputCost.toFixed(4),
|
||||
output: outputCost.toFixed(4),
|
||||
total: totalCost.toFixed(4),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
provider,
|
||||
model,
|
||||
},
|
||||
|
||||
@@ -97,17 +97,10 @@ export type CostCommandData = {
|
||||
tokenUsage?: {
|
||||
used?: number;
|
||||
total?: number;
|
||||
percentage?: number;
|
||||
};
|
||||
cost?: {
|
||||
input?: string;
|
||||
output?: string;
|
||||
total?: string;
|
||||
};
|
||||
tokenBreakdown?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache?: number;
|
||||
};
|
||||
provider?: string;
|
||||
model?: string;
|
||||
|
||||
@@ -624,19 +624,23 @@ export function useChatSessionState({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
||||
|
||||
// Token usage fetch for Claude
|
||||
// Initial token usage fetch for providers with file-backed usage data.
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id) {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'claude') return;
|
||||
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
// Token usage endpoint is now keyed by the DB projectId.
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
|
||||
const params = new URLSearchParams({ provider: sessionProvider });
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
setTokenBudget(await response.json());
|
||||
|
||||
@@ -18,7 +18,7 @@ import ClaudeStatus from './ClaudeStatus';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||
import TokenUsagePie from './TokenUsagePie';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputHeader,
|
||||
@@ -60,7 +60,7 @@ interface ChatComposerProps {
|
||||
onModeSwitch: () => void;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||
tokenBudget: { used?: number; total?: number } | null;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
@@ -361,7 +361,7 @@ export default function ChatComposer({
|
||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||
)}
|
||||
|
||||
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
||||
<TokenUsageSummary usage={tokenBudget} />
|
||||
|
||||
<PromptInputButton
|
||||
tooltip={{ content: t('input.showAllCommands') }}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CircleHelp,
|
||||
Clipboard,
|
||||
Coins,
|
||||
Command as CommandIcon,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Package,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
Timer,
|
||||
RefreshCw,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
|
||||
@@ -84,7 +82,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
|
||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||
{ name: '/models', description: 'Browse available models for the active provider.' },
|
||||
{ name: '/cost', description: 'Review context usage and estimated token spend.' },
|
||||
{ name: '/cost', description: 'Review token usage for the active session.' },
|
||||
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
|
||||
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
|
||||
{ name: '/config', description: 'Open settings and configuration.' },
|
||||
@@ -99,13 +97,6 @@ const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') =>
|
||||
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) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0';
|
||||
@@ -113,11 +104,6 @@ const formatNumber = (value: number) => {
|
||||
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({
|
||||
label,
|
||||
value,
|
||||
@@ -507,46 +493,59 @@ function ModelsContent({
|
||||
function CostContent({ data }: { data: CostCommandData }) {
|
||||
const used = Number(data.tokenUsage?.used ?? 0);
|
||||
const total = Number(data.tokenUsage?.total ?? 0);
|
||||
const percentage = clampPercentage(Number(data.tokenUsage?.percentage ?? 0));
|
||||
const model = data.model || 'Unknown';
|
||||
const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
|
||||
const inputTokens = Number(data.tokenBreakdown?.input ?? 0);
|
||||
const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
|
||||
const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0);
|
||||
const totalCost = Number(data.cost?.total ?? 0);
|
||||
const hasBreakdown =
|
||||
typeof data.tokenBreakdown?.input === 'number' ||
|
||||
typeof data.tokenBreakdown?.output === 'number';
|
||||
const usageRows = [
|
||||
{ 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/70 bg-background/75">
|
||||
{usageRows.map((row) => {
|
||||
const Icon = row.icon;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
|
||||
<div className="rounded-3xl border border-primary/25 bg-primary/10 p-5 text-center">
|
||||
<div
|
||||
className="mx-auto grid h-40 w-40 place-items-center rounded-full p-2 shadow-inner"
|
||||
style={{
|
||||
background: `conic-gradient(hsl(var(--primary)) ${percentage * 3.6}deg, hsl(var(--muted)) 0deg)`,
|
||||
}}
|
||||
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>
|
||||
<p className="font-mono text-3xl font-semibold text-foreground">{percentage.toFixed(1)}%</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">context</p>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<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>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{formatNumber(used)} of {formatNumber(total)} tokens used
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricCard label="Input" value={formatCurrency(data.cost?.input)} icon={Zap} />
|
||||
<MetricCard label="Output" value={formatCurrency(data.cost?.output)} icon={Activity} />
|
||||
<MetricCard label="Total" value={formatCurrency(totalCost)} icon={Coins} tone="primary" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<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">
|
||||
@@ -560,10 +559,6 @@ function CostContent({ data }: { data: CostCommandData }) {
|
||||
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
|
||||
</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>
|
||||
);
|
||||
@@ -636,8 +631,8 @@ export default function CommandResultModal({
|
||||
},
|
||||
cost: {
|
||||
eyebrow: 'Session telemetry',
|
||||
title: 'Usage & Cost',
|
||||
subtitle: 'Token budget, context pressure, and estimated spend for this session.',
|
||||
title: 'Token Usage',
|
||||
subtitle: 'Input, output, and total token counts for this session.',
|
||||
icon: Coins,
|
||||
},
|
||||
status: {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
type TokenUsagePieProps = {
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
|
||||
// Token usage visualization component
|
||||
// Only bail out on missing values or non‐positive 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>
|
||||
);
|
||||
}
|
||||
53
src/components/chat/view/subcomponents/TokenUsageSummary.tsx
Normal file
53
src/components/chat/view/subcomponents/TokenUsageSummary.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +1,12 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Clock,
|
||||
Download,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ServerCrash,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
||||
import { usePlugins } from '../../../contexts/PluginsContext';
|
||||
import type { Plugin } from '../../../contexts/PluginsContext';
|
||||
|
||||
import PluginIcon from './PluginIcon';
|
||||
|
||||
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 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 ─────────────────────────────────────────────────────── */
|
||||
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
|
||||
@@ -289,95 +208,117 @@ function PluginCard({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Recommendation Section ────────────────────────────────────────────── */
|
||||
function RecommendationSection({
|
||||
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;
|
||||
}) {
|
||||
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
|
||||
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
||||
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 (
|
||||
<div className={`relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 ${hoverClass}`}>
|
||||
<div className={`w-[3px] flex-shrink-0 ${accentClass}`} />
|
||||
<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 ${iconClass}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</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.${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 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.${recommendation.translationKey}.description`)}
|
||||
{t('pluginSettings.starterPlugin.description')}
|
||||
</p>
|
||||
<a
|
||||
href={recommendation.repoUrl}
|
||||
href={STARTER_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" />
|
||||
{repoSlug(recommendation.repoUrl)}
|
||||
cloudcli-ai/cloudcli-plugin-starter
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onInstall}
|
||||
disabled={disabled}
|
||||
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"
|
||||
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.${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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,7 +334,8 @@ export default function PluginSettingsTab() {
|
||||
|
||||
const [gitUrl, setGitUrl] = useState('');
|
||||
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 [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
|
||||
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
|
||||
@@ -422,18 +364,24 @@ export default function PluginSettingsTab() {
|
||||
setInstalling(false);
|
||||
};
|
||||
|
||||
const handleInstallRecommendation = async (recommendation: PluginRecommendation) => {
|
||||
if (installingRecommendation) return;
|
||||
setInstallingRecommendation(recommendation.id);
|
||||
const handleInstallStarter = async () => {
|
||||
setInstallingStarter(true);
|
||||
setInstallError(null);
|
||||
try {
|
||||
const result = await installPlugin(recommendation.repoUrl);
|
||||
const result = await installPlugin(STARTER_PLUGIN_URL);
|
||||
if (!result.success) {
|
||||
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) => {
|
||||
@@ -450,50 +398,8 @@ export default function PluginSettingsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const isRecommendationInstalled = (recommendation: PluginRecommendation) => {
|
||||
return plugins.some((plugin) => pluginMatchesRecommendation(plugin, recommendation));
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
|
||||
const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -550,49 +456,51 @@ export default function PluginSettingsTab() {
|
||||
</span>
|
||||
</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 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('pluginSettings.scanningPlugins')}
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{hasOfficialSection && (
|
||||
<RecommendationSection
|
||||
title={t('pluginSettings.sections.officialTitle')}
|
||||
description={t('pluginSettings.sections.officialDescription')}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{plugins.map((plugin, index) => {
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
const r = await togglePlugin(plugin.name, enabled);
|
||||
if (!r.success) {
|
||||
setInstallError(r.error || t('pluginSettings.toggleFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
{hasOtherSection && (
|
||||
<RecommendationSection
|
||||
title={t('pluginSettings.sections.unofficialTitle')}
|
||||
description={t('pluginSettings.sections.unofficialDescription')}
|
||||
>
|
||||
{otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))}
|
||||
{unofficialRecommendations.map((recommendation) => (
|
||||
<PluginRecommendationCard
|
||||
key={recommendation.id}
|
||||
recommendation={recommendation}
|
||||
onInstall={() => void handleInstallRecommendation(recommendation)}
|
||||
disabled={!!installingRecommendation}
|
||||
installing={installingRecommendation === recommendation.id}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</RecommendationSection>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -472,12 +472,6 @@
|
||||
"starterPluginLabel": "Starter Plugin",
|
||||
"starter": "Starter",
|
||||
"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": {
|
||||
"name": "Project Stats",
|
||||
"badge": "starter",
|
||||
@@ -490,18 +484,6 @@
|
||||
"description": "Integrated terminal with full shell access directly within the interface.",
|
||||
"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",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
|
||||
Reference in New Issue
Block a user