import React from 'react'; import type { Message, Part } from '@opencode-ai/sdk/v2'; import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { deriveMessageRole } from '@/components/chat/message/messageRole'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { copyTextToClipboard } from '@/lib/clipboard'; type SessionMessage = { info: Message; parts: Part[] }; const EMPTY_SESSION_MESSAGES: SessionMessage[] = []; 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 { 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); return { input, output, reasoning, cacheRead, cacheWrite, total: 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).reduce((sum, item) => sum + estimateTextLength(item), 0); } return 0; }; const estimatePartChars = (part: Part, role: 'user' | 'assistant' | 'tool' | 'other'): ContextBuckets => { const partRecord = part as Record; 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((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(); const formatMoney = (value: number): string => { if (!Number.isFinite(value) || value <= 0) return '$0.00'; if (value < 0.01) return `$${value.toFixed(4)}`; return `$${value.toFixed(2)}`; }; const formatDateTime = (timestamp: number | null): string => { if (!timestamp || !Number.isFinite(timestamp)) return '-'; const value = new Date(timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', }); return value.replace(/, (\d{1,2}:\d{2} [AP]M)$/, ' at $1'); }; const formatMessageDateMeta = (timestamp: number | null): string => { if (!timestamp || !Number.isFinite(timestamp)) return '-'; return new Date(timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); }; const capitalizeRole = (role: string): string => { if (!role) return role; return `${role[0].toUpperCase()}${role.slice(1)}`; }; 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 { currentTheme } = useThemeSystem(); const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); const [expandedRawMessages, setExpandedRawMessages] = React.useState>({}); const [copiedRawMessageId, setCopiedRawMessageId] = React.useState(null); const copyResetTimeoutRef = React.useRef(null); const currentSessionId = useSessionStore((state) => state.currentSessionId); const sessions = useSessionStore((state) => state.sessions); const sessionMessages = useSessionStore((state) => { if (!state.currentSessionId) return EMPTY_SESSION_MESSAGES; return state.messages.get(state.currentSessionId) ?? EMPTY_SESSION_MESSAGES; }); 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); }, [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; 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 || 'Untitled Session', 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, totalAssistantCost, contextLimit, breakdown: { user: userTokens, assistant: assistantTokens, tool: toolTokens, other: otherTokens, }, breakdownTotal, }; }, [currentSessionId, providers, sessionMessages, sessions]); if (!currentSessionId) { return (
Open a session to inspect context.
); } const segments: Array<{ key: string; label: string; value: number; color: string }> = [ { key: 'user', label: 'User', value: viewModel.breakdown.user, color: 'var(--status-success)' }, { key: 'assistant', label: 'Assistant', value: viewModel.breakdown.assistant, color: 'var(--primary-base)' }, { key: 'tool', label: 'Tool Calls', value: viewModel.breakdown.tool, color: 'var(--status-warning)' }, { key: 'other', label: 'Other', value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' }, ]; return (
{/* ── Session header ── */}

{viewModel.sessionTitle}

{viewModel.providerModel.providerName} / {viewModel.providerModel.modelName} {viewModel.createdAt && ( <> · {formatDateTime(viewModel.createdAt)} )}
{/* ── Context usage ── */}
Context {formatNumber(viewModel.tokenBreakdown.total)} {viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''}
{viewModel.usagePercent > 0 && (
80 ? 'var(--status-warning)' : 'var(--primary-base)', }} /> )}
{viewModel.usagePercent.toFixed(1)}% used
{/* ── Stat grid ── */}
{([ { label: 'Messages', value: formatNumber(viewModel.messagesCount) }, { label: 'User', value: formatNumber(viewModel.userMessagesCount) }, { label: 'Assistant', value: formatNumber(viewModel.assistantMessagesCount) }, { label: 'Cost', value: formatMoney(viewModel.totalAssistantCost) }, ] as const).map((item) => (
{item.label}
{item.value}
))}
{/* ── Last turn tokens ── */}
Last Assistant Message
{([ { label: 'Input', value: viewModel.tokenBreakdown.input }, { label: 'Output', value: viewModel.tokenBreakdown.output }, { label: 'Reasoning', value: viewModel.tokenBreakdown.reasoning }, { label: 'Cache Read', value: viewModel.tokenBreakdown.cacheRead }, { label: 'Cache Write', value: viewModel.tokenBreakdown.cacheWrite }, ] as const).map((item) => (
{item.label}
{formatNumber(item.value)}
))}
{/* ── Context breakdown ── */}
{segments.map((segment) => { if (segment.value <= 0 || viewModel.breakdownTotal <= 0) return null; return (
); })}
{segments.map((segment) => { const pct = viewModel.breakdownTotal > 0 ? (segment.value / viewModel.breakdownTotal) * 100 : 0; return (
{segment.label} {pct.toFixed(0)}%
); })}
{/* ── Raw messages ── */}
Raw Messages
{[...sessionMessages].reverse().map((message) => { const role = deriveMessageRole(message.info).role; const isExpanded = expandedRawMessages[message.info.id] === true; const isCopied = copiedRawMessageId === message.info.id; const messageCreatedAt = (message.info.time?.created ?? null) as number | null; const jsonValue = isExpanded ? JSON.stringify({ info: message.info, parts: message.parts }, null, 2) : ''; return (
{isExpanded && (
{jsonValue}
)}
); })}
); };