import type { AgentState } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { basename } from "path";
import { VERSION } from "./config.js";
import type { SessionManager } from "./session-manager.js";
/**
* TUI Color scheme (matching exact RGB values from TUI components)
*/
const COLORS = {
// Backgrounds
userMessageBg: "rgb(52, 53, 65)", // Dark slate
toolPendingBg: "rgb(40, 40, 50)", // Dark blue-gray
toolSuccessBg: "rgb(40, 50, 40)", // Dark green
toolErrorBg: "rgb(60, 40, 40)", // Dark red
bodyBg: "rgb(24, 24, 30)", // Very dark background
containerBg: "rgb(30, 30, 36)", // Slightly lighter container
// Text colors (matching chalk colors)
text: "rgb(229, 229, 231)", // Light gray (close to white)
textDim: "rgb(161, 161, 170)", // Dimmed gray
cyan: "rgb(103, 232, 249)", // Cyan for paths
green: "rgb(34, 197, 94)", // Green for success
red: "rgb(239, 68, 68)", // Red for errors
yellow: "rgb(234, 179, 8)", // Yellow for warnings
italic: "rgb(161, 161, 170)", // Gray italic for thinking
};
/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Shorten path with tilde notation
*/
function shortenPath(path: string): string {
const home = homedir();
if (path.startsWith(home)) {
return "~" + path.slice(home.length);
}
return path;
}
/**
* Replace tabs with 3 spaces
*/
function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
/**
* Format tool execution matching TUI ToolExecutionComponent
*/
function formatToolExecution(
toolName: string,
args: any,
result?: ToolResultMessage,
): { html: string; bgColor: string } {
let html = "";
const isError = result?.isError || false;
const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg;
// Get text output from result
const getTextOutput = (): string => {
if (!result) return "";
const textBlocks = result.content.filter((c) => c.type === "text");
return textBlocks.map((c: any) => c.text).join("\n");
};
// Format based on tool type (matching TUI logic exactly)
if (toolName === "bash") {
const command = args?.command || "";
html = `
$ ${escapeHtml(command || "...")}
`;
if (result) {
const output = getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = 5;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
if (remaining > 0) {
// Truncated output - make it expandable
html += '";
} else {
// Short output - show all
html += '";
}
}
}
} else if (toolName === "read") {
const path = shortenPath(args?.file_path || args?.path || "");
html = ``;
if (result) {
const output = getTextOutput();
const lines = output.split("\n");
const maxLines = 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
if (remaining > 0) {
// Truncated output - make it expandable
html += '";
} else {
// Short output - show all
html += '";
}
}
} else if (toolName === "write") {
const path = shortenPath(args?.file_path || args?.path || "");
const fileContent = args?.content || "";
const lines = fileContent ? fileContent.split("\n") : [];
const totalLines = lines.length;
html = `";
if (fileContent) {
const maxLines = 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
if (remaining > 0) {
// Truncated output - make it expandable
html += '";
} else {
// Short output - show all
html += '";
}
}
if (result) {
const output = getTextOutput().trim();
if (output) {
html += ``;
}
}
} else if (toolName === "edit") {
const path = shortenPath(args?.file_path || args?.path || "");
html = ``;
// Show diff if available from result.details.diff
if (result?.details?.diff) {
const diffLines = result.details.diff.split("\n");
html += '";
}
if (result) {
const output = getTextOutput().trim();
if (output) {
html += ``;
}
}
} else {
// Generic tool
html = ``;
html += ``;
if (result) {
const output = getTextOutput();
if (output) {
html += ``;
}
}
}
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 ? `${timestamp}
` : "";
const modelInfo = `${event.provider}/${event.modelId}`;
return `${timestampHtml}
Switched to model: ${escapeHtml(modelInfo)}
`;
}
/**
* Format a message as HTML (matching TUI component styling)
*/
function formatMessage(message: Message, toolResultsMap: Map): string {
let html = "";
const timestamp = (message as any).timestamp;
const timestampHtml = timestamp ? `${formatTimestamp(timestamp)}
` : "";
if (message.role === "user") {
const userMsg = message as UserMessage;
let textContent = "";
if (typeof userMsg.content === "string") {
textContent = userMsg.content;
} else {
const textBlocks = userMsg.content.filter((c) => c.type === "text");
textContent = textBlocks.map((c: any) => c.text).join("");
}
if (textContent.trim()) {
html += `${timestampHtml}${escapeHtml(textContent).replace(/\n/g, "
")}
`;
}
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
html += timestampHtml ? `${timestampHtml}` : "";
// Render text and thinking content
for (const content of assistantMsg.content) {
if (content.type === "text" && content.text.trim()) {
html += `
${escapeHtml(content.text.trim()).replace(/\n/g, "
")}
`;
} else if (content.type === "thinking" && content.thinking.trim()) {
html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`;
}
}
// Render tool calls with their results
for (const content of assistantMsg.content) {
if (content.type === "toolCall") {
const toolResult = toolResultsMap.get(content.id);
const { html: toolHtml, bgColor } = formatToolExecution(content.name, content.arguments, toolResult);
html += `
${toolHtml}
`;
}
}
// Show error/abort status if no tool calls
const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall");
if (!hasToolCalls) {
if (assistantMsg.stopReason === "aborted") {
html += '
Aborted
';
} else if (assistantMsg.stopReason === "error") {
const errorMsg = assistantMsg.errorMessage || "Unknown error";
html += `
Error: ${escapeHtml(errorMsg)}
`;
}
}
// Close the assistant message wrapper if we opened one
if (timestampHtml) {
html += "
";
}
}
return html;
}
/**
* Export session to a self-contained HTML file matching TUI visual style
*/
export function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string {
const sessionFile = sessionManager.getSessionFile();
const timestamp = new Date().toISOString();
// Use pi-session- prefix + session filename + .html if no output path provided
if (!outputPath) {
const sessionBasename = basename(sessionFile, ".jsonl");
outputPath = `pi-session-${sessionBasename}.html`;
}
// Read and parse session data
const sessionContent = readFileSync(sessionFile, "utf8");
const lines = sessionContent.trim().split("\n");
let sessionHeader: any = null;
const messages: Message[] = [];
const toolResultsMap = new Map();
const sessionEvents: any[] = []; // Track all events including model changes
const modelsUsed = new Set(); // 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
}
}
// Calculate message stats (matching session command)
const userMessages = messages.filter((m) => m.role === "user").length;
const assistantMessages = messages.filter((m) => m.role === "assistant").length;
const toolResultMessages = messages.filter((m) => m.role === "toolResult").length;
const totalMessages = messages.length;
// Count tool calls from assistant messages
let toolCallsCount = 0;
for (const message of messages) {
if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
toolCallsCount += assistantMsg.content.filter((c) => c.type === "toolCall").length;
}
}
// 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 event of sessionEvents) {
if (event.type === "message" && event.message.role !== "toolResult") {
// Skip toolResult messages as they're rendered with their tool calls
messagesHtml += formatMessage(event.message, toolResultsMap);
} else if (event.type === "model_change") {
messagesHtml += formatModelChange(event);
}
}
// Generate HTML (matching TUI aesthetic)
const html = `
Session Export - ${basename(sessionFile)}
${escapeHtml(sessionHeader?.systemPrompt || state.systemPrompt)}
${messagesHtml}
`;
// Write HTML file
writeFileSync(outputPath, html, "utf8");
return outputPath;
}
/**
* Parsed session data structure for HTML generation
*/
interface ParsedSessionData {
sessionId: string;
timestamp: string;
cwd?: string;
systemPrompt?: string;
modelsUsed: Set;
messages: Message[];
toolResultsMap: Map;
sessionEvents: any[];
tokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number };
costStats: { input: number; output: number; cacheRead: number; cacheWrite: number };
tools?: { name: string; description: string }[];
isStreamingFormat?: boolean;
}
/**
* Parse session manager format (type: "session", "message", "model_change")
*/
function parseSessionManagerFormat(lines: string[]): ParsedSessionData {
const data: ParsedSessionData = {
sessionId: "unknown",
timestamp: new Date().toISOString(),
modelsUsed: new Set(),
messages: [],
toolResultsMap: new Map(),
sessionEvents: [],
tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === "session") {
data.sessionId = entry.id || "unknown";
data.timestamp = entry.timestamp || data.timestamp;
data.cwd = entry.cwd;
data.systemPrompt = entry.systemPrompt;
if (entry.modelId) {
const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
data.modelsUsed.add(modelInfo);
}
} else if (entry.type === "message") {
data.messages.push(entry.message);
data.sessionEvents.push(entry);
if (entry.message.role === "toolResult") {
data.toolResultsMap.set(entry.message.toolCallId, entry.message);
}
if (entry.message.role === "assistant" && entry.message.usage) {
const usage = entry.message.usage;
data.tokenStats.input += usage.input || 0;
data.tokenStats.output += usage.output || 0;
data.tokenStats.cacheRead += usage.cacheRead || 0;
data.tokenStats.cacheWrite += usage.cacheWrite || 0;
if (usage.cost) {
data.costStats.input += usage.cost.input || 0;
data.costStats.output += usage.cost.output || 0;
data.costStats.cacheRead += usage.cost.cacheRead || 0;
data.costStats.cacheWrite += usage.cost.cacheWrite || 0;
}
}
} else if (entry.type === "model_change") {
data.sessionEvents.push(entry);
if (entry.modelId) {
const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
data.modelsUsed.add(modelInfo);
}
}
} catch {
// Skip malformed lines
}
}
return data;
}
/**
* Parse streaming event format (type: "agent_start", "message_start", "message_end", etc.)
*/
function parseStreamingEventFormat(lines: string[]): ParsedSessionData {
const data: ParsedSessionData = {
sessionId: "unknown",
timestamp: new Date().toISOString(),
modelsUsed: new Set(),
messages: [],
toolResultsMap: new Map(),
sessionEvents: [],
tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
isStreamingFormat: true,
};
let timestampSet = false;
// Track messages by collecting message_end events (which have the final state)
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === "message_end" && entry.message) {
const msg = entry.message;
data.messages.push(msg);
data.sessionEvents.push({ type: "message", message: msg, timestamp: msg.timestamp });
// Build tool results map
if (msg.role === "toolResult") {
data.toolResultsMap.set(msg.toolCallId, msg);
}
// Track models and accumulate stats from assistant messages
if (msg.role === "assistant") {
if (msg.model) {
const modelInfo = msg.provider ? `${msg.provider}/${msg.model}` : msg.model;
data.modelsUsed.add(modelInfo);
}
if (msg.usage) {
data.tokenStats.input += msg.usage.input || 0;
data.tokenStats.output += msg.usage.output || 0;
data.tokenStats.cacheRead += msg.usage.cacheRead || 0;
data.tokenStats.cacheWrite += msg.usage.cacheWrite || 0;
if (msg.usage.cost) {
data.costStats.input += msg.usage.cost.input || 0;
data.costStats.output += msg.usage.cost.output || 0;
data.costStats.cacheRead += msg.usage.cost.cacheRead || 0;
data.costStats.cacheWrite += msg.usage.cost.cacheWrite || 0;
}
}
}
// Use first message timestamp as session timestamp
if (!timestampSet && msg.timestamp) {
data.timestamp = new Date(msg.timestamp).toISOString();
timestampSet = true;
}
}
} catch {
// Skip malformed lines
}
}
// Generate a session ID from the timestamp
data.sessionId = `stream-${data.timestamp.replace(/[:.]/g, "-")}`;
return data;
}
/**
* Detect the format of a session file by examining the first valid JSON line
*/
function detectFormat(lines: string[]): "session-manager" | "streaming-events" | "unknown" {
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === "session") return "session-manager";
if (entry.type === "agent_start" || entry.type === "message_start" || entry.type === "turn_start") {
return "streaming-events";
}
} catch {
// Skip malformed lines
}
}
return "unknown";
}
/**
* Generate HTML from parsed session data
*/
function generateHtml(data: ParsedSessionData, inputFilename: string): string {
// Calculate message stats
const userMessages = data.messages.filter((m) => m.role === "user").length;
const assistantMessages = data.messages.filter((m) => m.role === "assistant").length;
// Count tool calls from assistant messages
let toolCallsCount = 0;
for (const message of data.messages) {
if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
toolCallsCount += assistantMsg.content.filter((c) => c.type === "toolCall").length;
}
}
// Get last assistant message for context info
const lastAssistantMessage = data.messages
.slice()
.reverse()
.find((m) => m.role === "assistant" && (m as AssistantMessage).stopReason !== "aborted") as
| AssistantMessage
| undefined;
const contextTokens = lastAssistantMessage
? lastAssistantMessage.usage.input +
lastAssistantMessage.usage.output +
lastAssistantMessage.usage.cacheRead +
lastAssistantMessage.usage.cacheWrite
: 0;
const lastModel = lastAssistantMessage?.model || "unknown";
const lastProvider = lastAssistantMessage?.provider || "";
const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;
// Generate messages HTML
let messagesHtml = "";
for (const event of data.sessionEvents) {
if (event.type === "message" && event.message.role !== "toolResult") {
messagesHtml += formatMessage(event.message, data.toolResultsMap);
} else if (event.type === "model_change") {
messagesHtml += formatModelChange(event);
}
}
// Tools section (only if tools info available)
const toolsHtml = data.tools
? `
`
: "";
// System prompt section (only if available)
const systemPromptHtml = data.systemPrompt
? `
${escapeHtml(data.systemPrompt)}
`
: "";
return `
Session Export - ${escapeHtml(inputFilename)}
${systemPromptHtml}
${toolsHtml}
${
data.isStreamingFormat
? `
Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.
`
: ""
}
${messagesHtml}
`;
}
/**
* Export a session file to HTML (standalone, without AgentState or SessionManager)
* Auto-detects format: session manager format or streaming event format
*/
export function exportFromFile(inputPath: string, outputPath?: string): string {
if (!existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
}
const content = readFileSync(inputPath, "utf8");
const lines = content
.trim()
.split("\n")
.filter((l) => l.trim());
if (lines.length === 0) {
throw new Error(`Empty file: ${inputPath}`);
}
const format = detectFormat(lines);
if (format === "unknown") {
throw new Error(`Unknown session file format: ${inputPath}`);
}
const data = format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines);
// Generate output path if not provided
if (!outputPath) {
const inputBasename = basename(inputPath, ".jsonl");
outputPath = `pi-session-${inputBasename}.html`;
}
const html = generateHtml(data, basename(inputPath));
writeFileSync(outputPath, html, "utf8");
return outputPath;
}