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>
427 lines
13 KiB
TypeScript
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);
|
|
}
|