diff --git a/package.json b/package.json index c5129997..ac66b4a2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "clean": "npm run clean --workspaces", "build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent && npm run build -w @mariozechner/coding-agent && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi", - "dev": "concurrently --names \"ai,web-ui,tui,proxy\" --prefix-colors \"cyan,green,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"", + "dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui,proxy\" --prefix-colors \"cyan,yellow,red,green,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-agent\" \"npm run dev -w @mariozechner/coding-agent\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"", "dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"npm run dev:tsc -w @mariozechner/pi-ai\" \"npm run dev:tsc -w @mariozechner/pi-web-ui\"", "check": "biome check --write . && npm run check --workspaces && tsgo --noEmit", "test": "npm run test --workspaces --if-present", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 70309dfc..de421ef8 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/coding-agent", - "version": "0.6.0", + "version": "0.6.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "bin": { diff --git a/packages/coding-agent/src/export-html.ts b/packages/coding-agent/src/export-html.ts new file mode 100644 index 00000000..1afd159e --- /dev/null +++ b/packages/coding-agent/src/export-html.ts @@ -0,0 +1,457 @@ +import type { AgentState } from "@mariozechner/pi-agent"; +import type { Message } from "@mariozechner/pi-ai"; +import { readFileSync, writeFileSync } from "fs"; +import type { SessionManager } from "./session-manager.js"; + +/** + * Escape HTML special characters + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Convert ANSI color codes to HTML spans + */ +function ansiToHtml(text: string): string { + // Simple ANSI color code to HTML conversion + // This is a basic implementation - could be enhanced with a library + const ansiColors: Record = { + "30": "#000000", // black + "31": "#cd3131", // red + "32": "#0dbc79", // green + "33": "#e5e510", // yellow + "34": "#2472c8", // blue + "35": "#bc3fbc", // magenta + "36": "#11a8cd", // cyan + "37": "#e5e5e5", // white + "90": "#666666", // bright black (gray) + "91": "#f14c4c", // bright red + "92": "#23d18b", // bright green + "93": "#f5f543", // bright yellow + "94": "#3b8eea", // bright blue + "95": "#d670d6", // bright magenta + "96": "#29b8db", // bright cyan + "97": "#ffffff", // bright white + }; + + let html = escapeHtml(text); + + // Replace ANSI codes with HTML spans + html = html.replace(/\x1b\[([0-9;]+)m/g, (_match, codes) => { + const codeList = codes.split(";"); + if (codeList.includes("0")) { + return ""; // Reset + } + for (const code of codeList) { + if (ansiColors[code]) { + return ``; + } + if (code === "1") { + return ''; + } + if (code === "2") { + return ''; + } + } + return ""; + }); + + return html; +} + +/** + * Format a message as HTML + */ +function formatMessage(message: Message): string { + const role = message.role; + const roleClass = + role === "user" ? "user-message" : role === "toolResult" ? "tool-result-message" : "assistant-message"; + const roleLabel = role === "user" ? "User" : role === "assistant" ? "Assistant" : "Tool Result"; + + let html = `
`; + html += `
${roleLabel}
`; + html += `
`; + + // Handle ToolResultMessage separately + if (role === "toolResult") { + const isError = message.isError; + html += `
`; + html += `
${isError ? "❌" : "✅"} ${escapeHtml(message.toolName)}
`; + + for (const content of message.content) { + if (content.type === "text") { + html += `
${ansiToHtml(content.text)}
`; + } else if (content.type === "image") { + const imageData = content.data; + const mimeType = content.mimeType || "image/png"; + html += `Tool result image`; + } + } + html += `
`; + } + // Handle string content (for user messages) + else if (typeof message.content === "string") { + const text = escapeHtml(message.content); + html += `
${text.replace(/\n/g, "
")}
`; + } else { + // Handle array content + for (const content of message.content) { + if (typeof content === "string") { + // Handle legacy string content + const text = escapeHtml(content); + html += `
${text.replace(/\n/g, "
")}
`; + } else if (content.type === "text") { + // Format text with markdown-like rendering + const text = escapeHtml(content.text); + html += `
${text.replace(/\n/g, "
")}
`; + } else if (content.type === "thinking") { + html += `
`; + html += `Thinking...`; + html += `
${escapeHtml(content.thinking).replace(/\n/g, "
")}
`; + html += `
`; + } else if (content.type === "toolCall") { + html += `
`; + html += `
🔧 ${escapeHtml(content.name)}
`; + html += `
${escapeHtml(JSON.stringify(content.arguments, null, 2))}
`; + html += `
`; + } else if (content.type === "image") { + const imageData = content.data; + const mimeType = content.mimeType || "image/png"; + html += `User image`; + } + } + } + + html += `
`; + return html; +} + +/** + * Export session to a self-contained HTML file + */ +export function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string { + const sessionFile = sessionManager.getSessionFile(); + const timestamp = new Date().toISOString(); + + // Generate output filename if not provided + if (!outputPath) { + const dateStr = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0]; + outputPath = `coding-session-${dateStr}.html`; + } + + // Read session data + const sessionContent = readFileSync(sessionFile, "utf8"); + const lines = sessionContent.trim().split("\n"); + + // Parse session metadata + let sessionHeader: any = null; + const messages: Message[] = []; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === "session") { + sessionHeader = entry; + } else if (entry.type === "message") { + messages.push(entry.message); + } + } catch { + // Skip malformed lines + } + } + + // Generate HTML + const html = ` + + + + + Coding Session Export - ${timestamp} + + + +
+
+

Coding Session Export

+
+
+
Session ID
+
${sessionHeader?.id || "unknown"}
+
+
+
Date
+
${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}
+
+
+
Model
+
${escapeHtml(sessionHeader?.model || state.model.id)}
+
+
+
Messages
+
${messages.length}
+
+
+
Working Directory
+
${escapeHtml(sessionHeader?.cwd || process.cwd())}
+
+
+
Thinking Level
+
${escapeHtml(sessionHeader?.thinkingLevel || state.thinkingLevel)}
+
+
+
+ +
+ ${messages.map((msg) => formatMessage(msg)).join("\n")} +
+ + +
+ +`; + + // Write HTML file + writeFileSync(outputPath, html, "utf8"); + + return outputPath; +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 77f6f441..44b69194 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -11,6 +11,7 @@ import { TUI, } from "@mariozechner/pi-tui"; import chalk from "chalk"; +import { exportSessionToHtml } from "../export-html.js"; import type { SessionManager } from "../session-manager.js"; import { AssistantMessageComponent } from "./assistant-message.js"; import { CustomEditor } from "./custom-editor.js"; @@ -77,8 +78,16 @@ export class TuiRenderer { description: "Select model (opens selector UI)", }; + const exportCommand: SlashCommand = { + name: "export", + description: "Export session to HTML file", + }; + // Setup autocomplete for file paths and slash commands - const autocompleteProvider = new CombinedAutocompleteProvider([thinkingCommand, modelCommand], process.cwd()); + const autocompleteProvider = new CombinedAutocompleteProvider( + [thinkingCommand, modelCommand, exportCommand], + process.cwd(), + ); this.editor.setAutocompleteProvider(autocompleteProvider); } @@ -151,6 +160,13 @@ export class TuiRenderer { return; } + // Check for /export command + if (text.startsWith("/export")) { + this.handleExportCommand(text); + this.editor.setText(""); + return; + } + if (this.onInputCallback) { this.onInputCallback(text); } @@ -516,6 +532,29 @@ export class TuiRenderer { this.ui.setFocus(this.editor); } + private handleExportCommand(text: string): void { + // Parse optional filename from command: /export [filename] + const parts = text.split(/\s+/); + const outputPath = parts.length > 1 ? parts[1] : undefined; + + try { + // Export session to HTML + const filePath = exportSessionToHtml(this.sessionManager, this.agent.state, outputPath); + + // Show success message in chat + this.chatContainer.addChild(new Text("", 0, 0)); // Spacer + this.chatContainer.addChild(new Text(chalk.green(`✓ Session exported to: ${filePath}`), 0, 0)); + this.ui.requestRender(); + } catch (error: any) { + // Show error message in chat + this.chatContainer.addChild(new Text("", 0, 0)); // Spacer + this.chatContainer.addChild( + new Text(chalk.red(`✗ Failed to export session: ${error.message || "Unknown error"}`), 0, 0), + ); + this.ui.requestRender(); + } + } + stop(): void { if (this.loadingAnimation) { this.loadingAnimation.stop();