glad-web 1.0.38 → 1.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/lib/commands/web.js +6 -1
- package/lib/server/routes/usage.js +23 -0
- package/lib/usage/ccusage-runner.js +105 -0
- package/lib/usage/source-catalog.js +26 -0
- package/lib/usage/usage-service.js +226 -0
- package/lib/web/claude.js +22 -8
- package/lib/web/index.html +88 -1
- package/lib/web/styles.css +81 -0
- package/lib/web/usage.js +323 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ Glad was created to enable **vibe coding** on mobile devices. By bringing variou
|
|
|
34
34
|
|
|
35
35
|
Our design philosophy is **Easy to use, Stable, and Restrained**. Glad focuses strictly on the essentials:
|
|
36
36
|
- **Session management:** Run multiple sessions from a single dashboard with per-session working directories.
|
|
37
|
+
- **Local usage dashboard:** Select a week or month, compare per-model and daily token totals, and inspect model-stacked token and cost charts through the bundled read-only `ccusage` engine. Costs use `ccusage` estimates and are shown only for GPT models used by Codex.
|
|
37
38
|
- **High-fidelity terminal interaction:** A mobile-friendly terminal experience with touch shortcuts.
|
|
38
39
|
- **Extreme performance history viewing:** Fast and responsive text history.
|
|
39
40
|
- **Simple but effective change checking:** Integrated Git changes preview.
|
package/README.zh-CN.md
CHANGED
|
@@ -34,6 +34,7 @@ Glad 的初衷是开发一款完全运行在本地的、足够简单的,且登
|
|
|
34
34
|
|
|
35
35
|
我们的设计哲学是:**易用、稳定、克制**。只提供最核心且体验优秀的功能:
|
|
36
36
|
- **Session 管理**:在一个面板中管理多个会话,每个会话可单独指定工作目录。
|
|
37
|
+
- **本地用量看板**:通过内置的只读 `ccusage` 引擎选择某周或某月,查看按模型汇总及每日 token,并用按模型堆叠的柱状图比较 token 和费用;费用完全采用 `ccusage` 估算,且只对 Codex 使用的 GPT 模型显示。
|
|
37
38
|
- **高还原度的 terminal 交互**:专为手机优化的终端体验与快捷按键。
|
|
38
39
|
- **极致性能的历史查看**:快速流畅的终端历史记录浏览。
|
|
39
40
|
- **简单但足够好用的改动检查**:内置 Git 改动预览功能。
|
package/lib/commands/web.js
CHANGED
|
@@ -47,6 +47,8 @@ const registerScheduleRoutes = require('../server/routes/schedules');
|
|
|
47
47
|
const registerWorkspaceRoutes = require('../server/routes/workspace');
|
|
48
48
|
const registerProviderRoutes = require('../server/routes/providers');
|
|
49
49
|
const registerNotificationRoutes = require('../server/routes/notifications');
|
|
50
|
+
const registerUsageRoutes = require('../server/routes/usage');
|
|
51
|
+
const { UsageService } = require('../usage/usage-service');
|
|
50
52
|
const { ServerChanSettingsStore } = require('../notifications/serverchan-settings-store');
|
|
51
53
|
const ServerChanClient = require('../notifications/serverchan-client');
|
|
52
54
|
const NotificationService = require('../notifications/notification-service');
|
|
@@ -101,6 +103,7 @@ async function webCommand(options) {
|
|
|
101
103
|
channel: new ServerChanClient(),
|
|
102
104
|
logger
|
|
103
105
|
});
|
|
106
|
+
const usageService = new UsageService({ logger });
|
|
104
107
|
sessionManager.on('output', ({ sessionId, data }) => {
|
|
105
108
|
broadcastToSession(sessionId, { type: 'output', data });
|
|
106
109
|
});
|
|
@@ -143,6 +146,7 @@ async function webCommand(options) {
|
|
|
143
146
|
settingsStore: serverChanSettings,
|
|
144
147
|
notificationService
|
|
145
148
|
});
|
|
149
|
+
registerUsageRoutes(app, { usageService, sendJson: sendCompressedJson });
|
|
146
150
|
|
|
147
151
|
// API: List all active sessions
|
|
148
152
|
app.get('/api/sessions', (req, res) => {
|
|
@@ -481,7 +485,8 @@ async function webCommand(options) {
|
|
|
481
485
|
'composer.js',
|
|
482
486
|
'timed-inputs.js',
|
|
483
487
|
'terminal-scroll.js',
|
|
484
|
-
'git.js'
|
|
488
|
+
'git.js',
|
|
489
|
+
'usage.js'
|
|
485
490
|
];
|
|
486
491
|
for (const assetName of webAssets) {
|
|
487
492
|
const escapedName = assetName.replace('.', '\\.');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module.exports = function registerUsageRoutes(app, { usageService, sendJson = (_req, res, payload) => res.json(payload) }) {
|
|
2
|
+
app.get('/api/usage/sources', async (req, res) => {
|
|
3
|
+
try {
|
|
4
|
+
res.json(await usageService.listSources(req.query.refresh === '1'));
|
|
5
|
+
} catch (error) {
|
|
6
|
+
res.status(error.statusCode || 500).json({ error: error.message || 'Failed to load usage sources' });
|
|
7
|
+
}
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
app.get('/api/usage/report', async (req, res) => {
|
|
11
|
+
try {
|
|
12
|
+
const report = await usageService.getDashboard(
|
|
13
|
+
req.query.source,
|
|
14
|
+
req.query.scope || 'weekly',
|
|
15
|
+
req.query.period,
|
|
16
|
+
req.query.refresh === '1'
|
|
17
|
+
);
|
|
18
|
+
sendJson(req, res, report);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
res.status(error.statusCode || 500).json({ error: error.message || 'Failed to load usage report' });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
|
|
3
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 45000;
|
|
5
|
+
|
|
6
|
+
const NATIVE_PACKAGES = {
|
|
7
|
+
'darwin-arm64': '@ccusage/ccusage-darwin-arm64',
|
|
8
|
+
'darwin-x64': '@ccusage/ccusage-darwin-x64',
|
|
9
|
+
'linux-arm64': '@ccusage/ccusage-linux-arm64',
|
|
10
|
+
'linux-x64': '@ccusage/ccusage-linux-x64',
|
|
11
|
+
'win32-arm64': '@ccusage/ccusage-win32-arm64',
|
|
12
|
+
'win32-x64': '@ccusage/ccusage-win32-x64'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function resolveCcusageBinary(platform = process.platform, arch = process.arch) {
|
|
16
|
+
const packageName = NATIVE_PACKAGES[`${platform}-${arch}`];
|
|
17
|
+
if (!packageName) {
|
|
18
|
+
throw new Error(`ccusage is not available for ${platform}-${arch}`);
|
|
19
|
+
}
|
|
20
|
+
const binaryName = platform === 'win32' ? 'ccusage.exe' : 'ccusage';
|
|
21
|
+
try {
|
|
22
|
+
return require.resolve(`${packageName}/bin/${binaryName}`);
|
|
23
|
+
} catch (_error) {
|
|
24
|
+
throw new Error(`ccusage native package is missing for ${platform}-${arch}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function reportArgs(timezone) {
|
|
29
|
+
return [
|
|
30
|
+
'daily',
|
|
31
|
+
'--sections', 'daily,weekly,monthly',
|
|
32
|
+
'--by-agent',
|
|
33
|
+
'--json',
|
|
34
|
+
'--offline',
|
|
35
|
+
'--timezone', timezone
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class CcusageRunner {
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
this.binaryPath = options.binaryPath || resolveCcusageBinary();
|
|
42
|
+
this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
43
|
+
this.spawnProcess = options.spawnProcess || spawn;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
loadAllPeriods(timezone) {
|
|
47
|
+
return this.runJson(reportArgs(timezone));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
runJson(args) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const child = this.spawnProcess(this.binaryPath, args, {
|
|
53
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
54
|
+
shell: false,
|
|
55
|
+
windowsHide: true
|
|
56
|
+
});
|
|
57
|
+
const stdout = [];
|
|
58
|
+
const stderr = [];
|
|
59
|
+
let outputBytes = 0;
|
|
60
|
+
let stderrBytes = 0;
|
|
61
|
+
let settled = false;
|
|
62
|
+
|
|
63
|
+
const finish = callback => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
callback();
|
|
68
|
+
};
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
child.kill();
|
|
71
|
+
finish(() => reject(new Error('ccusage timed out while reading local usage data')));
|
|
72
|
+
}, this.timeoutMs);
|
|
73
|
+
|
|
74
|
+
child.stdout.on('data', chunk => {
|
|
75
|
+
outputBytes += chunk.length;
|
|
76
|
+
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
77
|
+
child.kill();
|
|
78
|
+
finish(() => reject(new Error('ccusage report exceeded the safe output limit')));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
stdout.push(chunk);
|
|
82
|
+
});
|
|
83
|
+
child.stderr.on('data', chunk => {
|
|
84
|
+
if (stderrBytes >= 64 * 1024) return;
|
|
85
|
+
stderr.push(chunk);
|
|
86
|
+
stderrBytes += chunk.length;
|
|
87
|
+
});
|
|
88
|
+
child.on('error', error => finish(() => reject(new Error(`Unable to start ccusage: ${error.message}`))));
|
|
89
|
+
child.on('close', code => finish(() => {
|
|
90
|
+
const errorText = Buffer.concat(stderr).toString('utf8').trim();
|
|
91
|
+
if (code !== 0) {
|
|
92
|
+
reject(new Error(errorText || `ccusage exited with code ${code}`));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')));
|
|
97
|
+
} catch (_error) {
|
|
98
|
+
reject(new Error('ccusage returned invalid JSON'));
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { CcusageRunner, reportArgs, resolveCcusageBinary };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SOURCES = [
|
|
2
|
+
{ id: 'codex', label: 'Codex', badge: 'CX' },
|
|
3
|
+
{ id: 'claude', label: 'Claude', badge: 'CL' },
|
|
4
|
+
{ id: 'gemini', label: 'Gemini', badge: 'GE' },
|
|
5
|
+
{ id: 'opencode', label: 'OpenCode', badge: 'OC' },
|
|
6
|
+
{ id: 'copilot', label: 'Copilot', badge: 'CP' },
|
|
7
|
+
{ id: 'amp', label: 'Amp', badge: 'AM' },
|
|
8
|
+
{ id: 'droid', label: 'Droid', badge: 'DR' },
|
|
9
|
+
{ id: 'codebuff', label: 'Codebuff', badge: 'CB' },
|
|
10
|
+
{ id: 'hermes', label: 'Hermes', badge: 'HE' },
|
|
11
|
+
{ id: 'pi', label: 'Pi', badge: 'PI' },
|
|
12
|
+
{ id: 'goose', label: 'Goose', badge: 'GO' },
|
|
13
|
+
{ id: 'kilo', label: 'Kilo', badge: 'KI' },
|
|
14
|
+
{ id: 'kimi', label: 'Kimi', badge: 'KM' },
|
|
15
|
+
{ id: 'qwen', label: 'Qwen', badge: 'QW' },
|
|
16
|
+
{ id: 'openclaw', label: 'OpenClaw', badge: 'OA' },
|
|
17
|
+
{ id: 'grok', label: 'Grok', badge: 'GR' }
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const SOURCE_BY_ID = new Map(SOURCES.map(source => [source.id, source]));
|
|
21
|
+
|
|
22
|
+
function getUsageSource(id) {
|
|
23
|
+
return SOURCE_BY_ID.get(String(id || '').toLowerCase()) || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { SOURCES, getUsageSource };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const { CcusageRunner } = require('./ccusage-runner');
|
|
2
|
+
const { SOURCES, getUsageSource } = require('./source-catalog');
|
|
3
|
+
|
|
4
|
+
const SCOPES = new Set(['weekly', 'monthly']);
|
|
5
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
function defaultTimezone() {
|
|
8
|
+
try {
|
|
9
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
10
|
+
} catch (_error) {
|
|
11
|
+
return 'UTC';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ccusageVersion() {
|
|
16
|
+
try {
|
|
17
|
+
return require('ccusage/package.json').version;
|
|
18
|
+
} catch (_error) {
|
|
19
|
+
return 'unknown';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function positiveNumber(value) {
|
|
24
|
+
const number = Number(value);
|
|
25
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isGptModel(modelName) {
|
|
29
|
+
return /^gpt(?:-|$)/i.test(String(modelName || ''));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function modelCost(sourceId, modelName, cost) {
|
|
33
|
+
if (sourceId !== 'codex' || !isGptModel(modelName)) return null;
|
|
34
|
+
const value = positiveNumber(cost);
|
|
35
|
+
return value > 0 ? value : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeModel(sourceId, breakdown) {
|
|
39
|
+
const uncachedInputTokens = positiveNumber(breakdown.inputTokens)
|
|
40
|
+
+ positiveNumber(breakdown.cacheCreationTokens);
|
|
41
|
+
const cachedInputTokens = positiveNumber(breakdown.cacheReadTokens);
|
|
42
|
+
const outputTokens = positiveNumber(breakdown.outputTokens);
|
|
43
|
+
return {
|
|
44
|
+
modelName: String(breakdown.modelName || 'Unknown'),
|
|
45
|
+
uncachedInputTokens,
|
|
46
|
+
cachedInputTokens,
|
|
47
|
+
outputTokens,
|
|
48
|
+
totalTokens: uncachedInputTokens + cachedInputTokens + outputTokens,
|
|
49
|
+
estimatedCostUSD: modelCost(sourceId, breakdown.modelName, breakdown.cost)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sumModels(models) {
|
|
54
|
+
return models.reduce((totals, model) => {
|
|
55
|
+
totals.uncachedInputTokens += model.uncachedInputTokens;
|
|
56
|
+
totals.cachedInputTokens += model.cachedInputTokens;
|
|
57
|
+
totals.outputTokens += model.outputTokens;
|
|
58
|
+
totals.totalTokens += model.totalTokens;
|
|
59
|
+
if (model.estimatedCostUSD !== null) {
|
|
60
|
+
totals.estimatedCostUSD = (totals.estimatedCostUSD || 0) + model.estimatedCostUSD;
|
|
61
|
+
}
|
|
62
|
+
return totals;
|
|
63
|
+
}, {
|
|
64
|
+
uncachedInputTokens: 0,
|
|
65
|
+
cachedInputTokens: 0,
|
|
66
|
+
outputTokens: 0,
|
|
67
|
+
totalTokens: 0,
|
|
68
|
+
estimatedCostUSD: null
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fallbackModel(sourceId, agentRow) {
|
|
73
|
+
const names = Array.isArray(agentRow.modelsUsed) ? agentRow.modelsUsed : [];
|
|
74
|
+
const modelName = names.length === 1 ? names[0] : names.length > 1 ? 'Multiple models' : 'Unknown';
|
|
75
|
+
return normalizeModel(sourceId, {
|
|
76
|
+
modelName,
|
|
77
|
+
inputTokens: agentRow.inputTokens,
|
|
78
|
+
cacheCreationTokens: agentRow.cacheCreationTokens,
|
|
79
|
+
cacheReadTokens: agentRow.cacheReadTokens,
|
|
80
|
+
outputTokens: agentRow.outputTokens,
|
|
81
|
+
cost: names.length === 1 ? agentRow.totalCost : null
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeAgentRow(sourceId, period, agentRow) {
|
|
86
|
+
const breakdowns = Array.isArray(agentRow.modelBreakdowns) ? agentRow.modelBreakdowns : [];
|
|
87
|
+
const models = breakdowns.length
|
|
88
|
+
? breakdowns.map(item => normalizeModel(sourceId, item))
|
|
89
|
+
: [fallbackModel(sourceId, agentRow)];
|
|
90
|
+
models.sort((a, b) => b.totalTokens - a.totalTokens || a.modelName.localeCompare(b.modelName));
|
|
91
|
+
return { period, models, totals: sumModels(models) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findAgentRow(row, sourceId) {
|
|
95
|
+
return Array.isArray(row && row.agents)
|
|
96
|
+
? row.agents.find(agent => agent && agent.agent === sourceId) || null
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function availablePeriods(raw, scope, sourceId) {
|
|
101
|
+
return (raw[scope] || [])
|
|
102
|
+
.filter(row => findAgentRow(row, sourceId))
|
|
103
|
+
.map(row => String(row.period || ''))
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.sort()
|
|
106
|
+
.reverse();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function dateInsideScope(date, scope, selectedPeriod) {
|
|
110
|
+
if (scope === 'monthly') return date.startsWith(`${selectedPeriod}-`);
|
|
111
|
+
const start = new Date(`${selectedPeriod}T00:00:00Z`);
|
|
112
|
+
const candidate = new Date(`${date}T00:00:00Z`);
|
|
113
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(candidate.getTime())) return false;
|
|
114
|
+
const end = new Date(start);
|
|
115
|
+
end.setUTCDate(end.getUTCDate() + 7);
|
|
116
|
+
return candidate >= start && candidate < end;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildDashboard(raw, sourceId, scope, requestedPeriod) {
|
|
120
|
+
const periods = availablePeriods(raw, scope, sourceId);
|
|
121
|
+
const selectedPeriod = periods.includes(requestedPeriod) ? requestedPeriod : periods[0] || null;
|
|
122
|
+
if (!selectedPeriod) {
|
|
123
|
+
return { availablePeriods: [], selectedPeriod: null, summary: { models: [], totals: sumModels([]) }, days: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const scopeRow = (raw[scope] || []).find(row => row.period === selectedPeriod);
|
|
127
|
+
const summaryAgent = findAgentRow(scopeRow, sourceId);
|
|
128
|
+
const summary = summaryAgent
|
|
129
|
+
? normalizeAgentRow(sourceId, selectedPeriod, summaryAgent)
|
|
130
|
+
: { models: [], totals: sumModels([]) };
|
|
131
|
+
const days = (raw.daily || [])
|
|
132
|
+
.filter(row => dateInsideScope(String(row.period || ''), scope, selectedPeriod))
|
|
133
|
+
.flatMap(row => {
|
|
134
|
+
const agent = findAgentRow(row, sourceId);
|
|
135
|
+
return agent ? [normalizeAgentRow(sourceId, String(row.period), agent)] : [];
|
|
136
|
+
});
|
|
137
|
+
return { availablePeriods: periods, selectedPeriod, summary, days };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
class UsageService {
|
|
141
|
+
constructor(options = {}) {
|
|
142
|
+
this.runner = options.runner || null;
|
|
143
|
+
this.timezone = options.timezone || defaultTimezone();
|
|
144
|
+
this.cacheTtlMs = options.cacheTtlMs ?? CACHE_TTL_MS;
|
|
145
|
+
this.logger = options.logger || { debug() {} };
|
|
146
|
+
this.cached = null;
|
|
147
|
+
this.loading = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async getSnapshot(refresh = false) {
|
|
151
|
+
const fresh = this.cached && Date.now() - this.cached.loadedAt < this.cacheTtlMs;
|
|
152
|
+
if (!refresh && fresh) return this.cached;
|
|
153
|
+
if (!refresh && this.cached) {
|
|
154
|
+
this.loadSnapshot().catch(error => this.logger.debug(`Background usage refresh failed: ${error.message}`));
|
|
155
|
+
return this.cached;
|
|
156
|
+
}
|
|
157
|
+
return this.loadSnapshot();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async loadSnapshot() {
|
|
161
|
+
if (this.loading) return this.loading;
|
|
162
|
+
if (!this.runner) this.runner = new CcusageRunner();
|
|
163
|
+
this.loading = this.runner.loadAllPeriods(this.timezone)
|
|
164
|
+
.then(raw => {
|
|
165
|
+
this.cached = { raw, loadedAt: Date.now(), generatedAt: new Date().toISOString() };
|
|
166
|
+
return this.cached;
|
|
167
|
+
})
|
|
168
|
+
.finally(() => { this.loading = null; });
|
|
169
|
+
return this.loading;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async listSources(refresh = false) {
|
|
173
|
+
const snapshot = await this.getSnapshot(refresh);
|
|
174
|
+
const present = new Set();
|
|
175
|
+
for (const scope of ['daily', 'weekly', 'monthly']) {
|
|
176
|
+
for (const row of snapshot.raw[scope] || []) {
|
|
177
|
+
for (const agent of row.agents || []) present.add(agent.agent);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
sources: SOURCES.filter(source => present.has(source.id)),
|
|
182
|
+
generatedAt: snapshot.generatedAt,
|
|
183
|
+
timezone: this.timezone,
|
|
184
|
+
engine: { name: 'ccusage', version: ccusageVersion(), pricingMode: 'embedded' }
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async getDashboard(sourceId, scope, selectedPeriod, refresh = false) {
|
|
189
|
+
const source = getUsageSource(sourceId);
|
|
190
|
+
if (!source) {
|
|
191
|
+
const error = new Error('Unsupported usage source');
|
|
192
|
+
error.statusCode = 400;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
if (!SCOPES.has(scope)) {
|
|
196
|
+
const error = new Error('Scope must be weekly or monthly');
|
|
197
|
+
error.statusCode = 400;
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
const snapshot = await this.getSnapshot(refresh);
|
|
201
|
+
return {
|
|
202
|
+
source,
|
|
203
|
+
scope,
|
|
204
|
+
...buildDashboard(snapshot.raw, source.id, scope, selectedPeriod),
|
|
205
|
+
generatedAt: snapshot.generatedAt,
|
|
206
|
+
timezone: this.timezone,
|
|
207
|
+
engine: { name: 'ccusage', version: ccusageVersion(), pricingMode: 'embedded' },
|
|
208
|
+
cost: source.id === 'codex' ? {
|
|
209
|
+
basis: 'ccusage estimate for Codex GPT models',
|
|
210
|
+
note: 'Estimated from ccusage model pricing; it is not an actual provider bill.'
|
|
211
|
+
} : null
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
UsageService,
|
|
218
|
+
availablePeriods,
|
|
219
|
+
buildDashboard,
|
|
220
|
+
dateInsideScope,
|
|
221
|
+
isGptModel,
|
|
222
|
+
modelCost,
|
|
223
|
+
normalizeAgentRow,
|
|
224
|
+
normalizeModel,
|
|
225
|
+
sumModels
|
|
226
|
+
};
|
package/lib/web/claude.js
CHANGED
|
@@ -486,15 +486,29 @@
|
|
|
486
486
|
}
|
|
487
487
|
|
|
488
488
|
function inlineMarkdown(text) {
|
|
489
|
+
const protectedSegments = [];
|
|
490
|
+
const protect = value => `\uE000${protectedSegments.push(value) - 1}\uE001`;
|
|
491
|
+
const restore = value => value.replace(/\uE000(\d+)\uE001/g, (_match, index) => protectedSegments[Number(index)] || '');
|
|
492
|
+
const formatText = value => {
|
|
493
|
+
const codeSegments = [];
|
|
494
|
+
const protectCode = code => `\uE002${codeSegments.push(code) - 1}\uE003`;
|
|
495
|
+
const restoreCode = formatted => formatted.replace(/\uE002(\d+)\uE003/g, (_match, index) => codeSegments[Number(index)] || '');
|
|
496
|
+
let formatted = value.replace(/`([^`]+)`/g, (_match, code) => protectCode(`<code>${code}</code>`));
|
|
497
|
+
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
498
|
+
formatted = formatted.replace(/(^|[^\p{L}\p{N}_])__([^_\n]+)__(?![\p{L}\p{N}_])/gu, '$1<strong>$2</strong>');
|
|
499
|
+
formatted = formatted.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
|
500
|
+
formatted = formatted.replace(/(^|[^\p{L}\p{N}_])_([^_\n]+)_(?![\p{L}\p{N}_])/gu, '$1<em>$2</em>');
|
|
501
|
+
return restoreCode(formatted);
|
|
502
|
+
};
|
|
503
|
+
|
|
489
504
|
let html = escapeHtml(text || '');
|
|
490
|
-
html = html.replace(/`([^`]+)
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return html;
|
|
505
|
+
html = html.replace(/`([^`]+)`|!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
|
|
506
|
+
(_match, code, alt, imageUrl, label, linkUrl) => {
|
|
507
|
+
if (code !== undefined) return protect(`<code>${code}</code>`);
|
|
508
|
+
if (imageUrl !== undefined) return protect(`<img src="${imageUrl}" alt="${alt}">`);
|
|
509
|
+
return protect(`<a href="${linkUrl}" target="_blank" rel="noopener noreferrer">${formatText(label)}</a>`);
|
|
510
|
+
});
|
|
511
|
+
return restore(formatText(html));
|
|
498
512
|
}
|
|
499
513
|
|
|
500
514
|
function parseMarkdownFenceOpener(line) {
|
package/lib/web/index.html
CHANGED
|
@@ -21,7 +21,18 @@
|
|
|
21
21
|
<h1><img class="header-logo" src="logo.svg" alt="">Glad</h1>
|
|
22
22
|
<div class="header-actions">
|
|
23
23
|
<button class="header-action-btn" onclick="showToolModal()" title="New AI session"><span>+</span><span>Session</span></button>
|
|
24
|
-
<button class="header-action-btn
|
|
24
|
+
<button id="usage-dashboard-button" class="header-action-btn icon-only" type="button"
|
|
25
|
+
onclick="showUsageSourceModal()" title="Usage dashboard" aria-label="Usage dashboard">
|
|
26
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
27
|
+
<path d="M4 19V10"></path><path d="M10 19V5"></path><path d="M16 19v-7"></path><path d="M22 19V2"></path>
|
|
28
|
+
</svg>
|
|
29
|
+
</button>
|
|
30
|
+
<button id="schedule-create-button" class="header-action-btn icon-only" type="button"
|
|
31
|
+
onclick="showScheduleModal()" title="New scheduled task" aria-label="New scheduled task">
|
|
32
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
33
|
+
<circle cx="12" cy="13" r="8"></circle><path d="M12 9v4l2.5 1.5"></path><path d="M9 2h6"></path><path d="M12 2v3"></path>
|
|
34
|
+
</svg>
|
|
35
|
+
</button>
|
|
25
36
|
<button id="app-settings-button" class="header-action-btn icon-only" type="button"
|
|
26
37
|
onclick="openSettings()" title="Settings" aria-label="Settings">
|
|
27
38
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -44,6 +55,67 @@
|
|
|
44
55
|
</div>
|
|
45
56
|
</div>
|
|
46
57
|
|
|
58
|
+
<!-- Usage Dashboard View -->
|
|
59
|
+
<div id="usage-view" class="view">
|
|
60
|
+
<div class="usage-nav">
|
|
61
|
+
<button class="usage-nav-button" type="button" onclick="showLobby()" aria-label="Back to lobby">
|
|
62
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
|
63
|
+
Lobby
|
|
64
|
+
</button>
|
|
65
|
+
<div class="usage-nav-title">
|
|
66
|
+
<strong id="usage-source-title">Usage</strong>
|
|
67
|
+
<span id="usage-updated-at"></span>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="usage-nav-actions">
|
|
70
|
+
<button class="icon-btn usage-nav-icon" type="button" onclick="showUsageSourceModal()" title="Change CLI" aria-label="Change CLI">
|
|
71
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"></path><path d="M4 12h16"></path><path d="M4 17h16"></path></svg>
|
|
72
|
+
</button>
|
|
73
|
+
<button id="usage-refresh-button" class="icon-btn usage-nav-icon" type="button" onclick="refreshUsageDashboard()" title="Refresh usage" aria-label="Refresh usage">
|
|
74
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><polyline points="21 3 21 9 15 9"></polyline></svg>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<main class="usage-content">
|
|
79
|
+
<div class="usage-filter-bar">
|
|
80
|
+
<div class="usage-period-toggle" role="tablist" aria-label="Usage scope">
|
|
81
|
+
<button id="usage-scope-weekly" class="active" type="button" onclick="setUsageScope('weekly')">Week</button>
|
|
82
|
+
<button id="usage-scope-monthly" type="button" onclick="setUsageScope('monthly')">Month</button>
|
|
83
|
+
</div>
|
|
84
|
+
<label class="usage-period-picker">
|
|
85
|
+
<span>Period</span>
|
|
86
|
+
<select id="usage-period-select" onchange="selectUsagePeriod(this.value)" aria-label="Select period"></select>
|
|
87
|
+
</label>
|
|
88
|
+
</div>
|
|
89
|
+
<div id="usage-loading" class="usage-state">Loading usage data...</div>
|
|
90
|
+
<div id="usage-dashboard" hidden>
|
|
91
|
+
<section id="usage-summary" class="usage-summary usage-summary-totals" aria-label="All-model totals"></section>
|
|
92
|
+
<section class="usage-panel">
|
|
93
|
+
<div class="usage-panel-heading"><div><h2>Model summary</h2><p>All models in the selected period.</p></div></div>
|
|
94
|
+
<div id="usage-model-summary" class="usage-table-wrap"></div>
|
|
95
|
+
</section>
|
|
96
|
+
<section class="usage-panel">
|
|
97
|
+
<div class="usage-panel-heading">
|
|
98
|
+
<div><h2>Tokens by day</h2><p>Daily totals stacked by model.</p></div>
|
|
99
|
+
<div id="usage-token-legend" class="usage-legend" aria-label="Token chart model legend"></div>
|
|
100
|
+
</div>
|
|
101
|
+
<div id="usage-token-chart" class="usage-chart"></div>
|
|
102
|
+
</section>
|
|
103
|
+
<section id="usage-cost-panel" class="usage-panel">
|
|
104
|
+
<div class="usage-panel-heading">
|
|
105
|
+
<div><h2>Cost by day</h2><p>ccusage estimates stacked by GPT model.</p></div>
|
|
106
|
+
<div id="usage-cost-legend" class="usage-legend" aria-label="Cost chart model legend"></div>
|
|
107
|
+
</div>
|
|
108
|
+
<div id="usage-cost-chart" class="usage-chart"></div>
|
|
109
|
+
</section>
|
|
110
|
+
<section class="usage-panel">
|
|
111
|
+
<div class="usage-panel-heading"><div><h2>Daily details</h2><p>Exact daily totals reported by ccusage.</p></div></div>
|
|
112
|
+
<div id="usage-daily-table" class="usage-table-wrap"></div>
|
|
113
|
+
</section>
|
|
114
|
+
<div id="usage-engine-note" class="usage-pricing-note"></div>
|
|
115
|
+
</div>
|
|
116
|
+
</main>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
47
119
|
<!-- Terminal View -->
|
|
48
120
|
<div id="terminal-view" class="view">
|
|
49
121
|
<div id="top-ui">
|
|
@@ -259,6 +331,20 @@
|
|
|
259
331
|
</div>
|
|
260
332
|
</div>
|
|
261
333
|
|
|
334
|
+
<!-- Usage Source Modal -->
|
|
335
|
+
<div id="usage-source-overlay" onclick="closeUsageSourceModal(event)">
|
|
336
|
+
<div id="usage-source-modal" role="dialog" aria-modal="true" aria-labelledby="usage-source-modal-title" onclick="event.stopPropagation()">
|
|
337
|
+
<div class="usage-source-header">
|
|
338
|
+
<div>
|
|
339
|
+
<h2 id="usage-source-modal-title">Usage Dashboard</h2>
|
|
340
|
+
<p>Select a CLI with local usage history.</p>
|
|
341
|
+
</div>
|
|
342
|
+
<button class="icon-btn" type="button" onclick="closeUsageSourceModal()" aria-label="Close">×</button>
|
|
343
|
+
</div>
|
|
344
|
+
<div id="usage-sources-list"><p class="usage-modal-state">Reading local usage data...</p></div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
262
348
|
<!-- App Settings Modal -->
|
|
263
349
|
<div id="settings-modal-overlay" onclick="closeSettings(event)">
|
|
264
350
|
<div id="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title" onclick="event.stopPropagation()">
|
|
@@ -367,5 +453,6 @@
|
|
|
367
453
|
<script src="timed-inputs.js"></script>
|
|
368
454
|
<script src="terminal-scroll.js"></script>
|
|
369
455
|
<script src="git.js"></script>
|
|
456
|
+
<script src="usage.js"></script>
|
|
370
457
|
</body>
|
|
371
458
|
</html>
|
package/lib/web/styles.css
CHANGED
|
@@ -59,6 +59,74 @@
|
|
|
59
59
|
.tool-item { padding: 12px; border-bottom: 1px solid #333; cursor: pointer; display: flex; align-items: center; border-radius: 8px; margin-top: 4px; }
|
|
60
60
|
.tool-item:hover { background: rgba(255,255,255,0.05); }
|
|
61
61
|
.tool-icon { width: 32px; height: 32px; background: #333; border-radius: 8px; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
|
|
62
|
+
#usage-source-overlay { position: fixed; inset: 0; z-index: 10030; display: none; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; background: rgba(0,0,0,0.82); }
|
|
63
|
+
#usage-source-modal { width: 100%; max-width: 420px; max-height: min(680px, 90dvh); overflow-y: auto; padding: 20px; box-sizing: border-box; border-radius: 16px; background: var(--card-bg); box-shadow: 0 20px 40px rgba(0,0,0,0.45); }
|
|
64
|
+
.usage-source-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
|
65
|
+
.usage-source-header h2 { margin: 0; font-size: 20px; }
|
|
66
|
+
.usage-source-header p { margin: 5px 0 0; color: var(--text-dim); font-size: 12px; }
|
|
67
|
+
.usage-source-header .icon-btn { font-size: 25px; line-height: 1; }
|
|
68
|
+
.usage-source-item { width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px; margin-top: 5px; box-sizing: border-box; border: 0; border-radius: 10px; background: transparent; color: #fff; text-align: left; cursor: pointer; }
|
|
69
|
+
.usage-source-item:hover, .usage-source-item:active { background: rgba(255,255,255,0.07); }
|
|
70
|
+
.usage-source-badge { width: 38px; height: 38px; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; border-radius: 10px; background: rgba(0,122,255,0.18); color: #78b7ff; font-size: 12px; font-weight: 850; letter-spacing: .03em; }
|
|
71
|
+
.usage-source-copy { min-width: 0; flex: 1; }
|
|
72
|
+
.usage-source-copy strong { display: block; font-size: 15px; }
|
|
73
|
+
.usage-source-copy span { display: block; margin-top: 3px; color: var(--text-dim); font-size: 11px; }
|
|
74
|
+
.usage-source-arrow { color: var(--text-dim); font-size: 20px; }
|
|
75
|
+
.usage-modal-state { padding: 26px 8px; color: var(--text-dim); text-align: center; font-size: 13px; line-height: 1.5; }
|
|
76
|
+
|
|
77
|
+
#usage-view { background: #09090a; }
|
|
78
|
+
.usage-nav { min-height: 54px; flex: 0 0 auto; display: grid; grid-template-columns: minmax(90px, 1fr) minmax(0, 2fr) minmax(90px, 1fr); align-items: center; gap: 8px; padding: env(safe-area-inset-top) 14px 0; border-bottom: 1px solid rgba(255,255,255,0.08); background: rgba(18,18,18,0.96); box-sizing: content-box; }
|
|
79
|
+
.usage-nav-button { display: inline-flex; align-items: center; justify-self: start; gap: 4px; padding: 8px 0; border: 0; background: none; color: var(--primary); font-size: 15px; font-weight: 600; cursor: pointer; }
|
|
80
|
+
.usage-nav-title { min-width: 0; text-align: center; }
|
|
81
|
+
.usage-nav-title strong { display: block; overflow: hidden; color: #fff; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
|
82
|
+
.usage-nav-title span { display: block; min-height: 13px; margin-top: 2px; overflow: hidden; color: var(--text-dim); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
|
83
|
+
.usage-nav-actions { display: flex; justify-self: end; gap: 8px; }
|
|
84
|
+
.usage-nav-icon { width: 34px; height: 34px; padding: 0; border-radius: 50%; background: rgba(255,255,255,0.06); color: var(--primary); }
|
|
85
|
+
.usage-nav-icon.loading svg { animation: usage-spin .8s linear infinite; }
|
|
86
|
+
@keyframes usage-spin { to { transform: rotate(360deg); } }
|
|
87
|
+
.usage-content { flex: 1; overflow-y: auto; padding: 18px max(16px, calc((100vw - 1120px) / 2)) calc(28px + env(safe-area-inset-bottom)); box-sizing: border-box; }
|
|
88
|
+
.usage-filter-bar { width: min(100%, 680px); display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); align-items: end; gap: 12px; margin: 0 auto 18px; }
|
|
89
|
+
.usage-period-toggle { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; padding: 4px; border-radius: 10px; background: var(--card-bg); }
|
|
90
|
+
.usage-period-toggle button { min-height: 34px; border: 0; border-radius: 7px; background: transparent; color: var(--text-dim); font-size: 13px; font-weight: 750; cursor: pointer; }
|
|
91
|
+
.usage-period-toggle button.active { background: var(--primary); color: #fff; }
|
|
92
|
+
.usage-period-picker span { display: block; margin: 0 0 5px 2px; color: var(--text-dim); font-size: 10px; font-weight: 750; text-transform: uppercase; }
|
|
93
|
+
.usage-period-picker select { width: 100%; min-height: 42px; padding: 8px 34px 8px 11px; box-sizing: border-box; border: 1px solid rgba(255,255,255,0.09); border-radius: 10px; background: var(--card-bg); color: #fff; font-size: 13px; font-weight: 650; outline: none; }
|
|
94
|
+
.usage-state { padding: 70px 16px; color: var(--text-dim); text-align: center; font-size: 14px; line-height: 1.6; }
|
|
95
|
+
.usage-state.error { color: #ff6b61; }
|
|
96
|
+
.usage-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
|
|
97
|
+
.usage-summary.usage-summary-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
98
|
+
.usage-summary.usage-summary-totals .usage-summary-card:only-child { grid-column: 1 / -1; }
|
|
99
|
+
.usage-summary-card { min-width: 0; padding: 16px; border: 1px solid rgba(255,255,255,0.07); border-radius: 13px; background: var(--card-bg); }
|
|
100
|
+
.usage-summary-card .label { display: flex; align-items: center; gap: 7px; color: var(--text-dim); font-size: 12px; font-weight: 650; }
|
|
101
|
+
.usage-summary-card .dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; }
|
|
102
|
+
.usage-summary-card .value { display: block; margin-top: 9px; overflow: hidden; color: #fff; font-size: clamp(20px, 3vw, 29px); font-weight: 760; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
|
|
103
|
+
.usage-summary-card .exact { display: block; margin-top: 5px; overflow: hidden; color: #66666b; font: 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
|
104
|
+
.usage-panel { margin-top: 12px; padding: 17px; border: 1px solid rgba(255,255,255,0.07); border-radius: 13px; background: var(--card-bg); }
|
|
105
|
+
.usage-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
|
106
|
+
.usage-panel-heading h2 { margin: 0; font-size: 16px; }
|
|
107
|
+
.usage-panel-heading p { margin: 5px 0 0; color: var(--text-dim); font-size: 11px; }
|
|
108
|
+
.usage-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: var(--text-dim); font-size: 10px; }
|
|
109
|
+
.usage-legend span { display: inline-flex; align-items: center; gap: 4px; }
|
|
110
|
+
.usage-legend i { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; }
|
|
111
|
+
.usage-summary-card .dot.tokens { background: #0a84ff; }
|
|
112
|
+
.usage-summary-card .dot.cost { background: #ff9f0a; }
|
|
113
|
+
.usage-chart { display: flex; flex-direction: column; gap: 9px; }
|
|
114
|
+
.usage-chart-row { display: grid; grid-template-columns: 82px minmax(80px, 1fr) 82px; align-items: center; gap: 10px; min-height: 18px; }
|
|
115
|
+
.usage-chart-label { overflow: hidden; color: #b5b5ba; font: 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
|
116
|
+
.usage-chart-track { height: 10px; display: flex; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,0.06); }
|
|
117
|
+
.usage-chart-track span { display: block; height: 100%; min-width: 0; }
|
|
118
|
+
.usage-chart-total { color: #77777c; font-size: 10px; text-align: right; white-space: nowrap; }
|
|
119
|
+
.usage-table-wrap { overflow-x: auto; }
|
|
120
|
+
.usage-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
121
|
+
.usage-table th { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.1); color: var(--text-dim); font-size: 10px; font-weight: 750; text-align: right; white-space: nowrap; }
|
|
122
|
+
.usage-table th:first-child, .usage-table td:first-child { padding-left: 0; text-align: left; }
|
|
123
|
+
.usage-table th:last-child, .usage-table td:last-child { padding-right: 0; }
|
|
124
|
+
.usage-table td { padding: 11px 10px; border-bottom: 1px solid rgba(255,255,255,0.055); color: #d8d8dc; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
125
|
+
.usage-table tr:last-child td { border-bottom: 0; }
|
|
126
|
+
.usage-table tfoot td { border-top: 1px solid rgba(255,255,255,0.12); border-bottom: 0; color: #fff; font-weight: 750; }
|
|
127
|
+
.usage-models { max-width: 220px; overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; }
|
|
128
|
+
.usage-pricing-note { margin: 14px 3px 0; color: #6f6f74; font-size: 10px; line-height: 1.5; text-align: center; }
|
|
129
|
+
.usage-empty { padding: 30px 10px; color: var(--text-dim); text-align: center; font-size: 13px; }
|
|
62
130
|
#terminal-view { background: #000; overflow-anchor: none; position: relative; }
|
|
63
131
|
#top-ui { flex-shrink: 0; background: #121212; border-bottom: 1px solid #222; z-index: 2000; padding-top: env(safe-area-inset-top); position: relative; }
|
|
64
132
|
#nav-bar { display: flex; align-items: center; padding: 8px 14px; border-bottom: 1px solid #222; }
|
|
@@ -414,6 +482,15 @@
|
|
|
414
482
|
.step-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 10px; margin-bottom: 8px; background: rgba(255,255,255,0.04); }
|
|
415
483
|
.step-grid { display: grid; grid-template-columns: minmax(110px, 150px) 1fr auto; gap: 8px; align-items: center; }
|
|
416
484
|
@media (max-width: 640px) {
|
|
485
|
+
.usage-content { padding: 14px 10px calc(24px + env(safe-area-inset-bottom)); }
|
|
486
|
+
.usage-filter-bar { grid-template-columns: 1fr; gap: 9px; }
|
|
487
|
+
.usage-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
488
|
+
.usage-summary-card { padding: 13px; }
|
|
489
|
+
.usage-panel { padding: 13px; }
|
|
490
|
+
.usage-panel-heading { flex-direction: column; gap: 10px; }
|
|
491
|
+
.usage-legend { justify-content: flex-start; }
|
|
492
|
+
.usage-chart-row { grid-template-columns: 70px minmax(70px, 1fr); gap: 8px; }
|
|
493
|
+
.usage-chart-total { display: none; }
|
|
417
494
|
.schedule-row { flex-direction: column; }
|
|
418
495
|
.schedule-actions { justify-content: flex-start; }
|
|
419
496
|
.step-grid { grid-template-columns: 1fr; }
|
|
@@ -439,6 +516,10 @@
|
|
|
439
516
|
#codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
|
|
440
517
|
.codex-message-block.user { max-width: 94%; }
|
|
441
518
|
.claude-permission-actions { justify-content: flex-start; }
|
|
519
|
+
.usage-nav { grid-template-columns: 78px minmax(0, 1fr) 78px; padding-left: 10px; padding-right: 10px; }
|
|
520
|
+
.usage-nav-actions { gap: 5px; }
|
|
521
|
+
.usage-nav-icon { width: 32px; height: 32px; }
|
|
522
|
+
.usage-summary-card .value { font-size: 21px; }
|
|
442
523
|
}
|
|
443
524
|
@media (max-width: 430px) {
|
|
444
525
|
#nav-bar > div:last-child .icon-btn:not(#codex-terminal-switch) { width: 34px; font-size: 0 !important; gap: 0 !important; }
|
package/lib/web/usage.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
const usageState = {
|
|
2
|
+
source: null,
|
|
3
|
+
scope: 'weekly',
|
|
4
|
+
selectedPeriod: null,
|
|
5
|
+
sources: [],
|
|
6
|
+
requestSequence: 0
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const usageModelColors = [
|
|
10
|
+
'#0a84ff', '#30d158', '#bf5af2', '#ff9f0a', '#ff453a', '#64d2ff',
|
|
11
|
+
'#ffd60a', '#5e5ce6', '#ff375f', '#66d4cf', '#ac8e68', '#8e8e93'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function formatExactTokens(value) {
|
|
15
|
+
return new Intl.NumberFormat().format(Number(value) || 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatCompactNumber(value) {
|
|
19
|
+
const number = Number(value) || 0;
|
|
20
|
+
if (number < 1000) return formatExactTokens(number);
|
|
21
|
+
return new Intl.NumberFormat(undefined, {
|
|
22
|
+
notation: 'compact',
|
|
23
|
+
maximumFractionDigits: number >= 1000000 ? 2 : 1
|
|
24
|
+
}).format(number);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatEstimatedCost(value, compact = false) {
|
|
28
|
+
if (value === null || value === undefined) return '—';
|
|
29
|
+
const amount = Number(value) || 0;
|
|
30
|
+
return new Intl.NumberFormat(undefined, {
|
|
31
|
+
style: 'currency',
|
|
32
|
+
currency: 'USD',
|
|
33
|
+
notation: compact && amount >= 1000 ? 'compact' : 'standard',
|
|
34
|
+
minimumFractionDigits: compact ? 2 : (amount < 1 ? 3 : 2),
|
|
35
|
+
maximumFractionDigits: compact ? 2 : (amount < 1 ? 4 : 2)
|
|
36
|
+
}).format(amount);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function closeUsageSourceModal(event) {
|
|
40
|
+
if (event && event.target.id !== 'usage-source-overlay') return;
|
|
41
|
+
document.getElementById('usage-source-overlay').style.display = 'none';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function showUsageSourceModal(options = {}) {
|
|
45
|
+
const overlay = document.getElementById('usage-source-overlay');
|
|
46
|
+
overlay.style.display = 'flex';
|
|
47
|
+
const hasCachedSources = usageState.sources.length > 0;
|
|
48
|
+
if (hasCachedSources && !options.refresh) {
|
|
49
|
+
renderUsageSources();
|
|
50
|
+
} else {
|
|
51
|
+
document.getElementById('usage-sources-list').innerHTML = '<p class="usage-modal-state">Reading local usage data...</p>';
|
|
52
|
+
}
|
|
53
|
+
const list = document.getElementById('usage-sources-list');
|
|
54
|
+
try {
|
|
55
|
+
const suffix = options.refresh ? '?refresh=1' : '';
|
|
56
|
+
const response = await fetchWithTimeout(`/api/usage/sources${suffix}`, {}, 60000);
|
|
57
|
+
const data = await response.json();
|
|
58
|
+
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
59
|
+
usageState.sources = Array.isArray(data.sources) ? data.sources : [];
|
|
60
|
+
renderUsageSources();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (hasCachedSources) return;
|
|
63
|
+
list.innerHTML = `<div class="usage-modal-state">Unable to read usage data.<br>${escapeHtml(error.message)}<br><button class="btn-retry" type="button" onclick="showUsageSourceModal({ refresh: true })">Retry</button></div>`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderUsageSources() {
|
|
68
|
+
const list = document.getElementById('usage-sources-list');
|
|
69
|
+
if (!usageState.sources.length) {
|
|
70
|
+
list.innerHTML = '<p class="usage-modal-state">No supported local CLI usage history was found.</p>';
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
list.innerHTML = usageState.sources.map(source => `
|
|
74
|
+
<button class="usage-source-item" type="button" onclick="openUsageDashboard('${escapeHtml(source.id)}')">
|
|
75
|
+
<span class="usage-source-badge">${escapeHtml(source.badge)}</span>
|
|
76
|
+
<span class="usage-source-copy"><strong>${escapeHtml(source.label)}</strong><span>Local token history</span></span>
|
|
77
|
+
<span class="usage-source-arrow">›</span>
|
|
78
|
+
</button>`).join('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function openUsageDashboard(sourceId) {
|
|
82
|
+
const source = usageState.sources.find(item => item.id === sourceId);
|
|
83
|
+
usageState.source = source || { id: sourceId, label: sourceId };
|
|
84
|
+
usageState.selectedPeriod = null;
|
|
85
|
+
closeUsageSourceModal();
|
|
86
|
+
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'));
|
|
87
|
+
document.getElementById('usage-view').classList.add('active');
|
|
88
|
+
document.getElementById('usage-source-title').textContent = `${usageState.source.label} Usage`;
|
|
89
|
+
await loadUsageDashboard();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function setUsageScope(scope) {
|
|
93
|
+
if (!['weekly', 'monthly'].includes(scope) || usageState.scope === scope) return;
|
|
94
|
+
usageState.scope = scope;
|
|
95
|
+
usageState.selectedPeriod = null;
|
|
96
|
+
updateUsageScopeButtons();
|
|
97
|
+
if (usageState.source) await loadUsageDashboard();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function updateUsageScopeButtons() {
|
|
101
|
+
for (const scope of ['weekly', 'monthly']) {
|
|
102
|
+
document.getElementById(`usage-scope-${scope}`).classList.toggle('active', usageState.scope === scope);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function selectUsagePeriod(period) {
|
|
107
|
+
if (!period || usageState.selectedPeriod === period) return;
|
|
108
|
+
usageState.selectedPeriod = period;
|
|
109
|
+
await loadUsageDashboard();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function refreshUsageDashboard() {
|
|
113
|
+
if (!usageState.source) return;
|
|
114
|
+
await loadUsageDashboard(true);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function setUsageLoading(loading, message = 'Loading usage data...') {
|
|
118
|
+
const state = document.getElementById('usage-loading');
|
|
119
|
+
const dashboard = document.getElementById('usage-dashboard');
|
|
120
|
+
const refresh = document.getElementById('usage-refresh-button');
|
|
121
|
+
state.classList.remove('error');
|
|
122
|
+
state.textContent = message;
|
|
123
|
+
state.hidden = !loading;
|
|
124
|
+
dashboard.hidden = loading;
|
|
125
|
+
refresh.classList.toggle('loading', loading);
|
|
126
|
+
refresh.disabled = loading;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function loadUsageDashboard(refresh = false) {
|
|
130
|
+
const requestSequence = ++usageState.requestSequence;
|
|
131
|
+
setUsageLoading(true);
|
|
132
|
+
try {
|
|
133
|
+
const query = new URLSearchParams({ source: usageState.source.id, scope: usageState.scope });
|
|
134
|
+
if (usageState.selectedPeriod) query.set('period', usageState.selectedPeriod);
|
|
135
|
+
if (refresh) query.set('refresh', '1');
|
|
136
|
+
const response = await fetchWithTimeout(`/api/usage/report?${query}`, {}, 60000);
|
|
137
|
+
const data = await response.json();
|
|
138
|
+
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
139
|
+
if (requestSequence !== usageState.requestSequence) return;
|
|
140
|
+
renderUsageDashboard(data);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (requestSequence !== usageState.requestSequence) return;
|
|
143
|
+
const state = document.getElementById('usage-loading');
|
|
144
|
+
state.classList.add('error');
|
|
145
|
+
state.innerHTML = `Unable to load usage: ${escapeHtml(error.message)}<br><button class="btn-retry" type="button" onclick="loadUsageDashboard(true)">Retry</button>`;
|
|
146
|
+
state.hidden = false;
|
|
147
|
+
document.getElementById('usage-dashboard').hidden = true;
|
|
148
|
+
} finally {
|
|
149
|
+
if (requestSequence !== usageState.requestSequence) return;
|
|
150
|
+
const refreshButton = document.getElementById('usage-refresh-button');
|
|
151
|
+
refreshButton.classList.remove('loading');
|
|
152
|
+
refreshButton.disabled = false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderPeriodPicker(report) {
|
|
157
|
+
usageState.selectedPeriod = report.selectedPeriod;
|
|
158
|
+
const select = document.getElementById('usage-period-select');
|
|
159
|
+
select.innerHTML = (report.availablePeriods || []).map(period =>
|
|
160
|
+
`<option value="${escapeHtml(period)}"${period === report.selectedPeriod ? ' selected' : ''}>${escapeHtml(period)}</option>`
|
|
161
|
+
).join('');
|
|
162
|
+
select.disabled = !report.availablePeriods || report.availablePeriods.length === 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function totalSummaryCard(label, value, cost = false) {
|
|
166
|
+
const display = cost ? formatEstimatedCost(value, true) : formatCompactNumber(value);
|
|
167
|
+
const exact = cost ? formatEstimatedCost(value) : `${formatExactTokens(value)} tokens`;
|
|
168
|
+
return `<article class="usage-summary-card" title="${escapeHtml(exact)}">
|
|
169
|
+
<span class="label"><i class="dot ${cost ? 'cost' : 'tokens'}"></i>${escapeHtml(label)}</span>
|
|
170
|
+
<strong class="value">${escapeHtml(display)}</strong>
|
|
171
|
+
<span class="exact">${escapeHtml(exact)}</span>
|
|
172
|
+
</article>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderAllModelTotals(report) {
|
|
176
|
+
const totals = report.summary && report.summary.totals ? report.summary.totals : {};
|
|
177
|
+
const cards = [totalSummaryCard('All-model tokens', totals.totalTokens)];
|
|
178
|
+
if (totals.estimatedCostUSD !== null && totals.estimatedCostUSD !== undefined) {
|
|
179
|
+
cards.push(totalSummaryCard('All GPT cost', totals.estimatedCostUSD, true));
|
|
180
|
+
}
|
|
181
|
+
document.getElementById('usage-summary').innerHTML = cards.join('');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderModelSummary(report) {
|
|
185
|
+
const models = report.summary && Array.isArray(report.summary.models) ? report.summary.models : [];
|
|
186
|
+
const totals = report.summary && report.summary.totals ? report.summary.totals : {};
|
|
187
|
+
const hasCost = totals.estimatedCostUSD !== null && totals.estimatedCostUSD !== undefined;
|
|
188
|
+
const container = document.getElementById('usage-model-summary');
|
|
189
|
+
if (!models.length) {
|
|
190
|
+
container.innerHTML = '<div class="usage-empty">No usage in this period.</div>';
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
container.innerHTML = `<table class="usage-table">
|
|
194
|
+
<thead><tr><th>Model</th><th>Uncached input</th><th>Cached input</th><th>Output</th><th>Total tokens</th>${hasCost ? '<th>Cost</th>' : ''}</tr></thead>
|
|
195
|
+
<tbody>${models.map(model => `<tr>
|
|
196
|
+
<td class="usage-models" title="${escapeHtml(model.modelName)}">${escapeHtml(model.modelName)}</td>
|
|
197
|
+
<td>${escapeHtml(formatExactTokens(model.uncachedInputTokens))}</td>
|
|
198
|
+
<td>${escapeHtml(formatExactTokens(model.cachedInputTokens))}</td>
|
|
199
|
+
<td>${escapeHtml(formatExactTokens(model.outputTokens))}</td>
|
|
200
|
+
<td>${escapeHtml(formatExactTokens(model.totalTokens))}</td>
|
|
201
|
+
${hasCost ? `<td>${escapeHtml(formatEstimatedCost(model.estimatedCostUSD))}</td>` : ''}
|
|
202
|
+
</tr>`).join('')}</tbody>
|
|
203
|
+
<tfoot><tr><td>All models</td><td>${escapeHtml(formatExactTokens(totals.uncachedInputTokens))}</td><td>${escapeHtml(formatExactTokens(totals.cachedInputTokens))}</td><td>${escapeHtml(formatExactTokens(totals.outputTokens))}</td><td>${escapeHtml(formatExactTokens(totals.totalTokens))}</td>${hasCost ? `<td>${escapeHtml(formatEstimatedCost(totals.estimatedCostUSD))}</td>` : ''}</tr></tfoot>
|
|
204
|
+
</table>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function collectChartModels(days, metric) {
|
|
208
|
+
const names = [];
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
for (const day of days) {
|
|
211
|
+
for (const model of day.models || []) {
|
|
212
|
+
const value = model[metric];
|
|
213
|
+
if ((value === null || value === undefined || Number(value) <= 0) || seen.has(model.modelName)) continue;
|
|
214
|
+
seen.add(model.modelName);
|
|
215
|
+
names.push(model.modelName);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return names.sort((a, b) => a.localeCompare(b));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function modelColorMap(modelNames) {
|
|
222
|
+
return new Map(modelNames.map((name, index) => [
|
|
223
|
+
name,
|
|
224
|
+
usageModelColors[index] || `hsl(${(index * 47) % 360} 75% 58%)`
|
|
225
|
+
]));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderModelLegend(elementId, modelNames, colors) {
|
|
229
|
+
document.getElementById(elementId).innerHTML = modelNames.map(name =>
|
|
230
|
+
`<span title="${escapeHtml(name)}"><i style="background:${colors.get(name)}"></i>${escapeHtml(name)}</span>`
|
|
231
|
+
).join('');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderStackedModelChart(report, options) {
|
|
235
|
+
const days = report.days || [];
|
|
236
|
+
const modelNames = collectChartModels(days, options.metric);
|
|
237
|
+
const colors = modelColorMap(modelNames);
|
|
238
|
+
const container = document.getElementById(options.containerId);
|
|
239
|
+
renderModelLegend(options.legendId, modelNames, colors);
|
|
240
|
+
if (!days.length || !modelNames.length) {
|
|
241
|
+
container.innerHTML = `<div class="usage-empty">${escapeHtml(options.emptyText)}</div>`;
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
const dayTotals = days.map(day => (day.models || []).reduce((sum, model) => {
|
|
245
|
+
const value = model[options.metric];
|
|
246
|
+
return sum + (value === null || value === undefined ? 0 : Number(value) || 0);
|
|
247
|
+
}, 0));
|
|
248
|
+
const maximum = Math.max(...dayTotals, 1);
|
|
249
|
+
container.innerHTML = days.map((day, dayIndex) => {
|
|
250
|
+
const segments = modelNames.map(name => {
|
|
251
|
+
const model = (day.models || []).find(item => item.modelName === name);
|
|
252
|
+
const value = model && model[options.metric] !== null ? Number(model[options.metric]) || 0 : 0;
|
|
253
|
+
if (value <= 0) return '';
|
|
254
|
+
const title = `${name}: ${options.formatExact(value)}`;
|
|
255
|
+
return `<span title="${escapeHtml(title)}" style="width:${value / maximum * 100}%;background:${colors.get(name)}"></span>`;
|
|
256
|
+
}).join('');
|
|
257
|
+
return `<div class="usage-chart-row">
|
|
258
|
+
<span class="usage-chart-label">${escapeHtml(day.period)}</span>
|
|
259
|
+
<div class="usage-chart-track">${segments}</div>
|
|
260
|
+
<span class="usage-chart-total">${escapeHtml(options.formatCompact(dayTotals[dayIndex]))}</span>
|
|
261
|
+
</div>`;
|
|
262
|
+
}).join('');
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function renderDailyTable(report) {
|
|
267
|
+
const days = (report.days || []).slice().reverse();
|
|
268
|
+
const hasCost = days.some(day => day.totals && day.totals.estimatedCostUSD !== null);
|
|
269
|
+
const container = document.getElementById('usage-daily-table');
|
|
270
|
+
if (!days.length) {
|
|
271
|
+
container.innerHTML = '<div class="usage-empty">No daily usage in this period.</div>';
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
container.innerHTML = `<table class="usage-table">
|
|
275
|
+
<thead><tr><th>Date</th><th>Total tokens</th>${hasCost ? '<th>Cost</th>' : ''}<th>Models</th></tr></thead>
|
|
276
|
+
<tbody>${days.map(day => `<tr>
|
|
277
|
+
<td>${escapeHtml(day.period)}</td>
|
|
278
|
+
<td>${escapeHtml(formatExactTokens(day.totals.totalTokens))}</td>
|
|
279
|
+
${hasCost ? `<td>${escapeHtml(formatEstimatedCost(day.totals.estimatedCostUSD))}</td>` : ''}
|
|
280
|
+
<td class="usage-models" title="${escapeHtml(day.models.map(model => model.modelName).join(', '))}">${escapeHtml(day.models.map(model => model.modelName).join(', ') || '—')}</td>
|
|
281
|
+
</tr>`).join('')}</tbody>
|
|
282
|
+
</table>`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderEngineNote(report) {
|
|
286
|
+
const engine = report.engine || { name: 'ccusage', version: 'unknown' };
|
|
287
|
+
const pricingMode = engine.pricingMode === 'embedded' ? 'embedded pricing' : 'pricing';
|
|
288
|
+
const parts = [`Statistics and ${pricingMode} calculated by ${engine.name} ${engine.version}`];
|
|
289
|
+
if (report.cost) parts.push(report.cost.note);
|
|
290
|
+
else parts.push('Cost is only shown for GPT models used by Codex.');
|
|
291
|
+
document.getElementById('usage-engine-note').textContent = parts.join(' · ');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function renderUsageDashboard(report) {
|
|
295
|
+
setUsageLoading(false);
|
|
296
|
+
updateUsageScopeButtons();
|
|
297
|
+
renderPeriodPicker(report);
|
|
298
|
+
const updated = report.generatedAt ? new Date(report.generatedAt) : null;
|
|
299
|
+
document.getElementById('usage-updated-at').textContent = updated && !Number.isNaN(updated.getTime())
|
|
300
|
+
? `Updated ${updated.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
|
301
|
+
: '';
|
|
302
|
+
renderAllModelTotals(report);
|
|
303
|
+
renderModelSummary(report);
|
|
304
|
+
renderStackedModelChart(report, {
|
|
305
|
+
containerId: 'usage-token-chart',
|
|
306
|
+
legendId: 'usage-token-legend',
|
|
307
|
+
metric: 'totalTokens',
|
|
308
|
+
emptyText: 'No token data in this period.',
|
|
309
|
+
formatExact: value => `${formatExactTokens(value)} tokens`,
|
|
310
|
+
formatCompact: formatCompactNumber
|
|
311
|
+
});
|
|
312
|
+
const hasCostChart = renderStackedModelChart(report, {
|
|
313
|
+
containerId: 'usage-cost-chart',
|
|
314
|
+
legendId: 'usage-cost-legend',
|
|
315
|
+
metric: 'estimatedCostUSD',
|
|
316
|
+
emptyText: 'No Codex GPT cost estimate in this period.',
|
|
317
|
+
formatExact: formatEstimatedCost,
|
|
318
|
+
formatCompact: value => formatEstimatedCost(value, true)
|
|
319
|
+
});
|
|
320
|
+
document.getElementById('usage-cost-panel').hidden = !hasCostChart;
|
|
321
|
+
renderDailyTable(report);
|
|
322
|
+
renderEngineNote(report);
|
|
323
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glad-web",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.40",
|
|
4
4
|
"description": "Glad transforms terminal-based AI coding tools into a polished, mobile-friendly local Web interface.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"glad": "bin/cli.js"
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@xterm/addon-fit": "^0.10.0",
|
|
53
53
|
"@xterm/headless": "^6.0.0",
|
|
54
54
|
"@xterm/xterm": "^5.5.0",
|
|
55
|
+
"ccusage": "20.0.20",
|
|
55
56
|
"chalk": "^4.1.2",
|
|
56
57
|
"commander": "^11.0.0",
|
|
57
58
|
"conf": "^10.2.0",
|