Files
openchamber/packages/ui/src/components/layout/ContextSidebarTab.tsx
T
Yun Feng 36ce73f753 feat(ui): show session cost in VS Code context usage readout (#2991)
Share the desktop session-card currency formatter (lib/money.ts) and surface
the current session's cost in the extension chat-header context usage tooltip.
2026-08-19 00:34:23 +03:00

650 lines
26 KiB
TypeScript

import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { deriveMessageRole } from '@/components/chat/message/messageRole';
import { Icon } from "@/components/icon/Icon";
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
import { copyTextToClipboard } from '@/lib/clipboard';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { formatMoney } from '@/lib/money';
import {
derivePartsLabel,
deriveUserSnippet,
formatAssistantTokens,
formatMessagePreviewTime,
} from './rawMessagePreview';
import type { TimeFormatPreference } from '@/stores/useUIStore';
import { formatDateTimeForPreference } from '@/lib/timeFormat';
type SessionMessage = { info: Message; parts: Part[] };
type ProviderModelLike = {
id?: string;
name?: string;
limit?: { context?: number };
};
type ProviderLike = {
id?: string;
name?: string;
models?: ProviderModelLike[];
};
type TokenBreakdown = {
input: number;
output: number;
reasoning: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
type ContextBuckets = {
user: number;
assistant: number;
tool: number;
other: number;
};
const EMPTY_BREAKDOWN: TokenBreakdown = {
input: 0,
output: 0,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
};
const EMPTY_BUCKETS: ContextBuckets = {
user: 0,
assistant: 0,
tool: 0,
other: 0,
};
const toNonNegativeNumber = (value: unknown): number => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return 0;
}
return value;
};
const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
const tokenCandidate = (message.info as { tokens?: unknown }).tokens;
const source =
tokenCandidate !== undefined
? tokenCandidate
: (message.parts.find((part) => (part as { tokens?: unknown }).tokens !== undefined) as { tokens?: unknown } | undefined)?.tokens;
if (typeof source === 'number') {
return {
...EMPTY_BREAKDOWN,
total: toNonNegativeNumber(source),
};
}
if (!source || typeof source !== 'object') {
return EMPTY_BREAKDOWN;
}
const breakdown = source as {
total?: unknown;
input?: unknown;
output?: unknown;
reasoning?: unknown;
cache?: { read?: unknown; write?: unknown };
};
const input = toNonNegativeNumber(breakdown.input);
const output = toNonNegativeNumber(breakdown.output);
const reasoning = toNonNegativeNumber(breakdown.reasoning);
const cacheRead = toNonNegativeNumber(breakdown.cache?.read);
const cacheWrite = toNonNegativeNumber(breakdown.cache?.write);
// Multi-step turns accumulate the fields across API round-trips (every tool
// call re-reads the whole cached prompt), so summing them overstates the
// window. The server-reported total is the final round-trip's window.
const reportedTotal = toNonNegativeNumber(breakdown.total);
return {
input,
output,
reasoning,
cacheRead,
cacheWrite,
total: reportedTotal > 0 ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite,
};
};
const pickString = (...values: unknown[]): string => {
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return '';
};
const estimateTextLength = (value: unknown): number => {
if (typeof value === 'string') {
return value.length;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).length;
}
if (Array.isArray(value)) {
return value.reduce((sum, item) => sum + estimateTextLength(item), 0);
}
if (value && typeof value === 'object') {
return Object.values(value as Record<string, unknown>).reduce<number>((sum, item) => sum + estimateTextLength(item), 0);
}
return 0;
};
const estimatePartChars = (part: Part, role: 'user' | 'assistant' | 'tool' | 'other'): ContextBuckets => {
const partRecord = part as Record<string, unknown>;
const type = typeof partRecord.type === 'string' ? partRecord.type : '';
if (type === 'reasoning') {
return {
...EMPTY_BUCKETS,
assistant: estimateTextLength(partRecord.text) + estimateTextLength(partRecord.content),
};
}
const directText = pickString(
partRecord.text,
partRecord.content,
partRecord.value,
(partRecord.source as { value?: unknown; text?: { value?: unknown } } | undefined)?.value,
(partRecord.source as { value?: unknown; text?: { value?: unknown } } | undefined)?.text?.value,
);
if (type === 'tool' || role === 'tool') {
const toolInputOutputLength =
estimateTextLength(partRecord.input)
+ estimateTextLength(partRecord.output)
+ estimateTextLength(partRecord.error)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.input)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.output)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.error);
const toolPayloadLength =
toolInputOutputLength
+ estimateTextLength(partRecord.raw)
+ Math.round(estimateTextLength(partRecord.metadata) * 0.25)
+ Math.round(estimateTextLength(partRecord.state) * 0.1);
return { user: 0, assistant: 0, tool: toolPayloadLength, other: 0 };
}
if (role === 'user') {
return { user: directText.length, assistant: 0, tool: 0, other: 0 };
}
if (role === 'assistant') {
return { user: 0, assistant: directText.length, tool: 0, other: 0 };
}
return { user: 0, assistant: 0, tool: 0, other: directText.length };
};
const addBuckets = (target: ContextBuckets, value: ContextBuckets): ContextBuckets => ({
user: target.user + value.user,
assistant: target.assistant + value.assistant,
tool: target.tool + value.tool,
other: target.other + value.other,
});
const deriveRoleBucket = (message: SessionMessage): 'user' | 'assistant' | 'tool' | 'other' => {
const roleInfo = deriveMessageRole(message.info);
if (roleInfo.isUser) return 'user';
if (roleInfo.role === 'assistant') return 'assistant';
if (roleInfo.role === 'tool') return 'tool';
return 'other';
};
const computeContextBreakdown = (
sessionMessages: SessionMessage[],
systemPrompt: string,
): ContextBuckets => {
if (sessionMessages.length === 0) {
return { ...EMPTY_BUCKETS };
}
const totalChars = sessionMessages.reduce<ContextBuckets>((acc, message) => {
const role = deriveRoleBucket(message);
let bucket = { ...EMPTY_BUCKETS };
for (const part of message.parts) {
bucket = addBuckets(bucket, estimatePartChars(part, role));
}
return addBuckets(acc, bucket);
}, { ...EMPTY_BUCKETS });
totalChars.user += systemPrompt.length;
return {
user: Math.ceil(totalChars.user / 4),
assistant: Math.ceil(totalChars.assistant / 4),
tool: Math.ceil(totalChars.tool / 4),
other: Math.ceil(totalChars.other / 4),
};
};
const formatNumber = (value: number): string => value.toLocaleString(getCurrentIntlLocale());
const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
const resolveProviderAndModel = (
providers: ProviderLike[],
providerID: string,
modelID: string,
): { providerName: string; modelName: string; contextLimit: number | null } => {
const provider = providers.find((entry) => entry.id === providerID);
const model = provider?.models?.find((entry) => entry.id === modelID);
return {
providerName: provider?.name || providerID || '-',
modelName: model?.name || modelID || '-',
contextLimit: typeof model?.limit?.context === 'number' ? model.limit.context : null,
};
};
export const ContextPanelContent: React.FC = () => {
const { t } = useI18n();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
const copyResetTimeoutRef = React.useRef<number | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const sessions = useSessions(currentSessionDirectory ?? undefined);
const sessionMessages = useSessionMessageRecords(
currentSessionId ?? '',
currentSessionDirectory ?? undefined,
);
const providers = useConfigStore((state) => state.providers);
React.useEffect(() => {
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
copyResetTimeoutRef.current = null;
}
setExpandedRawMessages((prev) => (Object.keys(prev).length > 0 ? {} : prev));
setCopiedRawMessageId(null);
}, [currentSessionDirectory, currentSessionId]);
React.useEffect(() => {
return () => {
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
copyResetTimeoutRef.current = null;
}
};
}, []);
const handleCopyRawMessage = React.useCallback(async (messageId: string, value: string) => {
const result = await copyTextToClipboard(value);
if (result.ok) {
setCopiedRawMessageId(messageId);
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
}
copyResetTimeoutRef.current = window.setTimeout(() => {
setCopiedRawMessageId((prev) => (prev === messageId ? null : prev));
copyResetTimeoutRef.current = null;
}, 2000);
} else {
setCopiedRawMessageId(null);
}
}, []);
const viewModel = React.useMemo(() => {
const currentSession = currentSessionId ? sessions.find((session) => session.id === currentSessionId) ?? null : null;
const assistantMessages = sessionMessages.filter((entry) => deriveMessageRole(entry.info).role === 'assistant');
const userMessages = sessionMessages.filter((entry) => deriveMessageRole(entry.info).isUser);
let contextMessage: SessionMessage | null = null;
for (let i = assistantMessages.length - 1; i >= 0; i -= 1) {
const message = assistantMessages[i];
if (extractTokenBreakdown(message).total > 0) {
contextMessage = message;
break;
}
}
const tokenBreakdown = contextMessage ? extractTokenBreakdown(contextMessage) : EMPTY_BREAKDOWN;
// Cache hit rate for the last assistant message. `input` is the non-cached portion
// (total input - cache.read - cache.write per SDK's session.ts:getUsage),
// so hit rate = cache.read / (input + cache.read + cache.write).
const cacheHitRate = computeCacheHitRate({
input: tokenBreakdown.input,
cache: { read: tokenBreakdown.cacheRead, write: tokenBreakdown.cacheWrite },
});
const totalAssistantCost = assistantMessages.reduce((sum, message) => {
const cost = toNonNegativeNumber((message.info as { cost?: unknown }).cost);
return sum + cost;
}, 0);
const latestAssistantInfo = (contextMessage?.info ?? null) as (Message & { providerID?: string; modelID?: string }) | null;
const providerModel = resolveProviderAndModel(
providers as ProviderLike[],
latestAssistantInfo?.providerID || '',
latestAssistantInfo?.modelID || '',
);
const contextLimit = providerModel.contextLimit;
const usagePercent = contextLimit && contextLimit > 0
? Math.min(999, (tokenBreakdown.total / contextLimit) * 100)
: 0;
const systemPrompt = ([...sessionMessages].reverse().find(
(entry) => deriveMessageRole(entry.info).isUser && typeof (entry.info as { system?: unknown }).system === 'string',
)?.info as { system?: string } | undefined)?.system || '';
const computedBreakdown = computeContextBreakdown(sessionMessages, systemPrompt);
const userTokens = computedBreakdown.user;
const assistantTokens = computedBreakdown.assistant;
const toolTokens = computedBreakdown.tool;
const otherTokens = Math.max(0, tokenBreakdown.input - userTokens - assistantTokens - toolTokens);
const breakdownTotal = userTokens + assistantTokens + toolTokens + otherTokens;
const firstMessageTs = sessionMessages[0]?.info?.time?.created;
const lastMessageTs = sessionMessages.length > 0
? sessionMessages[sessionMessages.length - 1]?.info?.time?.created
: null;
return {
sessionTitle: currentSession?.title || t('contextSidebar.session.untitled'),
messagesCount: sessionMessages.length,
userMessagesCount: userMessages.length,
assistantMessagesCount: assistantMessages.length,
createdAt: (currentSession?.time?.created ?? firstMessageTs ?? null) as number | null,
lastActivityAt: (lastMessageTs ?? currentSession?.time?.created ?? null) as number | null,
providerModel,
tokenBreakdown,
usagePercent,
cacheHitRate,
totalAssistantCost,
contextLimit,
breakdown: {
user: userTokens,
assistant: assistantTokens,
tool: toolTokens,
other: otherTokens,
},
breakdownTotal,
};
}, [currentSessionId, providers, sessionMessages, sessions, t]);
if (!currentSessionId) {
return (
<div className="flex h-full items-center justify-center p-6 text-center typography-ui-label text-muted-foreground">
{t('contextSidebar.empty.openSession')}
</div>
);
}
const segments: Array<{ key: string; label: string; value: number; color: string }> = [
{ key: 'user', label: t('contextSidebar.breakdown.user'), value: viewModel.breakdown.user, color: 'var(--status-success)' },
{ key: 'assistant', label: t('contextSidebar.breakdown.assistant'), value: viewModel.breakdown.assistant, color: 'var(--primary-base)' },
{ key: 'tool', label: t('contextSidebar.breakdown.toolCalls'), value: viewModel.breakdown.tool, color: 'var(--status-warning)' },
{ key: 'other', label: t('contextSidebar.breakdown.other'), value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' },
];
return (
<div className="h-full overflow-y-auto bg-background">
<div className="mx-auto w-full max-w-[52rem] px-5 py-6">
{/* ── Session header ── */}
<div className="mb-6">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{viewModel.sessionTitle}</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 typography-micro text-muted-foreground/70">
<span>{viewModel.providerModel.providerName} / {viewModel.providerModel.modelName}</span>
{viewModel.createdAt && (
<>
<span>&middot;</span>
<span>{formatDateTime(viewModel.createdAt, timeFormatPreference)}</span>
</>
)}
</div>
</div>
{/* ── Context usage ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="flex items-baseline justify-between">
<span className="typography-micro text-muted-foreground">{t('contextSidebar.section.context')}</span>
<span className="typography-micro tabular-nums text-muted-foreground/70">
{formatNumber(viewModel.tokenBreakdown.total)}
{viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''}
</span>
</div>
<div className="mt-2.5 flex h-1 w-full overflow-hidden rounded-full bg-[var(--surface-subtle)]">
{viewModel.usagePercent > 0 && (
<div
className="rounded-full transition-all duration-300"
style={{
width: `${Math.max(0.5, viewModel.usagePercent)}%`,
backgroundColor: viewModel.usagePercent > 80 ? 'var(--status-warning)' : 'var(--primary-base)',
}}
/>
)}
</div>
<div className="mt-1.5 typography-micro font-medium tabular-nums text-foreground/80">
{t('contextSidebar.context.percentUsed', { percent: viewModel.usagePercent.toFixed(1) })}
</div>
</div>
{/* ── Stat grid ── */}
<div className="mb-5 grid grid-cols-2 gap-2">
{([
{ label: t('contextSidebar.stats.messages'), value: formatNumber(viewModel.messagesCount) },
{ label: t('contextSidebar.stats.user'), value: formatNumber(viewModel.userMessagesCount) },
{ label: t('contextSidebar.stats.assistant'), value: formatNumber(viewModel.assistantMessagesCount) },
{ label: t('contextSidebar.stats.cost'), value: formatMoney(viewModel.totalAssistantCost) },
] as const).map((item) => (
<div key={item.label} className="rounded-lg bg-[var(--surface-elevated)]/70 px-3 py-2.5">
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">{item.value}</div>
</div>
))}
</div>
{/* ── Last turn tokens ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="typography-micro text-muted-foreground mb-2.5">{t('contextSidebar.section.lastAssistantMessage')}</div>
<div className="grid grid-cols-3 gap-x-4 gap-y-2.5">
{([
{ label: t('contextSidebar.tokens.input'), value: viewModel.tokenBreakdown.input, format: 'count' },
{ label: t('contextSidebar.tokens.output'), value: viewModel.tokenBreakdown.output, format: 'count' },
{ label: t('contextSidebar.tokens.reasoning'), value: viewModel.tokenBreakdown.reasoning, format: 'count' },
{ label: t('contextSidebar.tokens.cacheRead'), value: viewModel.tokenBreakdown.cacheRead, format: 'count' },
{ label: t('contextSidebar.tokens.cacheWrite'), value: viewModel.tokenBreakdown.cacheWrite, format: 'count' },
{
label: t('contextSidebar.tokens.cacheHit'),
value: viewModel.cacheHitRate.hasInput ? viewModel.cacheHitRate.percent : null,
format: 'percent',
},
] as const).map((item) => (
<div key={item.label}>
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">
{item.value !== null && item.value !== undefined
? item.format === 'percent'
? `${item.value.toFixed(1)}%`
: formatNumber(item.value)
: '—'}
</div>
</div>
))}
</div>
</div>
{/* ── Context breakdown ── */}
<div className="mb-6">
<div className="flex h-1 w-full overflow-hidden rounded-full bg-[var(--surface-subtle)]">
{segments.map((segment) => {
if (segment.value <= 0 || viewModel.breakdownTotal <= 0) return null;
return (
<div
key={segment.key}
style={{
width: `${(segment.value / viewModel.breakdownTotal) * 100}%`,
backgroundColor: segment.color,
}}
/>
);
})}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
{segments.map((segment) => {
const pct = viewModel.breakdownTotal > 0 ? (segment.value / viewModel.breakdownTotal) * 100 : 0;
return (
<div key={segment.key} className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: segment.color }} />
<span className="typography-micro text-muted-foreground/70">
{segment.label} <span className="tabular-nums">{pct.toFixed(0)}%</span>
</span>
</div>
);
})}
</div>
</div>
{/* ── Raw messages ── */}
<div>
<div className="typography-micro text-muted-foreground">{t('contextSidebar.section.rawMessages')}</div>
<div className="mt-2.5 space-y-1">
{[...sessionMessages].reverse().map((message) => {
const roleInfo = deriveMessageRole(message.info);
const role = roleInfo.role;
const isAssistant = role === 'assistant';
const isUser = role === 'user';
const isExpanded = expandedRawMessages[message.info.id] === true;
const isCopied = copiedRawMessageId === message.info.id;
const messageCreatedAt = (message.info.time?.created ?? null) as number | null;
const partsLabel = derivePartsLabel(message.parts);
const tokens = isAssistant ? extractTokenBreakdown({ info: message.info, parts: message.parts }) : null;
const userSnippet = isUser ? deriveUserSnippet(message.parts) : '';
const previewTime = formatMessagePreviewTime(messageCreatedAt, timeFormatPreference);
// Keep token/time columns stable; the message label owns all
// remaining space and truncates before it can push metrics.
const assistantLeft = partsLabel || '\u2014';
const assistantMiddle = tokens
? formatAssistantTokens(tokens.input, tokens.output, formatNumber)
: '';
const otherLeft = role || 'unknown';
const otherLabel = partsLabel ? `${otherLeft}: ${partsLabel}` : otherLeft;
const jsonValue = isExpanded
? JSON.stringify({ info: message.info, parts: message.parts }, null, 2)
: '';
return (
<div
key={message.info.id}
className="overflow-hidden rounded-lg bg-[var(--surface-elevated)]/70"
>
<button
type="button"
className="w-full cursor-pointer px-3 py-1.5 text-left hover:bg-[var(--interactive-hover)]"
aria-expanded={isExpanded}
onClick={() => {
setExpandedRawMessages((prev) => ({
...prev,
[message.info.id]: !(prev[message.info.id] === true),
}));
}}
>
<div
className="grid items-center gap-x-2 whitespace-nowrap typography-micro"
style={{ gridTemplateColumns: isAssistant ? 'minmax(0, 1fr) 7.5rem max-content' : 'minmax(0, 1fr) max-content' }}
>
{isUser ? (
<span
className="min-w-0 truncate text-muted-foreground"
>
<span className="typography-ui-label text-foreground">user:</span>{' '}
{userSnippet}
</span>
) : (
<>
<span
className={
isAssistant
? 'min-w-0 truncate text-muted-foreground'
: 'min-w-0 truncate text-muted-foreground'
}
>
{isAssistant ? assistantLeft : otherLabel}
</span>
{isAssistant && (
<span className="text-right text-muted-foreground tabular-nums">
{assistantMiddle}
</span>
)}
</>
)}
<span className="text-right text-muted-foreground">{previewTime}</span>
</div>
</button>
{isExpanded && (
<div className="border-t border-[var(--surface-subtle)] p-0">
<div className="group relative max-h-[26rem] w-full overflow-auto bg-[var(--surface-background)]">
<div className="absolute top-1 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
void handleCopyRawMessage(message.info.id, jsonValue);
}}
aria-label={isCopied ? t('contextSidebar.actions.copied') : t('contextSidebar.actions.copyJson')}
title={isCopied ? t('contextSidebar.actions.copied') : t('contextSidebar.actions.copy')}
>
{isCopied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
</div>
<WorkerHighlightedCode
language="json"
code={jsonValue}
style={{
margin: 0,
padding: '0.75rem',
background: 'transparent',
fontSize: 'var(--text-micro)',
lineHeight: '1.35',
}}
wrap
/>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};