feat(coding-agent): add --export CLI flag to convert session files to HTML

Closes #80
This commit is contained in:
Mario Zechner 2025-11-29 23:05:07 +01:00
parent dd71f877e5
commit 8900897840
4 changed files with 747 additions and 1 deletions

View file

@ -4,6 +4,8 @@
### Added
- **`--export` CLI Flag**: Export session files to self-contained HTML files from the command line. Auto-detects format (session manager format or streaming event format). Usage: `pi --export session.jsonl` or `pi --export session.jsonl output.html`. Note: Streaming event logs (from `--mode json`) don't contain system prompt or tool definitions, so those sections are omitted with a notice in the HTML. ([#80](https://github.com/badlogic/pi-mono/issues/80))
- **Git Branch File Watcher**: Footer now auto-updates when the git branch changes externally (e.g., running `git checkout` in another terminal). Watches `.git/HEAD` for changes and refreshes the branch display automatically. ([#79](https://github.com/badlogic/pi-mono/pull/79) by [@fightbulc](https://github.com/fightbulc))
- **Read-Only Exploration Tools**: Added `grep`, `find`, and `ls` tools for safe code exploration without modification risk. These tools are available via the new `--tools` flag.

View file

@ -780,6 +780,15 @@ Examples:
- `--thinking high` - Start with high thinking level
- `--thinking off` - Disable thinking even if saved setting was different
**--export <file>**
Export a session file to a self-contained HTML file and exit. Auto-detects format (session manager format or streaming event format). Optionally provide an output filename as the second argument.
**Note:** When exporting streaming event logs (e.g., `pi-output.jsonl` from `--mode json`), the system prompt and tool definitions are not available since they are not recorded in the event stream. The exported HTML will include a notice about this.
Examples:
- `--export session.jsonl` - Export to `pi-session-session.html`
- `--export session.jsonl output.html` - Export to custom filename
**--help, -h**
Show help message
@ -834,6 +843,10 @@ pi --tools read,grep,find,ls -p "Review the architecture in src/"
pi --tools read,bash,grep,find,ls \
--no-session \
-p "Use bash only for read-only operations. Read issue #74 with gh, then review the implementation"
# Export a session file to HTML
pi --export ~/.pi/agent/sessions/--myproject--/session.jsonl
pi --export session.jsonl my-export.html
```
## Tools

View file

@ -1,6 +1,6 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { readFileSync, writeFileSync } from "fs";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join } from "path";
import { fileURLToPath } from "url";
@ -911,3 +911,711 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
return outputPath;
}
/**
* Parsed session data structure for HTML generation
*/
interface ParsedSessionData {
sessionId: string;
timestamp: string;
cwd?: string;
systemPrompt?: string;
modelsUsed: Set<string>;
messages: Message[];
toolResultsMap: Map<string, ToolResultMessage>;
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
? `
<div class="tools-list">
<div class="tools-header">Available Tools</div>
<div class="tools-content">
${data.tools.map((tool) => `<div class="tool-item"><span class="tool-item-name">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`).join("")}
</div>
</div>`
: "";
// System prompt section (only if available)
const systemPromptHtml = data.systemPrompt
? `
<div class="system-prompt">
<div class="system-prompt-header">System Prompt</div>
<div class="system-prompt-content">${escapeHtml(data.systemPrompt)}</div>
</div>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Export - ${escapeHtml(inputFilename)}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
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};
padding: 24px;
}
.container {
max-width: 700px;
margin: 0 auto;
}
.header {
margin-bottom: 24px;
padding: 16px;
background: ${COLORS.containerBg};
border-radius: 4px;
}
.header h1 {
font-size: 14px;
font-weight: bold;
margin-bottom: 12px;
color: ${COLORS.cyan};
}
.header-info {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
}
.info-item {
color: ${COLORS.textDim};
display: flex;
align-items: baseline;
}
.info-label {
font-weight: 600;
margin-right: 8px;
min-width: 100px;
}
.info-value {
color: ${COLORS.text};
flex: 1;
}
.info-value.cost {
font-family: 'SF Mono', monospace;
}
.messages {
display: flex;
flex-direction: column;
gap: 16px;
}
.message-timestamp {
font-size: 10px;
color: ${COLORS.textDim};
margin-bottom: 4px;
opacity: 0.8;
}
.user-message {
background: ${COLORS.userMessageBg};
padding: 12px 16px;
border-radius: 4px;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.assistant-message {
padding: 0;
}
.assistant-text {
padding: 12px 16px;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.thinking-text {
padding: 12px 16px;
color: ${COLORS.italic};
font-style: italic;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.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 {
padding: 12px 16px;
border-radius: 4px;
margin-top: 8px;
}
.tool-header {
font-weight: bold;
}
.tool-name {
font-weight: bold;
}
.tool-path {
color: ${COLORS.cyan};
word-break: break-all;
}
.line-count {
color: ${COLORS.textDim};
}
.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 {
line-height: 1.4;
}
.tool-output pre {
margin: 0;
font-family: inherit;
color: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.tool-output.expandable {
cursor: pointer;
}
.tool-output.expandable:hover {
opacity: 0.9;
}
.tool-output.expandable .output-full {
display: none;
}
.tool-output.expandable.expanded .output-preview {
display: none;
}
.tool-output.expandable.expanded .output-full {
display: block;
}
.expand-hint {
color: ${COLORS.cyan};
font-style: italic;
margin-top: 4px;
}
.system-prompt {
background: rgb(60, 55, 40);
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
}
.system-prompt-header {
font-weight: bold;
color: ${COLORS.yellow};
margin-bottom: 8px;
}
.system-prompt-content {
color: ${COLORS.textDim};
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
font-size: 11px;
}
.tools-list {
background: rgb(60, 55, 40);
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
}
.tools-header {
font-weight: bold;
color: ${COLORS.yellow};
margin-bottom: 8px;
}
.tools-content {
color: ${COLORS.textDim};
font-size: 11px;
}
.tool-item {
margin: 4px 0;
}
.tool-item-name {
font-weight: bold;
color: ${COLORS.text};
}
.tool-diff {
margin-top: 12px;
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-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.diff-line-new {
color: ${COLORS.green};
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.diff-line-context {
color: ${COLORS.textDim};
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.error-text {
color: ${COLORS.red};
padding: 12px 16px;
}
.footer {
margin-top: 48px;
padding: 20px;
text-align: center;
color: ${COLORS.textDim};
font-size: 10px;
}
.streaming-notice {
background: rgb(50, 45, 35);
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
color: ${COLORS.textDim};
font-size: 11px;
}
@media print {
body {
background: white;
color: black;
}
.tool-execution {
border: 1px solid #ddd;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>pi v${VERSION}</h1>
<div class="header-info">
<div class="info-item">
<span class="info-label">Session:</span>
<span class="info-value">${escapeHtml(data.sessionId)}</span>
</div>
<div class="info-item">
<span class="info-label">Date:</span>
<span class="info-value">${new Date(data.timestamp).toLocaleString()}</span>
</div>
<div class="info-item">
<span class="info-label">Models:</span>
<span class="info-value">${
Array.from(data.modelsUsed)
.map((m) => escapeHtml(m))
.join(", ") || "unknown"
}</span>
</div>
</div>
</div>
<div class="header">
<h1>Messages</h1>
<div class="header-info">
<div class="info-item">
<span class="info-label">User:</span>
<span class="info-value">${userMessages}</span>
</div>
<div class="info-item">
<span class="info-label">Assistant:</span>
<span class="info-value">${assistantMessages}</span>
</div>
<div class="info-item">
<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">Input:</span>
<span class="info-value">${data.tokenStats.input.toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Output:</span>
<span class="info-value">${data.tokenStats.output.toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Cache Read:</span>
<span class="info-value">${data.tokenStats.cacheRead.toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Cache Write:</span>
<span class="info-value">${data.tokenStats.cacheWrite.toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Total:</span>
<span class="info-value">${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens</span>
</div>
<div class="info-item">
<span class="info-label">Input Cost:</span>
<span class="info-value cost">$${data.costStats.input.toFixed(4)}</span>
</div>
<div class="info-item">
<span class="info-label">Output Cost:</span>
<span class="info-value cost">$${data.costStats.output.toFixed(4)}</span>
</div>
<div class="info-item">
<span class="info-label">Cache Read Cost:</span>
<span class="info-value cost">$${data.costStats.cacheRead.toFixed(4)}</span>
</div>
<div class="info-item">
<span class="info-label">Cache Write Cost:</span>
<span class="info-value cost">$${data.costStats.cacheWrite.toFixed(4)}</span>
</div>
<div class="info-item">
<span class="info-label">Total Cost:</span>
<span class="info-value cost"><strong>$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}</strong></span>
</div>
<div class="info-item">
<span class="info-label">Context Usage:</span>
<span class="info-value">${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}</span>
</div>
</div>
</div>
${systemPromptHtml}
${toolsHtml}
${
data.isStreamingFormat
? `<div class="streaming-notice">
<em>Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.</em>
</div>`
: ""
}
<div class="messages">
${messagesHtml}
</div>
<div class="footer">
Generated by pi coding-agent on ${new Date().toLocaleString()}
</div>
</div>
</body>
</html>`;
}
/**
* 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;
}

View file

@ -7,6 +7,7 @@ import { homedir } from "os";
import { dirname, extname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
import { exportFromFile } from "./export-html.js";
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
@ -50,6 +51,7 @@ interface Args {
models?: string[];
tools?: ToolName[];
print?: boolean;
export?: string;
messages: string[];
fileArgs: string[];
}
@ -114,6 +116,8 @@ function parseArgs(args: string[]): Args {
}
} else if (arg === "--print" || arg === "-p") {
result.print = true;
} else if (arg === "--export" && i + 1 < args.length) {
result.export = args[++i];
} else if (arg.startsWith("@")) {
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
} else if (!arg.startsWith("-")) {
@ -237,6 +241,7 @@ ${chalk.bold("Options:")}
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
Available: read, bash, edit, write, grep, find, ls
--thinking <level> Set thinking level: off, minimal, low, medium, high
--export <file> Export session file to HTML and exit
--help, -h Show this help
${chalk.bold("Examples:")}
@ -273,6 +278,10 @@ ${chalk.bold("Examples:")}
# Read-only mode (no file modifications possible)
pi --tools read,grep,find,ls -p "Review the code in src/"
# Export a session file to HTML
pi --export ~/.pi/agent/sessions/--path--/session.jsonl
pi --export session.jsonl output.html
${chalk.bold("Environment Variables:")}
ANTHROPIC_API_KEY - Anthropic Claude API key
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
@ -839,6 +848,20 @@ export async function main(args: string[]) {
return;
}
// Handle --export flag: convert session file to HTML and exit
if (parsed.export) {
try {
// Use first message as output path if provided
const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;
const result = exportFromFile(parsed.export, outputPath);
console.log(`Exported to: ${result}`);
return;
} catch (error: any) {
console.error(chalk.red(`Error: ${error.message || "Failed to export session"}`));
process.exit(1);
}
}
// Validate: RPC mode doesn't support @file arguments
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));