mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-31 00:55:42 +08:00
Compare commits
3 Commits
fix/use-fa
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d291d3efb | ||
|
|
6bf82a39bb | ||
|
|
3b79aab958 |
@@ -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' }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
159
server/index.js
159
server/index.js
@@ -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) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
|||||||
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
|
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
DEFAULT: 'sonnet',
|
DEFAULT: 'default',
|
||||||
};
|
};
|
||||||
type ClaudeInitEvent = {
|
type ClaudeInitEvent = {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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' }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -361,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') }}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
54
src/components/chat/view/subcomponents/TokenUsagePie.tsx
Normal file
54
src/components/chat/view/subcomponents/TokenUsagePie.tsx
Normal 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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,93 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
import {
|
||||||
|
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 }) {
|
||||||
@@ -208,117 +289,95 @@ function PluginCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
|
/* ─── Recommendation Section ────────────────────────────────────────────── */
|
||||||
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
function RecommendationSection({
|
||||||
const { t } = useTranslation('settings');
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
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">
|
<section className="space-y-2">
|
||||||
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
<div>
|
||||||
<div className="min-w-0 flex-1 p-4">
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<div className="flex items-start justify-between gap-3">
|
{title}
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
</h4>
|
||||||
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
|
<p className="mt-0.5 text-xs text-muted-foreground/70">
|
||||||
<BarChart3 className="h-5 w-5" />
|
{description}
|
||||||
</div>
|
</p>
|
||||||
<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.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.starterPlugin.description')}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
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" />
|
|
||||||
cloudcli-ai/cloudcli-plugin-starter
|
|
||||||
</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.starterPlugin.install')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */
|
/* ─── Plugin Recommendation Card ────────────────────────────────────────── */
|
||||||
function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
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 hover:border-blue-400 dark:hover:border-blue-500">
|
<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 bg-blue-500/30" />
|
<div className={`w-[3px] flex-shrink-0 ${accentClass}`} />
|
||||||
<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 text-blue-500">
|
<div className={`h-5 w-5 flex-shrink-0 ${iconClass}`}>
|
||||||
<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">
|
<Icon 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>
|
||||||
<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.terminalPlugin.name')}
|
{t(`pluginSettings.${recommendation.translationKey}.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>
|
||||||
<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.terminalPlugin.description')}
|
{t(`pluginSettings.${recommendation.translationKey}.description`)}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={TERMINAL_PLUGIN_URL}
|
href={recommendation.repoUrl}
|
||||||
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" />
|
||||||
cloudcli-ai/cloudcli-plugin-terminal
|
{repoSlug(recommendation.repoUrl)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onInstall}
|
onClick={onInstall}
|
||||||
disabled={installing}
|
disabled={disabled}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{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.terminalPlugin.install')}
|
{installing ? t('pluginSettings.installing') : t(`pluginSettings.${recommendation.translationKey}.install`)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,8 +393,7 @@ export default function PluginSettingsTab() {
|
|||||||
|
|
||||||
const [gitUrl, setGitUrl] = useState('');
|
const [gitUrl, setGitUrl] = useState('');
|
||||||
const [installing, setInstalling] = useState(false);
|
const [installing, setInstalling] = useState(false);
|
||||||
const [installingStarter, setInstallingStarter] = useState(false);
|
const [installingRecommendation, setInstallingRecommendation] = useState<string | null>(null);
|
||||||
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());
|
||||||
@@ -364,24 +422,18 @@ export default function PluginSettingsTab() {
|
|||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstallStarter = async () => {
|
const handleInstallRecommendation = async (recommendation: PluginRecommendation) => {
|
||||||
setInstallingStarter(true);
|
if (installingRecommendation) return;
|
||||||
|
setInstallingRecommendation(recommendation.id);
|
||||||
setInstallError(null);
|
setInstallError(null);
|
||||||
const result = await installPlugin(STARTER_PLUGIN_URL);
|
try {
|
||||||
if (!result.success) {
|
const result = await installPlugin(recommendation.repoUrl);
|
||||||
setInstallError(result.error || t('pluginSettings.installFailed'));
|
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) => {
|
const handleUninstall = async (name: string) => {
|
||||||
@@ -398,8 +450,50 @@ export default function PluginSettingsTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
|
const isRecommendationInstalled = (recommendation: PluginRecommendation) => {
|
||||||
const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal');
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -456,51 +550,49 @@ export default function PluginSettingsTab() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Official plugin suggestions — above the list */}
|
{/* Plugin sections */}
|
||||||
{!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-2">
|
<div className="space-y-4">
|
||||||
{plugins.map((plugin, index) => {
|
{hasOfficialSection && (
|
||||||
const handleToggle = async (enabled: boolean) => {
|
<RecommendationSection
|
||||||
const r = await togglePlugin(plugin.name, enabled);
|
title={t('pluginSettings.sections.officialTitle')}
|
||||||
if (!r.success) {
|
description={t('pluginSettings.sections.officialDescription')}
|
||||||
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>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{hasOtherSection && (
|
||||||
<PluginCard
|
<RecommendationSection
|
||||||
key={plugin.name}
|
title={t('pluginSettings.sections.unofficialTitle')}
|
||||||
plugin={plugin}
|
description={t('pluginSettings.sections.unofficialDescription')}
|
||||||
index={index}
|
>
|
||||||
onToggle={(enabled) => void handleToggle(enabled)}
|
{otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))}
|
||||||
onUpdate={() => void handleUpdate(plugin.name)}
|
{unofficialRecommendations.map((recommendation) => (
|
||||||
onUninstall={() => void handleUninstall(plugin.name)}
|
<PluginRecommendationCard
|
||||||
updating={updatingPlugins.has(plugin.name)}
|
key={recommendation.id}
|
||||||
confirmingUninstall={confirmUninstall === plugin.name}
|
recommendation={recommendation}
|
||||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
onInstall={() => void handleInstallRecommendation(recommendation)}
|
||||||
updateError={updateErrors[plugin.name] ?? null}
|
disabled={!!installingRecommendation}
|
||||||
/>
|
installing={installingRecommendation === recommendation.id}
|
||||||
);
|
/>
|
||||||
})}
|
))}
|
||||||
|
</RecommendationSection>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -472,6 +472,12 @@
|
|||||||
"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",
|
||||||
@@ -484,6 +490,18 @@
|
|||||||
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user