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

@ -1,19 +1,30 @@
- When receiving the first user message, you MUST read README.md in full. Then ask the user which module(s) they want to work on. Then you MUST read the corresponding README.md files in full, in parallel:
- packages/ai/README.md
- packages/tui/README.md
- packages/agent/README.md
- packages/coding-agent/README.md
- packages/mom/README.md
- packages/pods/README.md
- packages/web-ui/README.md
- We must NEVER have type `any` anywhere, unless absolutely, positively necessary.
- If you are working with an external API, check node_modules for the type definitions as needed instead of assuming things.
- Always run `npm run check` in the project's root directory after making code changes. Do not tail the output, you must get the full output to see ALL errors.
- You must NEVER run `npm run dev` yourself. Doing is means you failed the user hard.
- You must NEVER run `npm run build` yourself. Only ever run `npm run check`.
- Do NOT commit unless asked to by the user
- Keep you answers short and concise and to the point.
- Do NOT use inline imports ala `await import("./theme/theme.js");`
- Read `~/agent-tools/browser-tools/README.md` if you need to run an interact with a browser
- Use GitHub CLI to interact with GitHub issues and pull requests
- Use `tmux` (installed globally) if you need to interact with a TUI app
# Development Rules
## First Message
Read README.md, then ask which module(s) to work on. Read those README.md files in parallel:
- packages/ai/README.md
- packages/tui/README.md
- packages/agent/README.md
- packages/coding-agent/README.md
- packages/mom/README.md
- packages/pods/README.md
- packages/web-ui/README.md
## Code Quality
- No `any` types unless absolutely necessary
- Check node_modules for external API type definitions instead of guessing
- No inline imports like `await import("./foo.js")`
## Commands
- After code changes: `npm run check` (get full output, no tail)
- NEVER run: `npm run dev`, `npm run build`
- NEVER commit unless user asks
## Tools
- GitHub CLI for issues/PRs
- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:proxy, pkg:tui, pkg:web-ui
- Browser tools (~/agent-tools/browser-tools/README.md): browser automation for frontend testing, web searches, fetching documentation
- TUI interaction: use tmux
## Style
- Keep answers short and concise

550
out.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,10 @@
1. Line numbers were incorrect for edits far from the start of a file (e.g., showing 1, 2, 3 instead of 336, 337, 338). The skip count for context lines was being added after displaying lines instead of before.
2. When diff lines wrapped due to terminal width, the line number prefix lost its leading space alignment, and code indentation (spaces/tabs after line numbers) was lost. Rewrote `splitIntoTokensWithAnsi` in `pi-tui` to preserve whitespace as separate tokens instead of discarding it, so wrapped lines maintain proper alignment and indentation.
### Improved
- **HTML Export**: Added timestamps to each message, fixed text clipping with proper word-wrapping CSS, improved font selection (`ui-monospace`, `Cascadia Code`, `Source Code Pro`), reduced font sizes for more compact display (12px base), added model switch indicators in conversation timeline, created dedicated Tokens & Cost section with cumulative statistics (input/output/cache tokens, cost breakdown by type), added context usage display showing token count and percentage for the last model used, and now displays all models used during the session. ([#51](https://github.com/badlogic/pi-mono/issues/51), [#52](https://github.com/badlogic/pi-mono/issues/52))
## [0.10.0] - 2025-11-27
### Added

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>