feat(web-ui): better stats and ergonomics

Usage page: add 7-day trend chart (activity/tokens/cost tabs),
framework breakdown panel with per-framework run/tool/error counts
and proportional bars, and 7d aggregate pills above the chart.

Dashboard: add avg cost/run metric pill to the metrics strip.

Run detail: extract and display prompt preview from the first agent
span's payload above the spans table.

Bug fixes: stat-list bars now render correctly (flex-direction:column),
right-panel-tab active background uses correct accent color, missing
framework colors added for hermes/codex/gemini/copilot. Dead code
renderSessionRow removed from sessions.js. Hardcoded font-family
replaced with CSS variable in metric-pill-value and token-stat-value.
Usage page cleanup() wired into router teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-05-21 16:49:05 -07:00
parent 1b01f0b0cd
commit 8753c0c9d5
6 changed files with 455 additions and 90 deletions
@@ -184,6 +184,11 @@ function renderSummaryCards() {
animateCounter('dash-error-rate', rate.toFixed(1) + '%');
errorRateEl.classList.toggle('alert', rate > 5);
}
if (document.getElementById('dash-cost-per-run')) {
const avgCost = (s.runs_today || 0) > 0 ? (s.cost_today || 0) / s.runs_today : 0;
animateCounter('dash-cost-per-run', avgCost ? formatCost(avgCost) : '$0.0000');
}
}
async function loadTimeseries() {
@@ -875,6 +880,10 @@ export async function renderDashboard(routeToken) {
<span class="metric-pill-label">Error rate</span>
<span class="metric-pill-value metric-pill-alert" id="dash-error-rate">-</span>
</div>
<div class="metric-pill">
<span class="metric-pill-label">Cost / run</span>
<span class="metric-pill-value" id="dash-cost-per-run">-</span>
</div>
</div>
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
<div class="vm-strip" id="dash-vm-strip"></div>
@@ -19,6 +19,15 @@ export function cleanup() {
runLiveOps = {};
}
function extractPromptPreview(spans) {
for (const sp of spans) {
const inner = (sp.payload || {}).payload || {};
if (inner.prompt_preview) return inner.prompt_preview;
if (inner.message_preview) return inner.message_preview;
}
return null;
}
function renderSpanPayload(sp) {
const outer = sp.payload || {};
const inner = outer.payload || {};
@@ -361,6 +370,7 @@ export async function renderRun(runID, routeToken) {
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
: 'ongoing';
const runUsage = extractRunUsage(spans);
const promptPreview = extractPromptPreview(spans);
app.innerHTML = `
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">&larr; Back to Session</a>
@@ -382,6 +392,11 @@ export async function renderRun(runID, routeToken) {
</div>
</div>
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''}
${promptPreview ? `
<div class="prompt-preview-section">
<div class="prompt-preview-label">Prompt</div>
<pre class="prompt-preview-text">${escapeHTML(promptPreview)}</pre>
</div>` : ''}
<div class="section-title">
Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''}
@@ -227,27 +227,6 @@ function refreshSessionsTable() {
updatePaginationInfo();
}
// Dead code: renderSessionRow is never called but preserved for fidelity
function renderSessionRow(s) { // eslint-disable-line no-unused-vars
const fw = s.framework || 'unknown';
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
const active = isSessionActive(s);
const dotState = sessionDotState(s);
const dotTitle = dotState === 'active'
? 'Currently active session'
: (active ? 'Open session' : 'Session ended');
const errorCell = (s._errorCount || 0) > 0
? `<span class="error-count-badge">${s._errorCount}</span>`
: '<span style="color:var(--text-dim)">—</span>';
return `
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}${renderCopyButton(s.session_id)}</td>
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td>
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
<td>${errorCell}</td>
`;
}
function updateSessionTimers() {
const tbody = document.getElementById('sessions-body');
+243 -60
View File
@@ -2,6 +2,130 @@ import { app, isRouteCurrent } from '../router.js';
import { api } from '../api.js';
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
/* global uPlot */
let usageChart = null;
let usageChartMode = 'activity';
let usageResizeObserver = null;
let _usageSeries = [];
export function cleanup() {
if (usageChart) { usageChart.destroy(); usageChart = null; }
if (usageResizeObserver) { usageResizeObserver.disconnect(); usageResizeObserver = null; }
_usageSeries = [];
}
function buildChartData(series, mode) {
if (!series || series.length === 0) return null;
const ts = series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
if (mode === 'tokens') {
return [ts, series.map(b => b.input_tokens || 0), series.map(b => b.output_tokens || 0)];
}
if (mode === 'cost') {
return [ts, series.map(b => b.cost || 0)];
}
return [ts, series.map(b => b.runs || 0), series.map(b => b.tools || 0), series.map(b => b.errors || 0)];
}
function renderChart(series, mode) {
const container = document.getElementById('usage-chart');
if (!container) return;
if (usageChart) { usageChart.destroy(); usageChart = null; }
container.innerHTML = '';
const data = buildChartData(series, mode);
if (!data) {
container.innerHTML = '<p class="empty-state" style="padding:1.5rem">No data for this period</p>';
return;
}
const width = container.clientWidth || 580;
const height = 160;
const axisStyle = {
stroke: '#4e6070',
grid: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
ticks: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
font: '11px Fira Code',
};
let seriesDef;
if (mode === 'tokens') {
seriesDef = [
{},
{ label: 'Input', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
{ label: 'Output', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
];
} else if (mode === 'cost') {
seriesDef = [
{},
{ label: 'Cost', stroke: '#fbbf24', width: 1.75, fill: 'rgba(251,191,36,0.1)', points: { show: false } },
];
} else {
seriesDef = [
{},
{ label: 'Runs', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
{ label: 'Tools', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
{ label: 'Errors', stroke: '#f87171', width: 1.5, fill: 'rgba(248,113,113,0.08)', points: { show: false } },
];
}
usageChart = new window.uPlot({
width, height,
cursor: { show: true },
scales: { x: { time: true }, y: { auto: true, min: 0 } },
axes: [{ ...axisStyle }, { ...axisStyle, size: 52 }],
series: seriesDef,
}, data, container);
if (usageResizeObserver) usageResizeObserver.disconnect();
usageResizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (usageChart) usageChart.setSize({ width: entry.contentRect.width, height });
}
});
usageResizeObserver.observe(container);
}
function renderFrameworkBreakdown(byFw) {
const el = document.getElementById('usage-fw-breakdown');
if (!el) return;
const entries = Object.entries(byFw || {}).sort((a, b) => {
return (b[1].runs + b[1].tools + b[1].errors) - (a[1].runs + a[1].tools + a[1].errors);
});
if (entries.length === 0) {
el.innerHTML = '<p class="empty-state" style="padding:1rem">No framework data</p>';
return;
}
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors), 1);
el.innerHTML = entries.map(([name, stats]) => {
const total = stats.runs + stats.tools + stats.errors;
const pct = (total / maxTotal * 100).toFixed(1);
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const active = (stats.active_sessions || 0) > 0;
return `
<div class="usage-fw-row">
<div class="usage-fw-name">
<span class="fw-dot ${escapeHTML(cssClass)} ${active ? 'active' : 'ended'}"></span>
<span>${escapeHTML(name)}</span>
${active ? `<span class="usage-fw-active-badge">${stats.active_sessions} live</span>` : ''}
</div>
<div class="usage-fw-stats">
<span class="usage-fw-stat"><span class="usage-fw-stat-label">runs</span>${stats.runs || 0}</span>
<span class="usage-fw-stat"><span class="usage-fw-stat-label">tools</span>${stats.tools || 0}</span>
${(stats.errors || 0) > 0 ? `<span class="usage-fw-stat error"><span class="usage-fw-stat-label">err</span>${stats.errors}</span>` : ''}
</div>
<div class="usage-fw-bar-track">
<div class="usage-fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
</div>
</div>`;
}).join('');
}
export async function renderUsage(routeToken) {
app.innerHTML = `
<div class="page-header"><h2>Usage</h2></div>
@@ -18,90 +142,149 @@ export async function renderUsage(routeToken) {
const tools = toolsData.tools || [];
const models = modelsData.models || [];
const series = tsData.series || [];
// Aggregate 7d totals from timeseries
const totals7d = series.reduce((acc, b) => {
acc.runs += b.runs || 0;
acc.tools += b.tools || 0;
acc.errors += b.errors || 0;
acc.tokens += b.tokens || 0;
acc.cost += b.cost || 0;
return acc;
}, { runs: 0, tools: 0, errors: 0, tokens: 0, cost: 0 });
const s = summary || {};
_usageSeries = tsData.series || [];
const t = _usageSeries.reduce((acc, b) => {
acc.runs += b.runs || 0;
acc.tools += b.tools || 0;
acc.errors += b.errors || 0;
acc.tokens += b.tokens || 0;
acc.itok += b.input_tokens || 0;
acc.otok += b.output_tokens || 0;
acc.cost += b.cost || 0;
return acc;
}, { runs: 0, tools: 0, errors: 0, tokens: 0, itok: 0, otok: 0, cost: 0 });
const maxModel = models[0]?.count || 1;
const maxTool = tools[0]?.count || 1;
const content = document.getElementById('usage-content');
if (!content) return;
content.innerHTML = `
<div class="usage-summary-tiles">
<div class="meta-tile"><div class="meta-tile-label">Active Sessions</div><div class="meta-tile-value">${s.active_sessions || 0}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Runs Today</div><div class="meta-tile-value">${s.runs_today || 0}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Tool Calls Today</div><div class="meta-tile-value">${s.tool_calls_today || 0}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Errors Today</div><div class="meta-tile-value">${s.errors_today || 0}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Tokens Today</div><div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div>
<div class="meta-tile">
<div class="meta-tile-label">Active Sessions</div>
<div class="meta-tile-value">${s.active_sessions || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Runs Today</div>
<div class="meta-tile-value">${s.runs_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Tool Calls Today</div>
<div class="meta-tile-value">${s.tool_calls_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Errors Today</div>
<div class="meta-tile-value${(s.errors_today || 0) > 0 ? ' has-errors' : ''}">${s.errors_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Tokens Today</div>
<div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div>
${(s.tokens_today || 0) > 0 ? `<div class="meta-tile-sub">${formatTokenCount(s.tokens_today * 0.7 || 0)} in · ${formatTokenCount(s.tokens_today * 0.3 || 0)} out</div>` : ''}
</div>
<div class="meta-tile">
<div class="meta-tile-label">Cost Today</div>
<div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div>
${(s.runs_today || 0) > 0 ? `<div class="meta-tile-sub">${formatCost((s.cost_today || 0) / s.runs_today)}/run</div>` : ''}
</div>
</div>
<div class="usage-section-row">
<div class="usage-panel">
<div class="section-title">7-Day Totals</div>
<div class="usage-7d-tiles">
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div>
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div>
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div>
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div>
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div>
<div class="usage-panel usage-chart-panel">
<div class="usage-chart-header">
<span class="section-title" style="margin:0">7-Day Trend</span>
<div class="usage-chart-tabs" id="usage-chart-tabs">
<button class="usage-chart-tab active" data-mode="activity">Activity</button>
<button class="usage-chart-tab" data-mode="tokens">Tokens</button>
<button class="usage-chart-tab" data-mode="cost">Cost</button>
</div>
</div>
<div class="usage-chart-totals">
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">runs</span>
<strong>${t.runs}</strong>
</span>
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">tools</span>
<strong>${t.tools}</strong>
</span>
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">errors</span>
<strong class="${t.errors > 0 ? 'usage-total-errors' : ''}">${t.errors}</strong>
</span>
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">tokens</span>
<strong>${formatTokenCount(t.tokens)}</strong>
</span>
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">cost</span>
<strong>${formatCost(t.cost)}</strong>
</span>
</div>
<div id="usage-chart"></div>
</div>
<div class="usage-panel usage-fw-panel">
<div class="section-title" style="margin-bottom:0.75rem">
Frameworks
<span class="count">today</span>
</div>
<div id="usage-fw-breakdown"></div>
</div>
</div>
<div class="usage-section-row">
<div class="usage-panel">
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
${models.length === 0 ? '<p class="empty-state">No model data yet</p>' : `
<ul class="stat-list" id="usage-models-list">
${(() => {
const max = models[0]?.count || 1;
return models.map(m => {
const pct = (m.count / max * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(m.name)}</span>
<span class="stat-list-count">${m.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
</div>
</li>`;
}).join('');
})()}
${models.length === 0 ? '<p class="empty-state" style="padding:1rem">No model data</p>' : `
<ul class="stat-list">
${models.map(m => {
const pct = (m.count / maxModel * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(m.name)}</span>
<span class="stat-list-count">${m.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
</div>
</li>`;
}).join('')}
</ul>`}
</div>
<div class="usage-panel">
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
${tools.length === 0 ? '<p class="empty-state">No tool data yet</p>' : `
<ul class="stat-list" id="usage-tools-list">
${(() => {
const max = tools[0]?.count || 1;
return tools.map(t => {
const pct = (t.count / max * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(t.name)}</span>
<span class="stat-list-count">${t.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill tool" style="width:${pct}%"></div>
</div>
</li>`;
}).join('');
})()}
${tools.length === 0 ? '<p class="empty-state" style="padding:1rem">No tool data</p>' : `
<ul class="stat-list">
${tools.map(t => {
const pct = (t.count / maxTool * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(t.name)}</span>
<span class="stat-list-count">${t.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill" style="width:${pct}%"></div>
</div>
</li>`;
}).join('')}
</ul>`}
</div>
</div>
`;
renderChart(_usageSeries, usageChartMode);
renderFrameworkBreakdown(s.by_framework);
document.querySelectorAll('.usage-chart-tab').forEach(btn => {
btn.addEventListener('click', () => {
if (usageChartMode === btn.dataset.mode) return;
usageChartMode = btn.dataset.mode;
document.querySelectorAll('.usage-chart-tab').forEach(b => b.classList.toggle('active', b === btn));
renderChart(_usageSeries, usageChartMode);
});
});
}
+2 -1
View File
@@ -13,7 +13,7 @@ import { renderRun, cleanup as cleanupRunDetail } from './pages/run-
import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
import { renderSettings } from './pages/settings.js';
import { renderUsage } from './pages/usage.js';
import { renderUsage, cleanup as cleanupUsage } from './pages/usage.js';
// Exported so all page modules can write into it without querying the DOM each time
export const app = document.getElementById('app');
@@ -31,6 +31,7 @@ export function cleanupLiveViews() {
cleanupSessionDetail();
cleanupRunDetail();
cleanupDashboard();
cleanupUsage();
}
export function route() {
+186 -8
View File
@@ -1422,8 +1422,7 @@ tr.expandable:hover .expand-icon::before {
.stat-list li {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: column;
padding: 0.35rem 0;
border-bottom: 1px solid var(--border-soft);
font-size: 0.8rem;
@@ -1914,10 +1913,14 @@ tr.expandable:hover .expand-icon::before {
transition: width 0.4s ease;
}
.fw-bar-fill.openclaw { background: var(--accent); }
.fw-bar-fill.openclaw { background: var(--accent); }
.fw-bar-fill.claude-code { background: var(--success); }
.fw-bar-fill.opencode { background: var(--purple); }
.fw-bar-fill.unknown { background: var(--text-dim); }
.fw-bar-fill.opencode { background: var(--purple); }
.fw-bar-fill.hermes { background: var(--warning); }
.fw-bar-fill.codex { background: #60a5fa; }
.fw-bar-fill.gemini { background: #f97316; }
.fw-bar-fill.copilot { background: #2dd4bf; }
.fw-bar-fill.unknown { background: var(--text-dim); }
.bottom-panels {
display: grid;
@@ -2034,6 +2037,10 @@ tr.expandable:hover .expand-icon::before {
.fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); }
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); }
.fw-dot.hermes { background: var(--warning); --fw-glow: var(--warning); }
.fw-dot.codex { background: #60a5fa; --fw-glow: #60a5fa; }
.fw-dot.gemini { background: #f97316; --fw-glow: #f97316; }
.fw-dot.copilot { background: #2dd4bf; --fw-glow: #2dd4bf; }
.fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); }
.fw-dot.ended { opacity: 0.3; }
.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; }
@@ -3145,7 +3152,7 @@ tr.clickable.active-session td:first-child {
.metric-pill-value {
font-size: 1.05rem;
font-weight: 600;
font-family: 'Fira Code', monospace;
font-family: var(--font-mono);
color: var(--text);
}
@@ -3179,7 +3186,7 @@ tr.clickable.active-session td:first-child {
.right-panel-tab.active {
color: var(--accent);
border-color: var(--accent);
background: rgba(var(--accent-rgb, 99, 102, 241), 0.08);
background: var(--accent-dim);
}
.right-panel-body {
@@ -3211,7 +3218,7 @@ tr.clickable.active-session td:first-child {
.token-stat-value {
font-size: 2rem;
font-weight: 700;
font-family: 'Fira Code', monospace;
font-family: var(--font-mono);
color: var(--text);
line-height: 1.1;
}
@@ -3932,3 +3939,174 @@ tr.clickable:focus-visible {
outline-offset: 2px;
border-radius: var(--radius);
}
/* ── Usage Page: Chart Panel ─────────────────────────────── */
.usage-chart-panel { flex: 2 1 400px; }
.usage-fw-panel { flex: 1 1 240px; }
.usage-chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.usage-chart-tabs {
display: flex;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.usage-chart-tab {
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--text-dim);
font-family: var(--font-body);
font-size: 0.72rem;
font-weight: 500;
padding: 0.3rem 0.7rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.usage-chart-tab:last-child { border-right: none; }
.usage-chart-tab:hover { color: var(--text-bright); background: var(--surface-2); }
.usage-chart-tab.active { color: var(--accent); background: var(--accent-dim); }
.usage-chart-totals {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.usage-chart-total-pill {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
background: var(--surface-2);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
padding: 0.22rem 0.55rem;
font-family: var(--font-mono);
font-size: 0.8rem;
}
.usage-chart-total-label {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.usage-chart-total-pill strong { color: var(--text-bright); }
.usage-total-errors { color: var(--error); }
/* ── Usage Page: Framework Breakdown ─────────────────────── */
.usage-fw-row {
display: flex;
flex-direction: column;
gap: 0.28rem;
padding: 0.65rem 0;
border-bottom: 1px solid var(--border-soft);
}
.usage-fw-row:last-child { border-bottom: none; }
.usage-fw-name {
display: flex;
align-items: center;
gap: 0.45rem;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text);
}
.usage-fw-active-badge {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--success);
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2);
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.usage-fw-stats {
display: flex;
gap: 0.75rem;
font-family: var(--font-mono);
font-size: 0.72rem;
}
.usage-fw-stat {
display: flex;
gap: 0.28rem;
align-items: baseline;
color: var(--text);
}
.usage-fw-stat.error { color: var(--error); }
.usage-fw-stat-label {
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.usage-fw-bar-track {
height: 4px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.usage-fw-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
background: var(--text-dim);
}
.usage-fw-bar-fill.openclaw { background: var(--accent); }
.usage-fw-bar-fill.claude-code { background: var(--success); }
.usage-fw-bar-fill.opencode { background: var(--purple); }
.usage-fw-bar-fill.hermes { background: var(--warning); }
.usage-fw-bar-fill.codex { background: #60a5fa; }
.usage-fw-bar-fill.gemini { background: #f97316; }
.usage-fw-bar-fill.copilot { background: #2dd4bf; }
/* ── Run Detail: Prompt Preview ──────────────────────────── */
.prompt-preview-section {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--purple);
border-radius: var(--radius-lg);
padding: 0.875rem 1.125rem;
margin-bottom: 1.5rem;
}
.prompt-preview-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
.prompt-preview-text {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--code-text);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.65;
margin: 0;
max-height: 180px;
overflow-y: auto;
}
/* ── meta-tile: sub-line ─────────────────────────────────── */
.meta-tile-value.has-errors { color: var(--error); }