Improved HTML export with timestamps, better styling, and comprehensive stats

- Added timestamps to each user and assistant message (HH:MM:SS format)
- Fixed text clipping issues with comprehensive word-wrapping CSS
- Improved font selection: ui-monospace, Cascadia Code, Source Code Pro
- Reduced font sizes for more compact display (12px base, down from 14px)
- Added model switch indicators in conversation timeline with subtle background
- Created dedicated Tokens & Cost section showing:
  - Cumulative input/output/cache read/write tokens
  - Cost breakdown by token type with 4 decimal precision
  - Total cost in bold
  - Context usage with token count, percentage, and model identification
- Now displays all unique models used during session (not just initial model)
- Made Messages section more compact (reduced gaps, removed redundant fields)

Closes #51
Closes #52
This commit is contained in:
Mario Zechner 2025-11-27 12:32:45 +01:00
parent ca0a86b981
commit 48df1ff259
4 changed files with 807 additions and 47 deletions

View file

@ -246,11 +246,32 @@ function formatToolExecution(
return { html, bgColor };
}
/**
* Format timestamp for display
*/
function formatTimestamp(timestamp: number | string | undefined): string {
if (!timestamp) return "";
const date = new Date(typeof timestamp === "string" ? timestamp : timestamp);
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
/**
* Format model change event
*/
function formatModelChange(event: any): string {
const timestamp = formatTimestamp(event.timestamp);
const timestampHtml = timestamp ? `<div class="message-timestamp">${timestamp}</div>` : "";
const modelInfo = `${event.provider}/${event.modelId}`;
return `<div class="model-change">${timestampHtml}<div class="model-change-text">Switched to model: <span class="model-name">${escapeHtml(modelInfo)}</span></div></div>`;
}
/**
* Format a message as HTML (matching TUI component styling)
*/
function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>): string {
let html = "";
const timestamp = (message as any).timestamp;
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
if (message.role === "user") {
const userMsg = message as UserMessage;
@ -264,10 +285,11 @@ function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultM
}
if (textContent.trim()) {
html += `<div class="user-message">${escapeHtml(textContent).replace(/\n/g, "<br>")}</div>`;
html += `<div class="user-message">${timestampHtml}${escapeHtml(textContent).replace(/\n/g, "<br>")}</div>`;
}
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
// Render text and thinking content
for (const content of assistantMsg.content) {
@ -297,6 +319,11 @@ function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultM
html += `<div class="error-text">Error: ${escapeHtml(errorMsg)}</div>`;
}
}
// Close the assistant message wrapper if we opened one
if (timestampHtml) {
html += "</div>";
}
}
return html;
@ -322,18 +349,62 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
let sessionHeader: any = null;
const messages: Message[] = [];
const toolResultsMap = new Map<string, ToolResultMessage>();
const sessionEvents: any[] = []; // Track all events including model changes
const modelsUsed = new Set<string>(); // Track unique models used
// Cumulative token and cost stats
const tokenStats = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const costStats = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === "session") {
sessionHeader = entry;
// Track initial model from session header
if (entry.modelId) {
const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
modelsUsed.add(modelInfo);
}
} else if (entry.type === "message") {
messages.push(entry.message);
sessionEvents.push(entry);
// Build map of tool call ID to result
if (entry.message.role === "toolResult") {
toolResultsMap.set(entry.message.toolCallId, entry.message);
}
// Accumulate token and cost stats from assistant messages
if (entry.message.role === "assistant" && entry.message.usage) {
const usage = entry.message.usage;
tokenStats.input += usage.input || 0;
tokenStats.output += usage.output || 0;
tokenStats.cacheRead += usage.cacheRead || 0;
tokenStats.cacheWrite += usage.cacheWrite || 0;
if (usage.cost) {
costStats.input += usage.cost.input || 0;
costStats.output += usage.cost.output || 0;
costStats.cacheRead += usage.cost.cacheRead || 0;
costStats.cacheWrite += usage.cost.cacheWrite || 0;
}
}
} else if (entry.type === "model_change") {
sessionEvents.push(entry);
// Track model from model change event
if (entry.modelId) {
const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
modelsUsed.add(modelInfo);
}
}
} catch {
// Skip malformed lines
@ -355,12 +426,38 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
}
}
// Generate messages HTML
// Get last assistant message for context percentage calculation (skip aborted messages)
const lastAssistantMessage = messages
.slice()
.reverse()
.find((m) => m.role === "assistant" && (m as AssistantMessage).stopReason !== "aborted") as
| AssistantMessage
| undefined;
// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)
const contextTokens = lastAssistantMessage
? lastAssistantMessage.usage.input +
lastAssistantMessage.usage.output +
lastAssistantMessage.usage.cacheRead +
lastAssistantMessage.usage.cacheWrite
: 0;
// Get the model info from the last assistant message
const lastModel = lastAssistantMessage?.model || state.model?.id || "unknown";
const lastProvider = lastAssistantMessage?.provider || "";
const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;
const contextWindow = state.model?.contextWindow || 0;
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : "0.0";
// Generate messages HTML (including model changes in chronological order)
let messagesHtml = "";
for (const message of messages) {
if (message.role !== "toolResult") {
for (const event of sessionEvents) {
if (event.type === "message" && event.message.role !== "toolResult") {
// Skip toolResult messages as they're rendered with their tool calls
messagesHtml += formatMessage(message, toolResultsMap);
messagesHtml += formatMessage(event.message, toolResultsMap);
} else if (event.type === "model_change") {
messagesHtml += formatModelChange(event);
}
}
@ -379,8 +476,8 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
}
body {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 14px;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
font-size: 12px;
line-height: 1.6;
color: ${COLORS.text};
background: ${COLORS.bodyBg};
@ -400,7 +497,7 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
}
.header h1 {
font-size: 16px;
font-size: 14px;
font-weight: bold;
margin-bottom: 12px;
color: ${COLORS.cyan};
@ -409,8 +506,8 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
.header-info {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
gap: 3px;
font-size: 11px;
}
.info-item {
@ -422,7 +519,7 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
.info-label {
font-weight: 600;
margin-right: 8px;
min-width: 80px;
min-width: 100px;
}
.info-value {
@ -430,12 +527,24 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
flex: 1;
}
.info-value.cost {
font-family: 'SF Mono', monospace;
}
.messages {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Message timestamp */
.message-timestamp {
font-size: 10px;
color: ${COLORS.textDim};
margin-bottom: 4px;
opacity: 0.8;
}
/* User message - matching TUI UserMessageComponent */
.user-message {
background: ${COLORS.userMessageBg};
@ -443,6 +552,13 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
border-radius: 4px;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
/* Assistant message wrapper */
.assistant-message {
padding: 0;
}
/* Assistant text - matching TUI AssistantMessageComponent */
@ -450,6 +566,8 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
padding: 12px 16px;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
/* Thinking text - gray italic */
@ -459,6 +577,25 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
font-style: italic;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
/* Model change */
.model-change {
padding: 8px 16px;
background: rgb(40, 40, 50);
border-radius: 4px;
}
.model-change-text {
color: ${COLORS.textDim};
font-size: 11px;
}
.model-name {
color: ${COLORS.cyan};
font-weight: bold;
}
/* Tool execution - matching TUI ToolExecutionComponent */
@ -478,6 +615,7 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
.tool-path {
color: ${COLORS.cyan};
word-break: break-all;
}
.line-count {
@ -486,13 +624,21 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
.tool-command {
font-weight: bold;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.tool-output {
margin-top: 12px;
color: ${COLORS.textDim};
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
font-family: inherit;
overflow-x: auto;
}
.tool-output > div {
@ -503,6 +649,9 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
margin: 0;
font-family: inherit;
color: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Expandable tool output */
@ -550,7 +699,9 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
color: ${COLORS.textDim};
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
overflow-wrap: break-word;
word-break: break-word;
font-size: 11px;
}
.tools-list {
@ -568,7 +719,7 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
.tools-content {
color: ${COLORS.textDim};
font-size: 13px;
font-size: 11px;
}
.tool-item {
@ -583,25 +734,31 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
/* Diff styling */
.tool-diff {
margin-top: 12px;
font-size: 13px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
overflow-x: auto;
max-width: 100%;
}
.diff-line-old {
color: ${COLORS.red};
white-space: pre;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.diff-line-new {
color: ${COLORS.green};
white-space: pre;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.diff-line-context {
color: ${COLORS.textDim};
white-space: pre;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Error text */
@ -615,7 +772,7 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
padding: 20px;
text-align: center;
color: ${COLORS.textDim};
font-size: 12px;
font-size: 10px;
}
@media print {
@ -643,8 +800,12 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
<span class="info-value">${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}</span>
</div>
<div class="info-item">
<span class="info-label">Model:</span>
<span class="info-value">${escapeHtml(sessionHeader?.model || state.model.id)}</span>
<span class="info-label">Models:</span>
<span class="info-value">${
Array.from(modelsUsed)
.map((m) => escapeHtml(m))
.join(", ") || escapeHtml(sessionHeader?.model || state.model.id)
}</span>
</div>
</div>
</div>
@ -664,21 +825,55 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
<span class="info-label">Tool Calls:</span>
<span class="info-value">${toolCallsCount}</span>
</div>
</div>
</div>
<div class="header">
<h1>Tokens & Cost</h1>
<div class="header-info">
<div class="info-item">
<span class="info-label">Tool Results:</span>
<span class="info-value">${toolResultMessages}</span>
<span class="info-label">Input:</span>
<span class="info-value">${tokenStats.input.toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Output:</span>
<span class="info-value">${tokenStats.output.toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Cache Read:</span>
<span class="info-value">${tokenStats.cacheRead.toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Cache Write:</span>
<span class="info-value">${tokenStats.cacheWrite.toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Total:</span>
<span class="info-value">${totalMessages}</span>
<span class="info-value">${(tokenStats.input + tokenStats.output + tokenStats.cacheRead + tokenStats.cacheWrite).toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Directory:</span>
<span class="info-value">${escapeHtml(shortenPath(sessionHeader?.cwd || process.cwd()))}</span>
<span class="info-label">Input Cost:</span>
<span class="info-value cost">$${costStats.input.toFixed(4)}</span>
</div>
<div class="info-item">
<span class="info-label">Thinking:</span>
<span class="info-value">${escapeHtml(sessionHeader?.thinkingLevel || state.thinkingLevel)}</span>
<span class="info-label">Output Cost:</span>
<span class="info-value cost">$${costStats.output.toFixed(4)}</span>
</div>
<div class="info-item">
<span class="info-label">Cache Read Cost:</span>
<span class="info-value cost">$${costStats.cacheRead.toFixed(4)}</span>
</div>
<div class="info-item">
<span class="info-label">Cache Write Cost:</span>
<span class="info-value cost">$${costStats.cacheWrite.toFixed(4)}</span>
</div>
<div class="info-item">
<span class="info-label">Total Cost:</span>
<span class="info-value cost"><strong>$${(costStats.input + costStats.output + costStats.cacheRead + costStats.cacheWrite).toFixed(4)}</strong></span>
</div>
<div class="info-item">
<span class="info-label">Context Usage:</span>
<span class="info-value">${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}</span>
</div>
</div>
</div>