agent-ecosystem/src/renderer/utils/sessionExporter.ts
Paul Holstein d3b7d9dfeb feat: add session export (Markdown, JSON, Plain Text)
Add an export button to the TabBar header that lets users export
the current session as Markdown, JSON, or Plain Text. The button
appears between Search and Notifications, only for session tabs.

- sessionExporter.ts: formatters for all three formats + download trigger
- ExportDropdown.tsx: dropdown UI component with format selection
- TabBar.tsx: integration with conditional rendering for session tabs
- 51 new tests covering all formatters, edge cases, and download

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 11:18:52 -05:00

427 lines
13 KiB
TypeScript

/**
* Session export utilities for claude-devtools.
*
* Provides formatters to export session data as plain text, Markdown, or JSON,
* and a download trigger for browser-based file saving.
*/
import type { Chunk, SessionDetail } from '@renderer/types/data';
import type { ContentBlock } from '@shared/types';
// =============================================================================
// Types
// =============================================================================
export type ExportFormat = 'markdown' | 'json' | 'plaintext';
interface ExtractOptions {
includeThinking?: boolean;
}
// =============================================================================
// Helpers (not exported)
// =============================================================================
function formatNumber(n: number): string {
return n.toLocaleString('en-US');
}
function formatCost(cost?: number): string {
if (cost == null) return 'N/A';
return `$${cost.toFixed(2)}`;
}
function formatTimestamp(date: Date): string {
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, ' UTC');
}
function formatDurationForExport(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const secs = Math.floor(ms / 1000);
const mins = Math.floor(secs / 60);
const remainSecs = secs % 60;
if (mins === 0) return `${secs}s`;
return `${mins}m ${remainSecs}s`;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + '...';
}
// =============================================================================
// extractTextFromContent
// =============================================================================
/**
* Extract readable text from message content (string or ContentBlock[]).
*
* @param content - String content or array of ContentBlocks
* @param options - Options controlling extraction behavior
* @returns Extracted text with newlines between blocks
*/
export function extractTextFromContent(
content: string | ContentBlock[],
options?: ExtractOptions
): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content) || content.length === 0) {
return '';
}
const parts: string[] = [];
for (const block of content) {
switch (block.type) {
case 'text':
parts.push(block.text);
break;
case 'thinking':
if (options?.includeThinking) {
parts.push(block.thinking);
}
break;
case 'tool_use':
parts.push(`Tool: ${block.name}\nInput: ${JSON.stringify(block.input, null, 2)}`);
break;
case 'tool_result': {
const resultContent = block.content;
if (typeof resultContent === 'string') {
parts.push(resultContent);
} else if (Array.isArray(resultContent)) {
// Recursively extract from nested content blocks
const nested = extractTextFromContent(resultContent);
if (nested) parts.push(nested);
}
break;
}
case 'image':
parts.push('[Image]');
break;
}
}
return parts.join('\n');
}
// =============================================================================
// Plain Text Chunk Formatters
// =============================================================================
function formatToolExecutionPlainText(exec: {
toolCall: { name: string; input: Record<string, unknown> };
result?: { content: string | unknown[]; isError: boolean };
}): string[] {
const lines: string[] = [];
lines.push(` TOOL: ${exec.toolCall.name}`);
lines.push(` Input: ${JSON.stringify(exec.toolCall.input)}`);
if (exec.result) {
const prefix = exec.result.isError ? ' [ERROR] Result: ' : ' Result: ';
const resultText =
typeof exec.result.content === 'string'
? exec.result.content
: JSON.stringify(exec.result.content);
lines.push(`${prefix}${truncate(resultText, 500)}`);
} else {
lines.push(' [No result]');
}
return lines;
}
function formatChunkPlainText(chunk: Chunk): string[] {
const lines: string[] = [];
switch (chunk.chunkType) {
case 'user': {
lines.push(`USER: ${extractTextFromContent(chunk.userMessage.content)}`);
break;
}
case 'ai': {
// Render thinking blocks first, then text
for (const response of chunk.responses) {
if (Array.isArray(response.content)) {
// Check for thinking blocks
for (const block of response.content) {
if (block.type === 'thinking') {
lines.push(`THINKING: ${block.thinking}`);
}
}
// Then text
const text = extractTextFromContent(response.content);
if (text) {
lines.push(`ASSISTANT: ${text}`);
}
} else if (typeof response.content === 'string') {
lines.push(`ASSISTANT: ${response.content}`);
}
}
// Tool executions
for (const exec of chunk.toolExecutions) {
lines.push(...formatToolExecutionPlainText(exec));
}
break;
}
case 'system': {
lines.push(`SYSTEM: ${chunk.commandOutput}`);
break;
}
case 'compact': {
lines.push('[Context compacted]');
break;
}
}
return lines;
}
// =============================================================================
// Markdown Chunk Formatters
// =============================================================================
function formatToolExecutionMarkdown(exec: {
toolCall: { name: string; input: Record<string, unknown> };
result?: { content: string | unknown[]; isError: boolean };
}): string[] {
const lines: string[] = [];
lines.push(`**Tool:** \`${exec.toolCall.name}\``);
lines.push('');
lines.push('```json');
lines.push(JSON.stringify(exec.toolCall.input, null, 2));
lines.push('```');
lines.push('');
if (exec.result) {
if (exec.result.isError) {
lines.push('**Error:**');
} else {
lines.push('**Result:**');
}
lines.push('');
const resultText =
typeof exec.result.content === 'string'
? exec.result.content
: JSON.stringify(exec.result.content, null, 2);
lines.push('```');
lines.push(truncate(resultText, 2000));
lines.push('```');
}
return lines;
}
function formatChunkMarkdown(chunk: Chunk, turnNum: number): string[] {
const lines: string[] = [];
switch (chunk.chunkType) {
case 'user': {
lines.push(`### User (Turn ${turnNum})`);
lines.push('');
lines.push(extractTextFromContent(chunk.userMessage.content));
lines.push('');
break;
}
case 'ai': {
lines.push(`### Assistant (Turn ${turnNum})`);
lines.push('');
for (const response of chunk.responses) {
if (Array.isArray(response.content)) {
// Thinking blocks as blockquotes
for (const block of response.content) {
if (block.type === 'thinking') {
lines.push('> *Thinking:*');
for (const thinkLine of block.thinking.split('\n')) {
lines.push(`> ${thinkLine}`);
}
lines.push('');
}
}
// Text content
const text = extractTextFromContent(response.content);
if (text) {
lines.push(text);
lines.push('');
}
} else if (typeof response.content === 'string') {
lines.push(response.content);
lines.push('');
}
}
// Tool executions
for (const exec of chunk.toolExecutions) {
lines.push(...formatToolExecutionMarkdown(exec));
lines.push('');
}
break;
}
case 'system': {
lines.push(`### System (Turn ${turnNum})`);
lines.push('');
lines.push(chunk.commandOutput);
lines.push('');
break;
}
case 'compact': {
lines.push('---');
lines.push('');
lines.push('*Context compacted*');
lines.push('');
break;
}
}
return lines;
}
// =============================================================================
// Export Functions
// =============================================================================
/**
* Export session as plain text transcript.
*
* Produces a flat text format with clear labels (USER:, ASSISTANT:, TOOL:, etc.)
* and separator lines between sections.
*/
export function exportAsPlainText(detail: SessionDetail): string {
const { session, metrics, chunks } = detail;
const lines: string[] = [];
// Header
lines.push('═'.repeat(60));
lines.push('SESSION EXPORT');
lines.push('═'.repeat(60));
lines.push(`Session: ${session.id}`);
lines.push(`Project: ${session.projectPath}`);
if (session.gitBranch) {
lines.push(`Branch: ${session.gitBranch}`);
}
lines.push(`Date: ${formatTimestamp(new Date(session.createdAt))}`);
lines.push('');
// Metrics
lines.push('─'.repeat(40));
lines.push('METRICS');
lines.push('─'.repeat(40));
lines.push(`Duration: ${formatDurationForExport(metrics.durationMs)}`);
lines.push(`Total Tokens: ${formatNumber(metrics.totalTokens)}`);
lines.push(`Input Tokens: ${formatNumber(metrics.inputTokens)}`);
lines.push(`Output Tokens: ${formatNumber(metrics.outputTokens)}`);
lines.push(`Cache Read: ${formatNumber(metrics.cacheReadTokens)}`);
lines.push(`Cache Created: ${formatNumber(metrics.cacheCreationTokens)}`);
lines.push(`Messages: ${formatNumber(metrics.messageCount)}`);
lines.push(`Cost: ${formatCost(metrics.costUsd)}`);
lines.push('');
// Conversation
lines.push('═'.repeat(60));
lines.push('CONVERSATION');
lines.push('═'.repeat(60));
lines.push('');
for (const chunk of chunks) {
lines.push('─'.repeat(40));
lines.push(...formatChunkPlainText(chunk));
lines.push('');
}
return lines.join('\n');
}
/**
* Export session as structured Markdown.
*
* Produces Markdown with headings, tables, code blocks, and blockquotes
* suitable for viewing in any Markdown renderer.
*/
export function exportAsMarkdown(detail: SessionDetail): string {
const { session, metrics, chunks } = detail;
const lines: string[] = [];
// Title
lines.push('# Session Export');
lines.push('');
// Property table
lines.push('| Property | Value |');
lines.push('|----------|-------|');
lines.push(`| Session | \`${session.id}\` |`);
lines.push(`| Project | \`${session.projectPath}\` |`);
if (session.gitBranch) {
lines.push(`| Branch | \`${session.gitBranch}\` |`);
}
lines.push(`| Date | ${formatTimestamp(new Date(session.createdAt))} |`);
lines.push('');
// Metrics table
lines.push('## Metrics');
lines.push('');
lines.push('| Metric | Value |');
lines.push('|--------|-------|');
lines.push(`| Duration | ${formatDurationForExport(metrics.durationMs)} |`);
lines.push(`| Total Tokens | ${formatNumber(metrics.totalTokens)} |`);
lines.push(`| Input Tokens | ${formatNumber(metrics.inputTokens)} |`);
lines.push(`| Output Tokens | ${formatNumber(metrics.outputTokens)} |`);
lines.push(`| Cache Read | ${formatNumber(metrics.cacheReadTokens)} |`);
lines.push(`| Cache Created | ${formatNumber(metrics.cacheCreationTokens)} |`);
lines.push(`| Messages | ${formatNumber(metrics.messageCount)} |`);
lines.push(`| Cost | ${formatCost(metrics.costUsd)} |`);
lines.push('');
// Conversation
lines.push('## Conversation');
lines.push('');
let turnNum = 0;
for (const chunk of chunks) {
turnNum++;
lines.push(...formatChunkMarkdown(chunk, turnNum));
}
return lines.join('\n');
}
/**
* Export session as pretty-printed JSON.
*/
export function exportAsJson(detail: SessionDetail): string {
return JSON.stringify(detail, null, 2);
}
/**
* Trigger a browser file download for the given session in the specified format.
*
* Creates a Blob, generates an object URL, and simulates an anchor click
* to initiate the download.
*/
export function triggerDownload(detail: SessionDetail, format: ExportFormat): void {
const formatters: Record<
ExportFormat,
{ fn: (d: SessionDetail) => string; ext: string; mime: string }
> = {
markdown: { fn: exportAsMarkdown, ext: 'md', mime: 'text/markdown;charset=utf-8' },
json: { fn: exportAsJson, ext: 'json', mime: 'application/json;charset=utf-8' },
plaintext: { fn: exportAsPlainText, ext: 'txt', mime: 'text/plain;charset=utf-8' },
};
const { fn, ext, mime } = formatters[format];
const content = fn(detail);
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `session-${detail.session.id}.${ext}`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}