import { html, svg, nothing } from "lit" ;
import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts" ;
import { t } from "../../i18n/index.ts" ;
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts" ;
import { parseToolSummary } from "../usage-helpers.ts" ;
import { charsToTokens, formatCost, formatTokens } from "./usage-metrics.ts" ;
import { renderInsightList } from "./usage-render-overview.ts" ;
import {
SessionLogEntry,
SessionLogRole,
TimeSeriesPoint,
UsageSessionEntry,
} from "./usageTypes.ts" ;
// Chart constants
const CHART_BAR_WIDTH_RATIO = 0 .75 ; // Fraction of slot used for bar (rest is gap)
const CHART_MAX_BAR_WIDTH = 8 ; // Max bar width in SVG viewBox units
const CHART_SELECTION_OPACITY = 0 .06 ; // Opacity of range selection overlay
const HANDLE_WIDTH = 5 ; // Width of drag handle in SVG units
const HANDLE_HEIGHT = 12 ; // Height of drag handle
const HANDLE_GRIP_OFFSET = 0 .7 ; // Offset of grip lines inside handle
function pct(part: number, total: number): number {
if (!total || total <= 0 ) {
return 0 ;
}
return (part / total) * 100 ;
}
/** Normalize a log timestamp to milliseconds (handles seconds vs ms). */
function normalizeLogTimestamp(ts: number): number {
return ts < 1 e12 ? ts * 1000 : ts;
}
/** Filter session logs by a timestamp range. */
function filterLogsByRange(
logs: SessionLogEntry[],
rangeStart: number,
rangeEnd: number,
): SessionLogEntry[] {
const lo = Math.min(rangeStart, rangeEnd);
const hi = Math.max(rangeStart, rangeEnd);
return logs.filter((log) => {
if (log.timestamp <= 0 ) {
return true ;
}
const ts = normalizeLogTimestamp(log.timestamp);
return ts >= lo && ts <= hi;
});
}
function renderSessionSummary(
session: UsageSessionEntry,
filteredUsage?: UsageSessionEntry["usage" ],
filteredLogs?: SessionLogEntry[],
) {
const usage = filteredUsage || session.usage;
if (!usage) {
return html` <div class ="usage-empty-block" >${t("usage.details.noUsageData" )}</div> `;
}
const formatTs = (ts?: number): string =>
ts ? new Date(ts).toLocaleString() : t("usage.common.emptyValue" );
const badges: string[] = [];
if (session.channel) {
badges.push(`channel:${session.channel}`);
}
if (session.agentId) {
badges.push(`agent:${session.agentId}`);
}
if (session.modelProvider || session.providerOverride) {
badges.push(`provider:${session.modelProvider ?? session.providerOverride}`);
}
if (session.model) {
badges.push(`model:${session.model}`);
}
// Always use the full tool list for stable layout; update counts when filtering
const baseTools = usage.toolUsage?.tools.slice(0 , 6 ) ?? [];
let toolCallCount: number;
let uniqueToolCount: number;
let toolItems: Array<{ label: string; value: string; sub: string }>;
if (filteredLogs) {
const toolCounts = new Map<string, number>();
for (const log of filteredLogs) {
const { tools } = parseToolSummary(log.content);
for (const [name] of tools) {
toolCounts.set(name, (toolCounts.get(name) || 0 ) + 1 );
}
}
// Keep the same tool order as the full session, just update counts
toolItems = baseTools.map((tool) => ({
label: tool.name,
value: `${toolCounts.get(tool.name) ?? 0 }`,
sub: t("usage.overview.calls" ),
}));
toolCallCount = [...toolCounts.values()].reduce((sum, c) => sum + c, 0 );
uniqueToolCount = toolCounts.size;
} else {
toolItems = baseTools.map((tool) => ({
label: tool.name,
value: `${tool.count}`,
sub: t("usage.overview.calls" ),
}));
toolCallCount = usage.toolUsage?.totalCalls ?? 0 ;
uniqueToolCount = usage.toolUsage?.uniqueTools ?? 0 ;
}
const modelItems =
usage.modelUsage?.slice(0 , 6 ).map((entry) => ({
label: entry.model ?? t("usage.common.unknown" ),
value: formatCost(entry.totals.totalCost),
sub: formatTokens(entry.totals.totalTokens),
})) ?? [];
return html`
${badges.length > 0
? html`<div class ="usage-badges" >
${badges.map((b) => html`<span class ="usage-badge" >${b}</span>`)}
</div>`
: nothing}
<div class ="session-summary-grid" >
<div class ="stat session-summary-card" >
<div class ="session-summary-title" >${t("usage.overview.messages" )}</div>
<div class ="stat-value session-summary-value" >${usage.messageCounts?.total ?? 0 }</div>
<div class ="session-summary-meta" >
${usage.messageCounts?.user ?? 0 }
${normalizeLowercaseStringOrEmpty(t("usage.overview.user" ))} ·
${usage.messageCounts?.assistant ?? 0 }
${normalizeLowercaseStringOrEmpty(t("usage.overview.assistant" ))}
</div>
</div>
<div class ="stat session-summary-card" >
<div class ="session-summary-title" >${t("usage.overview.toolCalls" )}</div>
<div class ="stat-value session-summary-value" >${toolCallCount}</div>
<div class ="session-summary-meta" >${uniqueToolCount} ${t("usage.overview.toolsUsed" )}</div>
</div>
<div class ="stat session-summary-card" >
<div class ="session-summary-title" >${t("usage.overview.errors" )}</div>
<div class ="stat-value session-summary-value" >${usage.messageCounts?.errors ?? 0 }</div>
<div class ="session-summary-meta" >
${usage.messageCounts?.toolResults ?? 0 } ${t("usage.overview.toolResults" )}
</div>
</div>
<div class ="stat session-summary-card" >
<div class ="session-summary-title" >${t("usage.details.duration" )}</div>
<div class ="stat-value session-summary-value" >
${formatDurationCompact(usage.durationMs, { spaced: true }) ??
t("usage.common.emptyValue" )}
</div>
<div class ="session-summary-meta" >
${formatTs(usage.firstActivity)} → ${formatTs(usage.lastActivity)}
</div>
</div>
</div>
<div class ="usage-insights-grid usage-insights-grid--tight" >
${renderInsightList(t("usage.overview.topTools" ), toolItems, t("usage.overview.noToolCalls" ))}
${renderInsightList(t("usage.details.modelMix" ), modelItems, t("usage.overview.noModelData" ))}
</div>
`;
}
/** Aggregate usage stats from time series points within a timestamp range. */
function computeFilteredUsage(
baseUsage: NonNullable<UsageSessionEntry["usage" ]>,
points: TimeSeriesPoint[],
rangeStart: number,
rangeEnd: number,
): UsageSessionEntry["usage" ] | undefined {
const lo = Math.min(rangeStart, rangeEnd);
const hi = Math.max(rangeStart, rangeEnd);
const filtered = points.filter((p) => p.timestamp >= lo && p.timestamp <= hi);
if (filtered.length === 0 ) {
return undefined;
}
let totalTokens = 0 ;
let totalCost = 0 ;
let userMessages = 0 ;
let assistantMessages = 0 ;
let totalInput = 0 ;
let totalOutput = 0 ;
let totalCacheRead = 0 ;
let totalCacheWrite = 0 ;
for (const p of filtered) {
totalTokens += p.totalTokens || 0 ;
totalCost += p.cost || 0 ;
totalInput += p.input || 0 ;
totalOutput += p.output || 0 ;
totalCacheRead += p.cacheRead || 0 ;
totalCacheWrite += p.cacheWrite || 0 ;
if (p.output > 0 ) {
assistantMessages++;
}
if (p.input > 0 ) {
userMessages++;
}
}
return {
...baseUsage,
totalTokens,
totalCost,
input: totalInput,
output: totalOutput,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
durationMs: filtered[filtered.length - 1 ].timestamp - filtered[0 ].timestamp,
firstActivity: filtered[0 ].timestamp,
lastActivity: filtered[filtered.length - 1 ].timestamp,
messageCounts: {
total: filtered.length,
user: userMessages,
assistant: assistantMessages,
toolCalls: 0 ,
toolResults: 0 ,
errors: 0 ,
},
};
}
function renderSessionDetailPanel(
session: UsageSessionEntry,
timeSeries: { points: TimeSeriesPoint[] } | null ,
timeSeriesLoading: boolean ,
timeSeriesMode: "cumulative" | "per-turn" ,
onTimeSeriesModeChange: (mode: "cumulative" | "per-turn" ) => void ,
timeSeriesBreakdownMode: "total" | "by-type" ,
onTimeSeriesBreakdownChange: (mode: "total" | "by-type" ) => void ,
timeSeriesCursorStart: number | null ,
timeSeriesCursorEnd: number | null ,
onTimeSeriesCursorRangeChange: (start: number | null , end: number | null ) => void ,
startDate: string,
endDate: string,
selectedDays: string[],
sessionLogs: SessionLogEntry[] | null ,
sessionLogsLoading: boolean ,
sessionLogsExpanded: boolean ,
onToggleSessionLogsExpanded: () => void ,
logFilters: {
roles: SessionLogRole[];
tools: string[];
hasTools: boolean ;
query: string;
},
onLogFilterRolesChange: (next: SessionLogRole[]) => void ,
onLogFilterToolsChange: (next: string[]) => void ,
onLogFilterHasToolsChange: (next: boolean ) => void ,
onLogFilterQueryChange: (next: string) => void ,
onLogFilterClear: () => void ,
contextExpanded: boolean ,
onToggleContextExpanded: () => void ,
onClose: () => void ,
) {
const label = session.label || session.key;
const displayLabel = label.length > 50 ? label.slice(0 , 50 ) + "…" : label;
const usage = session.usage;
const hasRange = timeSeriesCursorStart !== null && timeSeriesCursorEnd !== null ;
const filteredUsage =
timeSeriesCursorStart !== null && timeSeriesCursorEnd !== null && timeSeries?.points && usage
? computeFilteredUsage(usage, timeSeries.points, timeSeriesCursorStart, timeSeriesCursorEnd)
: undefined;
const headerStats = filteredUsage
? { totalTokens: filteredUsage.totalTokens, totalCost: filteredUsage.totalCost }
: { totalTokens: usage?.totalTokens ?? 0 , totalCost: usage?.totalCost ?? 0 };
const cursorIndicator = filteredUsage ? t("usage.details.filtered" ) : "" ;
return html`
<div class ="card session-detail-panel" >
<div class ="session-detail-header" >
<div class ="session-detail-header-left" >
<div class ="session-detail-title" >
${displayLabel}
${cursorIndicator
? html`<span class ="session-detail-indicator" >${cursorIndicator}</span>`
: nothing}
</div>
</div>
<div class ="session-detail-stats" >
${usage
? html`
<span
><strong>${formatTokens(headerStats.totalTokens)}</strong>
${normalizeLowercaseStringOrEmpty(
t("usage.metrics.tokens" ),
)}${cursorIndicator}</span
>
<span><strong>${formatCost(headerStats.totalCost)}</strong>${cursorIndicator}</span>
`
: nothing}
</div>
<button
class ="btn btn--sm btn--ghost"
@click=${onClose}
title=${t("usage.details.close" )}
aria-label=${t("usage.details.close" )}
>
×
</button>
</div>
<div class ="session-detail-content" >
${renderSessionSummary(
session,
filteredUsage,
timeSeriesCursorStart != null && timeSeriesCursorEnd != null && sessionLogs
? filterLogsByRange(sessionLogs, timeSeriesCursorStart, timeSeriesCursorEnd)
: undefined,
)}
<div class ="session-detail-row" >
${renderTimeSeriesCompact(
timeSeries,
timeSeriesLoading,
timeSeriesMode,
onTimeSeriesModeChange,
timeSeriesBreakdownMode,
onTimeSeriesBreakdownChange,
startDate,
endDate,
selectedDays,
timeSeriesCursorStart,
timeSeriesCursorEnd,
onTimeSeriesCursorRangeChange,
)}
</div>
<div class ="session-detail-bottom" >
${renderSessionLogsCompact(
sessionLogs,
sessionLogsLoading,
sessionLogsExpanded,
onToggleSessionLogsExpanded,
logFilters,
onLogFilterRolesChange,
onLogFilterToolsChange,
onLogFilterHasToolsChange,
onLogFilterQueryChange,
onLogFilterClear,
hasRange ? timeSeriesCursorStart : null ,
hasRange ? timeSeriesCursorEnd : null ,
)}
${renderContextPanel(
session.contextWeight,
usage,
contextExpanded,
onToggleContextExpanded,
)}
</div>
</div>
</div>
`;
}
function renderTimeSeriesCompact(
timeSeries: { points: TimeSeriesPoint[] } | null ,
loading: boolean ,
mode: "cumulative" | "per-turn" ,
onModeChange: (mode: "cumulative" | "per-turn" ) => void ,
breakdownMode: "total" | "by-type" ,
onBreakdownChange: (mode: "total" | "by-type" ) => void ,
startDate?: string,
endDate?: string,
selectedDays?: string[],
cursorStart?: number | null ,
cursorEnd?: number | null ,
onCursorRangeChange?: (start: number | null , end: number | null ) => void ,
) {
if (loading) {
return html`
<div class ="session-timeseries-compact" >
<div class ="usage-empty-block" >${t("usage.loading.badge" )}</div>
</div>
`;
}
if (!timeSeries || timeSeries.points.length < 2 ) {
return html`
<div class ="session-timeseries-compact" >
<div class ="usage-empty-block" >${t("usage.details.noTimeline" )}</div>
</div>
`;
}
// Filter and recalculate (same logic as main function)
let points = timeSeries.points;
if (startDate || endDate || (selectedDays && selectedDays.length > 0 )) {
const startTs = startDate ? new Date(startDate + "T00:00:00" ).getTime() : 0 ;
const endTs = endDate ? new Date(endDate + "T23:59:59" ).getTime() : Infinity;
points = timeSeries.points.filter((p) => {
if (p.timestamp < startTs || p.timestamp > endTs) {
return false ;
}
if (selectedDays && selectedDays.length > 0 ) {
const d = new Date(p.timestamp);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1 ).padStart(2 , "0" )}-${String(d.getDate()).padStart(2 , "0" )}`;
return selectedDays.includes(dateStr);
}
return true ;
});
}
if (points.length < 2 ) {
return html`
<div class ="session-timeseries-compact" >
<div class ="usage-empty-block" >${t("usage.details.noDataInRange" )}</div>
</div>
`;
}
let cumTokens = 0 ,
cumCost = 0 ;
let sumOutput = 0 ;
let sumInput = 0 ;
let sumCacheRead = 0 ;
let sumCacheWrite = 0 ;
points = points.map((p) => {
cumTokens += p.totalTokens;
cumCost += p.cost;
sumOutput += p.output;
sumInput += p.input;
sumCacheRead += p.cacheRead;
sumCacheWrite += p.cacheWrite;
return { ...p, cumulativeTokens: cumTokens, cumulativeCost: cumCost };
});
// Compute range-filtered sums for "Tokens by Type"
const hasSelection = cursorStart != null && cursorEnd != null ;
const rangeStartTs = hasSelection ? Math.min(cursorStart, cursorEnd) : 0 ;
const rangeEndTs = hasSelection ? Math.max(cursorStart, cursorEnd) : Infinity;
// Find start/end indices for dimming
let rangeStartIdx = 0 ;
let rangeEndIdx = points.length;
if (hasSelection) {
rangeStartIdx = points.findIndex((p) => p.timestamp >= rangeStartTs);
if (rangeStartIdx === -1 ) {
rangeStartIdx = points.length;
}
const endIdx = points.findIndex((p) => p.timestamp > rangeEndTs);
rangeEndIdx = endIdx === -1 ? points.length : endIdx;
}
const filteredPoints = hasSelection ? points.slice(rangeStartIdx, rangeEndIdx) : points;
let filteredOutput = 0 ,
filteredInput = 0 ,
filteredCacheRead = 0 ,
filteredCacheWrite = 0 ;
for (const p of filteredPoints) {
filteredOutput += p.output;
filteredInput += p.input;
filteredCacheRead += p.cacheRead;
filteredCacheWrite += p.cacheWrite;
}
const width = 400 ,
height = 100 ;
const padding = { top: 8 , right: 4 , bottom: 14 , left: 30 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const isCumulative = mode === "cumulative" ;
const breakdownByType = mode === "per-turn" && breakdownMode === "by-type" ;
const totalTypeTokens = filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite;
const barTotals = points.map((p) =>
isCumulative
? p.cumulativeTokens
: breakdownByType
? p.input + p.output + p.cacheRead + p.cacheWrite
: p.totalTokens,
);
const maxValue = Math.max(...barTotals, 1 );
// Ensure bars + gaps fit exactly within chartWidth
const slotWidth = chartWidth / points.length; // space per bar including gap
const barWidth = Math.min(CHART_MAX_BAR_WIDTH, Math.max(1 , slotWidth * CHART_BAR_WIDTH_RATIO));
const barGap = slotWidth - barWidth;
// Pre-compute handle X positions in SVG viewBox coordinates
const leftHandleX = padding.left + rangeStartIdx * (barWidth + barGap);
const rightHandleX =
rangeEndIdx >= points.length
? padding.left + (points.length - 1 ) * (barWidth + barGap) + barWidth // right edge of last bar
: padding.left + (rangeEndIdx - 1 ) * (barWidth + barGap) + barWidth; // right edge of last selected bar
return html`
<div class ="session-timeseries-compact" >
<div class ="timeseries-header-row" >
<div class ="card-title usage-section-title" >${t("usage.details.usageOverTime" )}</div>
<div class ="timeseries-controls" >
${hasSelection
? html`
<div class ="chart-toggle small" >
<button
class ="btn btn--sm toggle-btn active"
@click=${() => onCursorRangeChange?.(null , null )}
>
${t("usage.details.reset" )}
</button>
</div>
`
: nothing}
<div class ="chart-toggle small" >
<button
class ="btn btn--sm toggle-btn ${!isCumulative ? " active" : " "}"
@click=${() => onModeChange("per-turn" )}
>
${t("usage.details.perTurn" )}
</button>
<button
class ="btn btn--sm toggle-btn ${isCumulative ? " active" : " "}"
@click=${() => onModeChange("cumulative" )}
>
${t("usage.details.cumulative" )}
</button>
</div>
${!isCumulative
? html`
<div class ="chart-toggle small" >
<button
class ="btn btn--sm toggle-btn ${breakdownMode === " total" ? " active" : " "}"
@click=${() => onBreakdownChange("total" )}
>
${t("usage.daily.total" )}
</button>
<button
class ="btn btn--sm toggle-btn ${breakdownMode === " by-type" ? " active" : " "}"
@click=${() => onBreakdownChange("by-type" )}
>
${t("usage.daily.byType" )}
</button>
</div>
`
: nothing}
</div>
</div>
<div class ="timeseries-chart-wrapper" >
<svg viewBox="0 0 ${width} ${height + 18}" class ="timeseries-svg" >
<!-- Y axis -->
<line
x1="${padding.left}"
y1="${padding.top}"
x2="${padding.left}"
y2="${padding.top + chartHeight}"
stroke="var(--border)"
/>
<!-- X axis -->
<line
x1="${padding.left}"
y1="${padding.top + chartHeight}"
x2="${width - padding.right}"
y2="${padding.top + chartHeight}"
stroke="var(--border)"
/>
<!-- Y axis labels -->
<text
x="${padding.left - 4}"
y="${padding.top + 5}"
text-anchor="end"
class ="ts-axis-label"
>
${formatTokens(maxValue)}
</text>
<text
x="${padding.left - 4}"
y="${padding.top + chartHeight}"
text-anchor="end"
class ="ts-axis-label"
>
0
</text>
<!-- X axis labels (first and last) -->
${points.length > 0
? svg`
<text x="${padding.left}" y="${padding.top + chartHeight + 10}" text-anchor="start" class ="ts-axis-label" >${new Date(points[0 ].timestamp).toLocaleTimeString(undefined, { hour: "2-digit" , minute: "2-digit" })}</text>
<text x="${width - padding.right}" y="${padding.top + chartHeight + 10}" text-anchor="end" class ="ts-axis-label" >${new Date(points[points.length - 1 ].timestamp).toLocaleTimeString(undefined, { hour: "2-digit" , minute: "2-digit" })}</text>
`
: nothing}
<!-- Bars -->
${points.map((p, i) => {
const val = barTotals[i];
const x = padding.left + i * (barWidth + barGap);
const bh = (val / maxValue) * chartHeight;
const y = padding.top + chartHeight - bh;
const date = new Date(p.timestamp);
const tooltipLines = [
date.toLocaleDateString(undefined, {
month: "short" ,
day: "numeric" ,
hour: "2-digit" ,
minute: "2-digit" ,
}),
`${formatTokens(val)} ${normalizeLowercaseStringOrEmpty(t("usage.metrics.tokens" ))}`,
];
if (breakdownByType) {
tooltipLines.push(`Out ${formatTokens(p.output)}`);
tooltipLines.push(`In ${formatTokens(p.input)}`);
tooltipLines.push(`CW ${formatTokens(p.cacheWrite)}`);
tooltipLines.push(`CR ${formatTokens(p.cacheRead)}`);
}
const tooltip = tooltipLines.join(" · " );
const isOutside = hasSelection && (i < rangeStartIdx || i >= rangeEndIdx);
if (!breakdownByType) {
return svg`<rect x="${x}" y="${y}" width="${barWidth}" height="${bh}" class ="ts-bar${isOutside ? " dimmed" : " "}" rx="1" ><title>${tooltip}</title></rect>`;
}
const segments = [
{ value: p.output, cls: "output" },
{ value: p.input, cls: "input" },
{ value: p.cacheWrite, cls: "cache-write" },
{ value: p.cacheRead, cls: "cache-read" },
];
let yC = padding.top + chartHeight;
const dim = isOutside ? " dimmed" : "" ;
return svg`
${segments.map((seg) => {
if (seg.value <= 0 || val <= 0 ) {
return nothing;
}
const sh = bh * (seg.value / val);
yC -= sh;
return svg`<rect x="${x}" y="${yC}" width="${barWidth}" height="${sh}" class ="ts-bar ${seg.cls}${dim}" rx="1" ><title>${tooltip}</title></rect>`;
})}
`;
})}
<!-- Selection highlight overlay (always visible between handles) -->
${svg`
<rect
x="${leftHandleX}"
y="${padding.top}"
width="${Math.max(1, rightHandleX - leftHandleX)}"
height="${chartHeight}"
fill="var(--accent)"
opacity="${CHART_SELECTION_OPACITY}"
pointer-events="none"
/>
`}
<!-- Left cursor line + handle -->
${svg`
<line x1="${leftHandleX}" y1="${padding.top}" x2="${leftHandleX}" y2="${padding.top + chartHeight}" stroke="var(--accent)" stroke-width="0.8" opacity="0.7" />
<rect x="${leftHandleX - HANDLE_WIDTH / 2}" y="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 2}" width="${HANDLE_WIDTH}" height="${HANDLE_HEIGHT}" rx="1.5" fill="var(--accent)" class ="cursor-handle" />
<line x1="${leftHandleX - HANDLE_GRIP_OFFSET}" y1="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 5}" x2="${leftHandleX - HANDLE_GRIP_OFFSET}" y2="${padding.top + chartHeight / 2 + HANDLE_HEIGHT / 5}" stroke="var(--bg)" stroke-width="0.4" pointer-events="none" />
<line x1="${leftHandleX + HANDLE_GRIP_OFFSET}" y1="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 5}" x2="${leftHandleX + HANDLE_GRIP_OFFSET}" y2="${padding.top + chartHeight / 2 + HANDLE_HEIGHT / 5}" stroke="var(--bg)" stroke-width="0.4" pointer-events="none" />
`}
<!-- Right cursor line + handle -->
${svg`
<line x1="${rightHandleX}" y1="${padding.top}" x2="${rightHandleX}" y2="${padding.top + chartHeight}" stroke="var(--accent)" stroke-width="0.8" opacity="0.7" />
<rect x="${rightHandleX - HANDLE_WIDTH / 2}" y="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 2}" width="${HANDLE_WIDTH}" height="${HANDLE_HEIGHT}" rx="1.5" fill="var(--accent)" class ="cursor-handle" />
<line x1="${rightHandleX - HANDLE_GRIP_OFFSET}" y1="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 5}" x2="${rightHandleX - HANDLE_GRIP_OFFSET}" y2="${padding.top + chartHeight / 2 + HANDLE_HEIGHT / 5}" stroke="var(--bg)" stroke-width="0.4" pointer-events="none" />
<line x1="${rightHandleX + HANDLE_GRIP_OFFSET}" y1="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 5}" x2="${rightHandleX + HANDLE_GRIP_OFFSET}" y2="${padding.top + chartHeight / 2 + HANDLE_HEIGHT / 5}" stroke="var(--bg)" stroke-width="0.4" pointer-events="none" />
`}
</svg>
<!-- Handle drag zones (only on handles, not full chart) -->
${(() => {
const leftHandlePos = `${((leftHandleX / width) * 100 ).toFixed(1 )}%`;
const rightHandlePos = `${((rightHandleX / width) * 100 ).toFixed(1 )}%`;
const makeDragHandler = (side: "left" | "right" ) => (e: MouseEvent) => {
if (!onCursorRangeChange) {
return ;
}
e.preventDefault();
e.stopPropagation();
// Find the wrapper, then the SVG inside it
const wrapper = (e.currentTarget as HTMLElement).closest(".timeseries-chart-wrapper" );
const svgEl = wrapper?.querySelector("svg" ) as SVGSVGElement;
if (!svgEl) {
return ;
}
// Capture rect once at mousedown to avoid re-render offset shifts
const rect = svgEl.getBoundingClientRect();
const svgWidth = rect.width;
const chartLeftPx = (padding.left / width) * svgWidth;
const chartRightPx = ((width - padding.right) / width) * svgWidth;
const chartW = chartRightPx - chartLeftPx;
const posToIdx = (clientX: number) => {
const x = Math.max(0 , Math.min(1 , (clientX - rect.left - chartLeftPx) / chartW));
return Math.min(Math.floor(x * points.length), points.length - 1 );
};
// Compute click offset: where on the handle the user grabbed
const handleSvgX = side === "left" ? leftHandleX : rightHandleX;
const handleClientX = rect.left + (handleSvgX / width) * svgWidth;
const grabOffset = e.clientX - handleClientX;
document.body.style.cursor = "col-resize" ;
const handleMove = (me: MouseEvent) => {
const adjustedX = me.clientX - grabOffset;
const idx = posToIdx(adjustedX);
const pt = points[idx];
if (!pt) {
return ;
}
if (side === "left" ) {
const endTs = cursorEnd ?? points[points.length - 1 ].timestamp;
// Don't let left go past right
onCursorRangeChange(Math.min(pt.timestamp, endTs), endTs);
} else {
const startTs = cursorStart ?? points[0 ].timestamp;
// Don't let right go past left
onCursorRangeChange(startTs, Math.max(pt.timestamp, startTs));
}
};
const handleUp = () => {
document.body.style.cursor = "" ;
document.removeEventListener("mousemove" , handleMove);
document.removeEventListener("mouseup" , handleUp);
};
document.addEventListener("mousemove" , handleMove);
document.addEventListener("mouseup" , handleUp);
};
return html`
<div
class ="chart-handle-zone chart-handle-left"
style="left: ${leftHandlePos};"
@mousedown=${makeDragHandler("left" )}
></div>
<div
class ="chart-handle-zone chart-handle-right"
style="left: ${rightHandlePos};"
@mousedown=${makeDragHandler("right" )}
></div>
`;
})()}
</div>
<div class ="timeseries-summary" >
${hasSelection
? html`
<span class ="timeseries-summary__range" >
${t("usage.details.turnRange" , {
start: String(rangeStartIdx + 1 ),
end: String(rangeEndIdx),
total: String(points.length),
})}
</span>
·
${new Date(rangeStartTs).toLocaleTimeString(undefined, {
hour: "2-digit" ,
minute: "2-digit" ,
})}–${new Date(rangeEndTs).toLocaleTimeString(undefined, {
hour: "2-digit" ,
minute: "2-digit" ,
})}
·
${formatTokens(
filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite,
)}
· ${formatCost(filteredPoints.reduce((s, p) => s + (p.cost || 0 ), 0 ))}
`
: html`${points.length} ${t("usage.overview.messagesAbbrev" )} · ${formatTokens(cumTokens)}
· ${formatCost(cumCost)}`}
</div>
${breakdownByType
? html`
<div class ="timeseries-breakdown" >
<div class ="card-title usage-section-title" >${t("usage.breakdown.tokensByType" )}</div>
<div class ="cost-breakdown-bar cost-breakdown-bar--compact" >
<div
class ="cost-segment output"
style="width: ${pct(filteredOutput, totalTypeTokens).toFixed(1)}%"
></div>
<div
class ="cost-segment input"
style="width: ${pct(filteredInput, totalTypeTokens).toFixed(1)}%"
></div>
<div
class ="cost-segment cache-write"
style="width: ${pct(filteredCacheWrite, totalTypeTokens).toFixed(1)}%"
></div>
<div
class ="cost-segment cache-read"
style="width: ${pct(filteredCacheRead, totalTypeTokens).toFixed(1)}%"
></div>
</div>
<div class ="cost-breakdown-legend" >
<div class ="legend-item" title=${t("usage.details.assistantOutputTokens" )}>
<span class ="legend-dot output" ></span>${t("usage.breakdown.output" )}
${formatTokens(filteredOutput)}
</div>
<div class ="legend-item" title=${t("usage.details.userToolInputTokens" )}>
<span class ="legend-dot input" ></span>${t("usage.breakdown.input" )}
${formatTokens(filteredInput)}
</div>
<div class ="legend-item" title=${t("usage.details.tokensWrittenToCache" )}>
<span class ="legend-dot cache-write" ></span>${t("usage.breakdown.cacheWrite" )}
${formatTokens(filteredCacheWrite)}
</div>
<div class ="legend-item" title=${t("usage.details.tokensReadFromCache" )}>
<span class ="legend-dot cache-read" ></span>${t("usage.breakdown.cacheRead" )}
${formatTokens(filteredCacheRead)}
</div>
</div>
<div class ="cost-breakdown-total" >
${t("usage.breakdown.total" )}: ${formatTokens(totalTypeTokens)}
</div>
</div>
`
: nothing}
</div>
`;
}
function renderContextPanel(
contextWeight: UsageSessionEntry["contextWeight" ],
usage: UsageSessionEntry["usage" ],
expanded: boolean ,
onToggleExpanded: () => void ,
) {
if (!contextWeight) {
return html`
<div class ="context-details-panel" >
<div class ="usage-empty-block" >${t("usage.details.noContextData" )}</div>
</div>
`;
}
const systemTokens = charsToTokens(contextWeight.systemPrompt.chars);
const skillsTokens = charsToTokens(contextWeight.skills.promptChars);
const toolsTokens = charsToTokens(
contextWeight.tools.listChars + contextWeight.tools.schemaChars,
);
const filesTokens = charsToTokens(
contextWeight.injectedWorkspaceFiles.reduce((sum, f) => sum + f.injectedChars, 0 ),
);
const totalContextTokens = systemTokens + skillsTokens + toolsTokens + filesTokens;
let contextPct = "" ;
if (usage && usage.totalTokens > 0 ) {
const inputTokens = usage.input + usage.cacheRead;
if (inputTokens > 0 ) {
contextPct = `~${Math.min((totalContextTokens / inputTokens) * 100 , 100 ).toFixed(0 )}% ${t("usage.details.ofInput" )}`;
}
}
const skillsList = contextWeight.skills.entries.toSorted((a, b) => b.blockChars - a.blockChars);
const toolsList = contextWeight.tools.entries.toSorted(
(a, b) => b.summaryChars + b.schemaChars - (a.summaryChars + a.schemaChars),
);
const filesList = contextWeight.injectedWorkspaceFiles.toSorted(
(a, b) => b.injectedChars - a.injectedChars,
);
const defaultLimit = 4 ;
const showAll = expanded;
const skillsTop = showAll ? skillsList : skillsList.slice(0 , defaultLimit);
const toolsTop = showAll ? toolsList : toolsList.slice(0 , defaultLimit);
const filesTop = showAll ? filesList : filesList.slice(0 , defaultLimit);
const hasMore =
skillsList.length > defaultLimit ||
toolsList.length > defaultLimit ||
filesList.length > defaultLimit;
return html`
<div class ="context-details-panel" >
<div class ="context-breakdown-header" >
<div class ="card-title usage-section-title" >
${t("usage.details.systemPromptBreakdown" )}
</div>
${hasMore
? html`<button class ="btn btn--sm" @click=${onToggleExpanded}>
${showAll ? t("usage.details.collapse" ) : t("usage.details.expandAll" )}
</button>`
: nothing}
</div>
<p class ="context-weight-desc" >${contextPct || t("usage.details.baseContextPerMessage" )}</p>
<div class ="context-stacked-bar" >
<div
class ="context-segment system"
style="width: ${pct(systemTokens, totalContextTokens).toFixed(1)}%"
title="${t(" usage.details.system")}: ~${formatTokens(systemTokens)}"
></div>
<div
class ="context-segment skills"
style="width: ${pct(skillsTokens, totalContextTokens).toFixed(1)}%"
title="${t(" usage.details.skills")}: ~${formatTokens(skillsTokens)}"
></div>
<div
class ="context-segment tools"
style="width: ${pct(toolsTokens, totalContextTokens).toFixed(1)}%"
title="${t(" usage.details.tools")}: ~${formatTokens(toolsTokens)}"
></div>
<div
class ="context-segment files"
style="width: ${pct(filesTokens, totalContextTokens).toFixed(1)}%"
title="${t(" usage.details.files")}: ~${formatTokens(filesTokens)}"
></div>
</div>
<div class ="context-legend" >
<span class ="legend-item"
><span class ="legend-dot system" ></span>${t("usage.details.systemShort" )}
~${formatTokens(systemTokens)}</span
>
<span class ="legend-item"
><span class ="legend-dot skills" ></span>${t("usage.details.skills" )}
~${formatTokens(skillsTokens)}</span
>
<span class ="legend-item"
><span class ="legend-dot tools" ></span>${t("usage.details.tools" )}
~${formatTokens(toolsTokens)}</span
>
<span class ="legend-item"
><span class ="legend-dot files" ></span>${t("usage.details.files" )}
~${formatTokens(filesTokens)}</span
>
</div>
<div class ="context-total" >
${t("usage.breakdown.total" )}: ~${formatTokens(totalContextTokens)}
</div>
<div class ="context-breakdown-grid" >
${skillsList.length > 0
? (() => {
const more = skillsList.length - skillsTop.length;
return html`
<div class ="context-breakdown-card" >
<div class ="context-breakdown-title" >
${t("usage.details.skills" )} (${skillsList.length})
</div>
<div class ="context-breakdown-list" >
${skillsTop.map(
(s) => html`
<div class ="context-breakdown-item" >
<span class ="mono" >${s.name}</span>
<span class ="muted" >~${formatTokens(charsToTokens(s.blockChars))}</span>
</div>
`,
)}
</div>
${more > 0
? html`
<div class ="context-breakdown-more" >
${t("usage.sessions.more" , { count: String(more) })}
</div>
`
: nothing}
</div>
`;
})()
: nothing}
${toolsList.length > 0
? (() => {
const more = toolsList.length - toolsTop.length;
return html`
<div class ="context-breakdown-card" >
<div class ="context-breakdown-title" >
${t("usage.details.tools" )} (${toolsList.length})
</div>
<div class ="context-breakdown-list" >
${toolsTop.map(
(t) => html`
<div class ="context-breakdown-item" >
<span class ="mono" >${t.name}</span>
<span class ="muted"
>~${formatTokens(charsToTokens(t.summaryChars + t.schemaChars))}</span
>
</div>
`,
)}
</div>
${more > 0
? html`
<div class ="context-breakdown-more" >
${t("usage.sessions.more" , { count: String(more) })}
</div>
`
: nothing}
</div>
`;
})()
: nothing}
${filesList.length > 0
? (() => {
const more = filesList.length - filesTop.length;
return html`
<div class ="context-breakdown-card" >
<div class ="context-breakdown-title" >
${t("usage.details.files" )} (${filesList.length})
</div>
<div class ="context-breakdown-list" >
${filesTop.map(
(f) => html`
<div class ="context-breakdown-item" >
<span class ="mono" >${f.name}</span>
<span class ="muted"
>~${formatTokens(charsToTokens(f.injectedChars))}</span
>
</div>
`,
)}
</div>
${more > 0
? html`
<div class ="context-breakdown-more" >
${t("usage.sessions.more" , { count: String(more) })}
</div>
`
: nothing}
</div>
`;
})()
: nothing}
</div>
</div>
`;
}
function renderSessionLogsCompact(
logs: SessionLogEntry[] | null ,
loading: boolean ,
expandedAll: boolean ,
onToggleExpandedAll: () => void ,
filters: {
roles: SessionLogRole[];
tools: string[];
hasTools: boolean ;
query: string;
},
onFilterRolesChange: (next: SessionLogRole[]) => void ,
onFilterToolsChange: (next: string[]) => void ,
onFilterHasToolsChange: (next: boolean ) => void ,
onFilterQueryChange: (next: string) => void ,
onFilterClear: () => void ,
cursorStart?: number | null ,
cursorEnd?: number | null ,
) {
if (loading) {
return html`
<div class ="session-logs-compact" >
<div class ="session-logs-header" >${t("usage.details.conversation" )}</div>
<div class ="usage-empty-block" >${t("usage.loading.badge" )}</div>
</div>
`;
}
if (!logs || logs.length === 0 ) {
return html`
<div class ="session-logs-compact" >
<div class ="session-logs-header" >${t("usage.details.conversation" )}</div>
<div class ="usage-empty-block" >${t("usage.details.noMessages" )}</div>
</div>
`;
}
const normalizedQuery = normalizeLowercaseStringOrEmpty(filters.query);
const entries = logs.map((log) => {
const toolInfo = parseToolSummary(log.content);
const cleanContent = toolInfo.cleanContent || log.content;
return { log, toolInfo, cleanContent };
});
const toolOptions = Array.from(
new Set(entries.flatMap((entry) => entry.toolInfo.tools.map(([name]) => name))),
).toSorted((a, b) => a.localeCompare(b));
const filteredEntries = entries.filter((entry) => {
// Filter by cursor timeline range (only if logs cover the range)
if (cursorStart != null && cursorEnd != null ) {
const ts = entry.log.timestamp;
if (ts > 0 ) {
const lo = Math.min(cursorStart, cursorEnd);
const hi = Math.max(cursorStart, cursorEnd);
const normalizedTs = normalizeLogTimestamp(ts);
if (normalizedTs < lo || normalizedTs > hi) {
return false ;
}
}
}
if (filters.roles.length > 0 && !filters.roles.includes(entry.log.role)) {
return false ;
}
if (filters.hasTools && entry.toolInfo.tools.length === 0 ) {
return false ;
}
if (filters.tools.length > 0 ) {
const matchesTool = entry.toolInfo.tools.some(([name]) => filters.tools.includes(name));
if (!matchesTool) {
return false ;
}
}
if (normalizedQuery) {
const haystack = normalizeLowercaseStringOrEmpty(entry.cleanContent);
if (!haystack.includes(normalizedQuery)) {
return false ;
}
}
return true ;
});
const hasActiveFilters =
filters.roles.length > 0 || filters.tools.length > 0 || filters.hasTools || normalizedQuery;
const hasCursorFilter = cursorStart != null && cursorEnd != null ;
const displayedCount =
hasActiveFilters || hasCursorFilter
? `${filteredEntries.length} ${t("usage.details.of" )} ${logs.length}${hasCursorFilter ? ` (${t("usage.details.timelineFiltered" )})` : "" }`
: `${logs.length}`;
const roleSelected = new Set(filters.roles);
const toolSelected = new Set(filters.tools);
return html`
<div class ="session-logs-compact" >
<div class ="session-logs-header" >
<span>
${t("usage.details.conversation" )}
<span class ="session-logs-header-count" >
(${displayedCount} ${normalizeLowercaseStringOrEmpty(t("usage.overview.messages" ))})
</span>
</span>
<button class ="btn btn--sm" @click=${onToggleExpandedAll}>
${expandedAll ? t("usage.details.collapseAll" ) : t("usage.details.expandAll" )}
</button>
</div>
<div class ="usage-filters-inline session-log-filters" >
<select
multiple
size="4"
aria-label="Filter by role"
@change=${(event: Event) =>
onFilterRolesChange(
Array.from((event.target as HTMLSelectElement).selectedOptions).map(
(option) => option.value as SessionLogRole,
),
)}
>
<option value="user" ?selected=${roleSelected.has("user" )}>
${t("usage.overview.user" )}
</option>
<option value="assistant" ?selected=${roleSelected.has("assistant" )}>
${t("usage.overview.assistant" )}
</option>
<option value="tool" ?selected=${roleSelected.has("tool" )}>
${t("usage.details.tool" )}
</option>
<option value="toolResult" ?selected=${roleSelected.has("toolResult" )}>
${t("usage.details.toolResult" )}
</option>
</select>
<select
multiple
size="4"
aria-label="Filter by tool"
@change=${(event: Event) =>
onFilterToolsChange(
Array.from((event.target as HTMLSelectElement).selectedOptions).map(
(option) => option.value,
),
)}
>
${toolOptions.map(
(tool) =>
html`<option value=${tool} ?selected=${toolSelected.has(tool)}>${tool}</option>`,
)}
</select>
<label class ="usage-filters-inline session-log-has-tools" >
<input
type="checkbox"
.checked=${filters.hasTools}
@change=${(event: Event) =>
onFilterHasToolsChange((event.target as HTMLInputElement).checked)}
/>
${t("usage.details.hasTools" )}
</label>
<input
type="text"
placeholder=${t("usage.details.searchConversation" )}
aria-label=${t("usage.details.searchConversation" )}
.value=${filters.query}
@input=${(event: Event) => onFilterQueryChange((event.target as HTMLInputElement).value)}
/>
<button class ="btn btn--sm" @click=${onFilterClear}>${t("usage.filters.clear" )}</button>
</div>
<div class ="session-logs-list" >
${filteredEntries.map((entry) => {
const { log, toolInfo, cleanContent } = entry;
const roleClass = log.role === "user" ? "user" : "assistant" ;
const roleLabel =
log.role === "user"
? t("usage.details.you" )
: log.role === "assistant"
? t("usage.overview.assistant" )
: t("usage.details.tool" );
return html`
<div class ="session-log-entry ${roleClass}" >
<div class ="session-log-meta" >
<span class ="session-log-role" >${roleLabel}</span>
<span>${new Date(log.timestamp).toLocaleString()}</span>
${log.tokens ? html`<span>${formatTokens(log.tokens)}</span>` : nothing}
</div>
<div class ="session-log-content" >${cleanContent}</div>
${toolInfo.tools.length > 0
? html`
<details class ="session-log-tools" ?open=${expandedAll}>
<summary>${toolInfo.summary}</summary>
<div class ="session-log-tools-list" >
${toolInfo.tools.map(
([name, count]) => html`
<span class ="session-log-tools-pill" >${name} × ${count}</span>
`,
)}
</div>
</details>
`
: nothing}
</div>
`;
})}
${filteredEntries.length === 0
? html`
<div class ="usage-empty-block usage-empty-block--compact" >
${t("usage.details.noMessagesMatch" )}
</div>
`
: nothing}
</div>
</div>
`;
}
export {
computeFilteredUsage,
renderContextPanel,
renderSessionDetailPanel,
renderSessionLogsCompact,
renderSessionSummary,
renderTimeSeriesCompact,
CHART_BAR_WIDTH_RATIO,
CHART_MAX_BAR_WIDTH,
};
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.20 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland