Compare commits

..

7 Commits

Author SHA1 Message Date
Haileyesus
15d7419a3c fix: harden token usage reporting
Token usage is shown as concrete counts, so malformed provider payloads must not leak NaN.

Gemini can emit token fields as strings or invalid values, so non-finite values now fall back to 0.

OpenCode token reads happen while the CLI is shutting down, when the DB may be missing or locked.

Those failures now return null instead of interrupting session completion.

/cost no longer invents an input breakdown from an aggregate total.

When a provider only supplies total usage, the UI now says the breakdown is unavailable.

This keeps the display honest instead of presenting made-up input and output rows.

Verification: npm run typecheck; targeted eslint.
2026-05-29 17:41:19 +03:00
Haileyesus
d0cc85e76b fix: refine token usage reporting
The old token UI mixed context pressure, cache counters, and dollar estimates.

That made the percentage look precise even when provider data was incomplete or different.

The composer and /cost view now show concrete counts instead of a pie percentage.

Token payloads now share a smaller shape: used, inputTokens, outputTokens, and breakdown.

Claude uses per-step usage where available and Codex reads total_token_usage events.

Gemini reads its tokens object without inventing a context window.

OpenCode reads opencode.db session totals and includes all token columns in used.

The /cost backend no longer returns cache display fields or input/output dollar estimates.

This avoids derived values that look reliable but are not comparable across providers.

Verification: npm run typecheck; targeted eslint; OpenCode session provider test.
2026-05-29 15:06:55 +03:00
Haileyesus
661b8bd137 fix: update claude default model 2026-05-29 14:06:37 +03:00
Haileyesus
b80c7105d4 fix(claude): force fallback models and disable supportedModels lookup 2026-05-29 13:56:37 +03:00
Haileyesus
6f8fd37ab0 Merge branch 'main' of https://github.com/siteboon/claudecodeui 2026-05-29 13:55:17 +03:00
Haileyesus
50b3b90235 feat(cursor): update fallback models 2026-05-28 20:54:16 +03:00
Haileyesus
dd6614bca3 fix: remove the hide cursor on windows logic 2026-05-28 20:50:46 +03:00
18 changed files with 687 additions and 520 deletions

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,
},
};
}

View File

@@ -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,
});
};

View File

@@ -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 });

View File

@@ -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' }));
}
}
}

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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;

View File

@@ -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());

View File

@@ -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') }}

View File

@@ -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: {

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
)}

View File

@@ -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",