@@ -10,6 +10,14 @@ import {
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 ;
@@ -21,8 +29,34 @@ function renderEmptyDetailState() {
return nothing ;
}
function renderSessionSummary ( session : UsageSessionEntry ) {
const usage = session . usage ;
/** 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="muted">No usage data for this session.</div>
@@ -45,12 +79,37 @@ function renderSessionSummary(session: UsageSessionEntry) {
badges . push ( ` model: ${ session . model } ` ) ;
}
const toolItems =
usage . toolUsage ? . tools . slice ( 0 , 6 ) . map ( ( tool ) = > ( {
// 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 : "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 : "calls" ,
} ) ) ? ? [ ] ;
} ) ) ;
toolCallCount = usage . toolUsage ? . totalCalls ? ? 0 ;
uniqueToolCount = usage . toolUsage ? . uniqueTools ? ? 0 ;
}
const modelItems =
usage . modelUsage ? . slice ( 0 , 6 ) . map ( ( entry ) = > ( {
label : entry.model ? ? "unknown" ,
@@ -68,8 +127,8 @@ function renderSessionSummary(session: UsageSessionEntry) {
</div>
<div class="session-summary-card">
<div class="session-summary-title">Tool Calls</div>
<div class="session-summary-value"> ${ usage . toolUsage ? . totalCalls ? ? 0 } </div>
<div class="session-summary-meta"> ${ usage . toolUsage ? . uniqueTools ? ? 0 } tools</div>
<div class="session-summary-value"> ${ toolCallCount } </div>
<div class="session-summary-meta"> ${ uniqueToolCount } tools</div>
</div>
<div class="session-summary-card">
<div class="session-summary-title">Errors</div>
@@ -89,6 +148,66 @@ function renderSessionSummary(session: UsageSessionEntry) {
` ;
}
/** 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 ,
@@ -97,6 +216,9 @@ function renderSessionDetailPanel(
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 [ ] ,
@@ -123,18 +245,31 @@ function renderSessionDetailPanel(
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 ? " (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 } </div>
<div class="session-detail-title">
${ displayLabel }
${ cursorIndicator ? html ` <span style="font-size: 11px; color: var(--muted); margin-left: 8px;"> ${ cursorIndicator } </span> ` : nothing }
</div>
</div>
<div class="session-detail-stats">
${
usage
? html `
<span><strong> ${ formatTokens ( usage . totalTokens ) } </strong> tokens</span>
<span><strong> ${ formatCost ( usage . totalCost ) } </strong></span>
<span><strong> ${ formatTokens ( headerStats . totalTokens ) } </strong> tokens${ cursorIndicator } </span>
<span><strong> ${ formatCost ( headerStats . totalCost ) } </strong>${ cursorIndicator } </span>
`
: nothing
}
@@ -142,7 +277,13 @@ function renderSessionDetailPanel(
<button class="session-close-btn" @click= ${ onClose } title="Close session details">× </button>
</div>
<div class="session-detail-content">
${ renderSessionSummary ( session ) }
${ renderSessionSummary (
session ,
filteredUsage ,
timeSeriesCursorStart != null && timeSeriesCursorEnd != null && sessionLogs
? filterLogsByRange ( sessionLogs , timeSeriesCursorStart , timeSeriesCursorEnd )
: undefined ,
) }
<div class="session-detail-row">
${ renderTimeSeriesCompact (
timeSeries ,
@@ -154,6 +295,9 @@ function renderSessionDetailPanel(
startDate ,
endDate ,
selectedDays ,
timeSeriesCursorStart ,
timeSeriesCursorEnd ,
onTimeSeriesCursorRangeChange ,
) }
</div>
<div class="session-detail-bottom">
@@ -168,6 +312,8 @@ function renderSessionDetailPanel(
onLogFilterHasToolsChange ,
onLogFilterQueryChange ,
onLogFilterClear ,
hasRange ? timeSeriesCursorStart : null ,
hasRange ? timeSeriesCursorEnd : null ,
) }
${ renderContextPanel ( session . contextWeight , usage , contextExpanded , onToggleContextExpanded ) }
</div>
@@ -186,6 +332,9 @@ function renderTimeSeriesCompact(
startDate? : string ,
endDate? : string ,
selectedDays? : string [ ] ,
cursorStart? : number | null ,
cursorEnd? : number | null ,
onCursorRangeChange ? : ( start : number | null , end : number | null ) = > void ,
) {
if ( loading ) {
return html `
@@ -242,14 +391,44 @@ function renderTimeSeriesCompact(
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 = 8 0;
const padding = { top : 16 , right : 10 , bottom : 20 , left : 4 0 } ;
height = 10 0;
const padding = { top : 8 , right : 4 , bottom : 14 , left : 3 0 } ;
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 = sumOutput + sumInput + sumCacheRead + sumCacheWrite ;
const totalTypeTokens = filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite ;
const barTotals = points . map ( ( p ) = >
isCumulative
? p.cumulativeTokens
@@ -258,14 +437,32 @@ function renderTimeSeriesCompact(
: p.totalTokens ,
) ;
const maxValue = Math . max ( . . . barTotals , 1 ) ;
const barWidth = Math . max ( 2 , Math . min ( 8 , ( chartWidth / points . length ) * 0.7 ) ) ;
const barGap = Math . max ( 1 , ( char tWidth - b arWidth * points . length ) / ( points . length - 1 || 1 ) ) ;
// Ensure bars + gaps fit exactly within chartWidth
const slo tWidth = ch art Width / 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" style="font-size: 13 px;">Usage Over Time</div>
<div class="card-title" style="font-size: 12 px; color: var(--text); ">Usage Over Time</div>
<div class="timeseries-controls">
${
hasSelection
? html `
<div class="chart-toggle small">
<button class="toggle-btn active" @click= ${ ( ) = > onCursorRangeChange ? . ( null , null ) } >Reset</button>
</div>
`
: nothing
}
<div class="chart-toggle small">
<button
class="toggle-btn ${ ! isCumulative ? "active" : "" } "
@@ -302,92 +499,211 @@ function renderTimeSeriesCompact(
}
</div>
</div>
<svg viewBox="0 0 ${ width } ${ height + 15 } " class="timeseries-svg" style="width: 100%; height: auto ;">
<!-- 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 + 4 } " text-anchor="end" class="axis-label" style="font-size: 9px; fill: var(--text-muted)"> ${ formatTokens ( maxValue ) } </text >
<text x=" ${ padding . left - 4 } " y=" ${ padding . top + chartHeight } " text-anchor="end" class="axis-label" style="font-size: 9px; fill: var(--text-muted)">0</text >
<!-- X axis labels (first and last) -->
${
points . length > 0
? svg `
<text x=" ${ padding . left } " y=" ${ padding . top + chartHeight + 12 } " text-anchor="start" style="font-size: 8px; fill: var(--text-muted)"> ${ new Date ( points [ 0 ] . timestamp ) . toLocaleDateString ( undefined , { month : "short" , day : "numeric" } )} </text>
<text x=" ${ width - padding . right } " y=" ${ padding . top + chartHeight + 12 } " text-anchor="end" style="font-size: 8px; fill: var(--text-muted)"> ${ new Date ( points [ points . length - 1 ] . timestamp ) . toLocaleDateString ( undefined , { month : "short" , day : "numeric" } )}</text >
`
: nothing
}
<!-- Bars -- >
${ points . map ( ( p , i ) = > {
const val = barTotals [ i ] ;
const x = padding . left + i * ( barWidth + barGap ) ;
const barHeight = ( val / maxValue ) * chartHeight ;
const y = padding . top + chartHeight - barHeight ;
const date = new Date ( p . timestamp ) ;
const tooltipLines = [
date . toLocaleDateString ( undefined , {
month : "short" ,
day : "numeric" ,
hour : "2-digit" ,
minute : "2-digit" ,
} ),
` $ { formatTokens ( val ) } tokens ` ,
];
if (breakdownByType) {
tooltipLines.push( ` Output $ { formatTokens ( p . output ) } ` );
tooltipLines.push( ` Input $ { formatTokens ( p . input ) } ` );
tooltipLines.push( ` Cache write $ { formatTokens ( p . cacheWrite ) } ` );
tooltipLines.push( ` Cache read $ { formatTokens ( p . cacheRead ) } ` );
<div class="timeseries-chart-wrapper" style="position: relative; cursor: crosshair ;">
<svg
viewBox="0 0 ${ width } ${ height + 18 } "
class="timeseries-svg"
style="width: 100%; height: auto; display: block;"
>
<!-- 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
}
const tooltip = tooltipLines.join(" · ");
if (!breakdownByType) {
return svg ` < rect x = "${x}" y = "${y}" width = "${barWidth}" height = "${barHeight}" class = "ts-bar" rx = "1" style = "cursor: pointer;" > < title > $ { tooltip } < / title > < / rect > ` ;
}
const segments = [
{ value: p.output, class: "output" },
{ value: p.input, class: "input" },
{ value: p.cacheWrite, class: "cache-write" },
{ value: p.cacheRead, class: "cache-read" },
];
let yCursor = padding.top + chartHeight;
return svg `
$ { segments . map ( ( seg ) = > {
if ( seg . value <= 0 || val <= 0 ) {
return 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 ) } 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;
}
const segHeight = barHeight * ( seg . value / val ) ;
yCursor -= segHeight ;
return svg ` <rect x=" ${ x } " y=" ${ yCursor } " width=" ${ barWidth } " height=" ${ segHeight } " class="ts-bar ${ seg . class } " rx="1"><title> ${ tooltip } </title></rect> ` ;
} ) }
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 >
` ;
})}
</svg >
<div class="timeseries-summary">${ points . length } msgs · ${ formatTokens ( cumTokens ) } · ${ formatCost ( cumCost ) } </div>
})() }
</div >
<div class="timeseries-summary">
${
hasSelection
? html `
<span style="color: var(--accent);">▶ Turns ${ rangeStartIdx + 1 } – ${ rangeEndIdx } of ${ 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 } msgs · ${ formatTokens ( cumTokens ) } · ${ formatCost ( cumCost ) } `
}
</div>
${
breakdownByType
? html `
<div style="margin-top: 8px;">
<div class="card-title" style="font-size: 12px; margin-bottom: 6px;">Tokens by Type</div>
<div class="card-title" style="font-size: 12px; margin-bottom: 6px; color: var(--text); ">Tokens by Type</div>
<div class="cost-breakdown-bar" style="height: 18px;">
<div class="cost-segment output" style="width: ${ pct ( sum Output, totalTypeTokens ) . toFixed ( 1 ) } %"></div>
<div class="cost-segment input" style="width: ${ pct ( sum Input, totalTypeTokens ) . toFixed ( 1 ) } %"></div>
<div class="cost-segment cache-write" style="width: ${ pct ( sum CacheWrite, totalTypeTokens ) . toFixed ( 1 ) } %"></div>
<div class="cost-segment cache-read" style="width: ${ pct ( sum CacheRead, totalTypeTokens ) . toFixed ( 1 ) } %"></div>
<div class="cost-segment output" style="width: ${ pct ( filtered Output, totalTypeTokens ) . toFixed ( 1 ) } %"></div>
<div class="cost-segment input" style="width: ${ pct ( filtered Input, totalTypeTokens ) . toFixed ( 1 ) } %"></div>
<div class="cost-segment cache-write" style="width: ${ pct ( filtered CacheWrite, totalTypeTokens ) . toFixed ( 1 ) } %"></div>
<div class="cost-segment cache-read" style="width: ${ pct ( filtered CacheRead, totalTypeTokens ) . toFixed ( 1 ) } %"></div>
</div>
<div class="cost-breakdown-legend">
<div class="legend-item" title="Assistant output tokens">
<span class="legend-dot output"></span>Output ${ formatTokens ( sum Output) }
<span class="legend-dot output"></span>Output ${ formatTokens ( filtered Output) }
</div>
<div class="legend-item" title="User + tool input tokens">
<span class="legend-dot input"></span>Input ${ formatTokens ( sum Input) }
<span class="legend-dot input"></span>Input ${ formatTokens ( filtered Input) }
</div>
<div class="legend-item" title="Tokens written to cache">
<span class="legend-dot cache-write"></span>Cache Write ${ formatTokens ( sum CacheWrite) }
<span class="legend-dot cache-write"></span>Cache Write ${ formatTokens ( filtered CacheWrite) }
</div>
<div class="legend-item" title="Tokens read from cache">
<span class="legend-dot cache-read"></span>Cache Read ${ formatTokens ( sum CacheRead) }
<span class="legend-dot cache-read"></span>Cache Read ${ formatTokens ( filtered CacheRead) }
</div>
</div>
<div class="cost-breakdown-total">Total: ${ formatTokens ( totalTypeTokens ) } </div>
@@ -404,6 +720,7 @@ function renderContextPanel(
usage : UsageSessionEntry [ "usage" ] ,
expanded : boolean ,
onToggleExpanded : ( ) = > void ,
timeSeriesCursor? : number | null ,
) {
if ( ! contextWeight ) {
return html `
@@ -450,7 +767,7 @@ function renderContextPanel(
return html `
<div class="context-details-panel">
<div class="context-breakdown-header">
<div class="card-title" style="font-size: 13 px;">System Prompt Breakdown</div>
<div class="card-title" style="font-size: 12 px; color: var(--text); ">System Prompt Breakdown</div>
${
hasMore
? html ` <button class="context-expand-btn" @click= ${ onToggleExpanded } >
@@ -459,7 +776,13 @@ function renderContextPanel(
: nothing
}
</div>
<p class="context-weight-desc">${ contextPct || "Base context per message" } </p>
<p class="context-weight-desc">
${
timeSeriesCursor !== null && timeSeriesCursor !== undefined
? "Current state (not filtered by timeline cursor)"
: contextPct || "Base context per message"
}
</p>
<div class="context-stacked-bar">
<div class="context-segment system" style="width: ${ pct ( systemTokens , totalContextTokens ) . toFixed ( 1 ) } %" title="System: ~ ${ formatTokens ( systemTokens ) } "></div>
<div class="context-segment skills" style="width: ${ pct ( skillsTokens , totalContextTokens ) . toFixed ( 1 ) } %" title="Skills: ~ ${ formatTokens ( skillsTokens ) } "></div>
@@ -576,6 +899,8 @@ function renderSessionLogsCompact(
onFilterHasToolsChange : ( next : boolean ) = > void ,
onFilterQueryChange : ( next : string ) = > void ,
onFilterClear : ( ) = > void ,
cursorStart? : number | null ,
cursorEnd? : number | null ,
) {
if ( loading ) {
return html `
@@ -604,6 +929,18 @@ function renderSessionLogsCompact(
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 ;
}
@@ -624,9 +961,12 @@ function renderSessionLogsCompact(
}
return true ;
} ) ;
const hasActiveFilters =
filters . roles . length > 0 || filters . tools . length > 0 || filters . hasTools || normalizedQuery ;
const hasCursorFilter = cursorStart != null && cursorEnd != null ;
const displayedCount =
filters . roles . length > 0 || filters . tools . length > 0 || filters . hasTools || normalizedQu ery
? ` ${ filteredEntries . length } of ${ logs . length } `
hasActiveFilters || hasCursorFilt er
? ` ${ filteredEntries . length } of ${ logs . length } ${ hasCursorFilter ? "(timeline filtered)" : "" } `
: ` ${ logs . length } ` ;
const roleSelected = new Set ( filters . roles ) ;
@@ -635,7 +975,7 @@ function renderSessionLogsCompact(
return html `
<div class="session-logs-compact">
<div class="session-logs-header">
<span>Conversation <span style="font-weight: normal; color: var(--text- muted);">( ${ displayedCount } messages)</span></span>
<span>Conversation <span style="font-weight: normal; color: var(--muted);">( ${ displayedCount } messages)</span></span>
<button class="btn btn-sm usage-action-btn usage-secondary-btn" @click= ${ onToggleExpandedAll } >
${ expandedAll ? "Collapse All" : "Expand All" }
</button>
@@ -736,10 +1076,13 @@ function renderSessionLogsCompact(
}
export {
computeFilteredUsage ,
renderContextPanel ,
renderEmptyDetailState ,
renderSessionDetailPanel ,
renderSessionLogsCompact ,
renderSessionSummary ,
renderTimeSeriesCompact ,
CHART_BAR_WIDTH_RATIO ,
CHART_MAX_BAR_WIDTH ,
} ;