diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
index 1f9bca2..6cd21e3 100644
--- a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
+++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
@@ -260,8 +260,8 @@
/delete <name>
Delete a saved conversation
- /export md|json
- Export conversation as Markdown or JSON
+ /export md|html|pdf|json
+ Export conversation as Markdown, HTML, PDF, or JSON
MCP Commands
@@ -567,10 +567,12 @@
Exporting Conversations
- Export to Markdown or JSON format:
+ Export to Markdown, HTML, PDF, or JSON format:
/export md
+ /export html
+ /export pdf
/export json
- Files are saved to your Downloads folder.
+ Files are saved to your Downloads folder. HTML and PDF export are also available from File → Export as HTML…/PDF…, and per-conversation from the Export submenu in the conversation list's context menu.
diff --git a/oAI/Services/ConversationExportService.swift b/oAI/Services/ConversationExportService.swift
new file mode 100644
index 0000000..cbe84a3
--- /dev/null
+++ b/oAI/Services/ConversationExportService.swift
@@ -0,0 +1,364 @@
+//
+// ConversationExportService.swift
+// oAI
+//
+// Shared conversation export: Markdown, HTML, and PDF
+//
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+// Copyright (C) 2026 Rune Olsen
+//
+// This file is part of oAI.
+//
+// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
+// You may use, study, modify, and share it for any noncommercial
+// purpose. Commercial use — including selling oAI or any part of
+// it, standalone or bundled into another product or service —
+// requires a separate commercial license from the copyright holder.
+//
+// See the LICENSE file or
+// for
+// the full license text. For commercial licensing, contact Rune
+// Olsen via .
+
+
+import Foundation
+import WebKit
+
+enum ConversationExportService {
+
+ // MARK: - Markdown
+
+ nonisolated static func markdown(messages: [Message]) -> String {
+ messages.map { msg in
+ let header = msg.role == .user ? "**User**" : "**Assistant**"
+ return "\(header)\n\n\(msg.content)"
+ }.joined(separator: "\n\n---\n\n")
+ }
+
+ // MARK: - HTML
+
+ nonisolated static func html(name: String, messages: [Message]) -> String {
+ let body = messages.map { msg -> String in
+ let roleLabel = msg.role == .user ? "User" : "Assistant"
+ let roleClass = msg.role == .user ? "user" : "assistant"
+ let rendered = renderMarkdownBody(htmlEscape(msg.content))
+ return """
+
+
\(roleLabel)
+ \(rendered)
+
+ """
+ }.joined(separator: "\n")
+
+ return """
+
+
+
+
+ \(htmlEscape(name))
+
+
+
+ \(htmlEscape(name))
+ \(body)
+
+
+ """
+ }
+
+ nonisolated private static let css = """
+ body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
+ color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; padding: 0 24px; \
+ line-height: 1.5; }
+ h1 { font-size: 24px; border-bottom: 1px solid #ddd; padding-bottom: 12px; }
+ .message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; }
+ .message.user { background: #f5f7fa; border-left-color: #4a90d9; }
+ .message.assistant { background: #fafafa; border-left-color: #8a8a8a; }
+ .role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \
+ color: #666; margin-bottom: 8px; }
+ .message p { margin: 8px 0; }
+ .message h1, .message h2, .message h3, .message h4, .message h5, .message h6 { margin: 12px 0 6px; }
+ .message ul, .message ol { margin: 8px 0; padding-left: 24px; }
+ .message blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ccc; color: #555; }
+ .message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \
+ background: #eef0f2; padding: 1px 5px; border-radius: 4px; }
+ .message pre { background: #1e1e1e; color: #e8e8e8; padding: 12px 14px; border-radius: 6px; \
+ overflow-x: auto; }
+ .message pre code { background: none; padding: 0; color: inherit; }
+ .message hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }
+ .message a { color: #4a90d9; }
+ """
+
+ // MARK: - PDF
+
+ static func pdfData(name: String, messages: [Message]) async throws -> Data {
+ let htmlString = html(name: name, messages: messages)
+ let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 816, height: 1056))
+ let delegate = PDFLoadDelegate()
+ webView.navigationDelegate = delegate
+ try await delegate.load(htmlString, in: webView)
+
+ return try await withCheckedThrowingContinuation { continuation in
+ webView.createPDF(configuration: WKPDFConfiguration()) { result in
+ continuation.resume(with: result)
+ }
+ }
+ }
+
+ private final class PDFLoadDelegate: NSObject, WKNavigationDelegate {
+ private var continuation: CheckedContinuation?
+
+ func load(_ html: String, in webView: WKWebView) async throws {
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
+ self.continuation = cont
+ webView.loadHTMLString(html, baseURL: nil)
+ }
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ continuation?.resume()
+ continuation = nil
+ }
+
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ continuation?.resume(throwing: error)
+ continuation = nil
+ }
+
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ continuation?.resume(throwing: error)
+ continuation = nil
+ }
+ }
+
+ // MARK: - File writing
+
+ nonisolated static func writeToDownloads(_ content: String, filename: String) -> URL? {
+ let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
+ ?? FileManager.default.temporaryDirectory
+ let fileURL = downloads.appendingPathComponent(filename)
+ do {
+ try content.write(to: fileURL, atomically: true, encoding: .utf8)
+ return fileURL
+ } catch {
+ return nil
+ }
+ }
+
+ nonisolated static func writeToDownloads(_ data: Data, filename: String) -> URL? {
+ let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
+ ?? FileManager.default.temporaryDirectory
+ let fileURL = downloads.appendingPathComponent(filename)
+ do {
+ try data.write(to: fileURL, options: .atomic)
+ return fileURL
+ } catch {
+ return nil
+ }
+ }
+
+ // MARK: - Markdown → HTML rendering (scoped to what chat messages actually contain —
+ // headers, bold/italic, inline code, fenced code blocks, lists, blockquotes, links,
+ // horizontal rules, paragraphs. Not full CommonMark/GFM — no tables.)
+
+ nonisolated private static func htmlEscape(_ text: String) -> String {
+ var result = text
+ result = result.replacingOccurrences(of: "&", with: "&")
+ result = result.replacingOccurrences(of: "<", with: "<")
+ result = result.replacingOccurrences(of: ">", with: ">")
+ result = result.replacingOccurrences(of: "\"", with: """)
+ result = result.replacingOccurrences(of: "'", with: "'")
+ return result
+ }
+
+ /// Renders already-HTML-escaped markdown text into an HTML body fragment.
+ nonisolated static func renderMarkdownBody(_ escapedContent: String) -> String {
+ let lines = escapedContent.components(separatedBy: "\n")
+ var html = ""
+ var index = 0
+ var paragraphLines: [String] = []
+ var listBuffer: [String] = []
+ var listTag: String?
+
+ func flushParagraph() {
+ guard !paragraphLines.isEmpty else { return }
+ let joined = paragraphLines.joined(separator: "
\n")
+ html += "\(renderInline(joined))
\n"
+ paragraphLines = []
+ }
+
+ func flushList() {
+ guard let tag = listTag, !listBuffer.isEmpty else { return }
+ html += "<\(tag)>\n"
+ for item in listBuffer { html += "\(renderInline(item))\n" }
+ html += "\(tag)>\n"
+ listBuffer = []
+ listTag = nil
+ }
+
+ while index < lines.count {
+ let line = lines[index].trimmingCharacters(in: .whitespaces)
+
+ // Fenced code block
+ if line.hasPrefix("```") {
+ flushParagraph(); flushList()
+ let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
+ var codeLines: [String] = []
+ index += 1
+ while index < lines.count, !lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
+ codeLines.append(lines[index])
+ index += 1
+ }
+ let classAttr = lang.isEmpty ? "" : " class=\"language-\(lang)\""
+ html += "\(codeLines.joined(separator: "\n"))
\n"
+ if index < lines.count { index += 1 } // skip closing ```
+ continue
+ }
+
+ // Headers
+ if let header = headerMatch(line) {
+ flushParagraph(); flushList()
+ html += "\(renderInline(header.text))\n"
+ index += 1
+ continue
+ }
+
+ // Horizontal rule
+ if isHorizontalRule(line) {
+ flushParagraph(); flushList()
+ html += "
\n"
+ index += 1
+ continue
+ }
+
+ // Blockquote (escaped ">" is ">")
+ if line.hasPrefix("> ") || line == ">" {
+ flushParagraph(); flushList()
+ var quoteLines: [String] = []
+ while index < lines.count {
+ let quoteLine = lines[index].trimmingCharacters(in: .whitespaces)
+ if quoteLine.hasPrefix("> ") {
+ quoteLines.append(String(quoteLine.dropFirst(5)))
+ } else if quoteLine == ">" {
+ quoteLines.append("")
+ } else {
+ break
+ }
+ index += 1
+ }
+ html += "\(renderInline(quoteLines.joined(separator: "
\n")))
\n"
+ continue
+ }
+
+ // Unordered list
+ if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") {
+ flushParagraph()
+ if listTag == "ol" { flushList() }
+ listTag = "ul"
+ listBuffer.append(String(line.dropFirst(2)))
+ index += 1
+ continue
+ }
+
+ // Ordered list
+ if let text = orderedListMatch(line) {
+ flushParagraph()
+ if listTag == "ul" { flushList() }
+ listTag = "ol"
+ listBuffer.append(text)
+ index += 1
+ continue
+ }
+
+ // Blank line — paragraph/list separator
+ if line.isEmpty {
+ flushParagraph(); flushList()
+ index += 1
+ continue
+ }
+
+ // Plain paragraph text
+ flushList()
+ paragraphLines.append(line)
+ index += 1
+ }
+
+ flushParagraph()
+ flushList()
+ return html
+ }
+
+ nonisolated private static func headerMatch(_ line: String) -> (level: Int, text: String)? {
+ var level = 0
+ var idx = line.startIndex
+ while idx < line.endIndex, line[idx] == "#", level < 6 {
+ level += 1
+ idx = line.index(after: idx)
+ }
+ guard level > 0, idx < line.endIndex, line[idx] == " " else { return nil }
+ return (level, String(line[line.index(after: idx)...]))
+ }
+
+ nonisolated private static func orderedListMatch(_ line: String) -> String? {
+ guard let dotRange = line.range(of: ". ") else { return nil }
+ let prefix = line[line.startIndex.. Bool {
+ guard line.count >= 3 else { return false }
+ return line.allSatisfy { $0 == "-" } || line.allSatisfy { $0 == "*" } || line.allSatisfy { $0 == "_" }
+ }
+
+ /// Applies inline markdown (code, links, bold, italic) to an already-HTML-escaped line.
+ nonisolated private static func renderInline(_ escapedText: String) -> String {
+ var text = escapedText
+ var codeSpans: [String] = []
+
+ text = replacingCaptures(text, pattern: #"`([^`]+?)`"#) { match in
+ let token = "\u{E000}\(codeSpans.count)\u{E000}"
+ codeSpans.append("\(match)")
+ return token
+ }
+
+ text = replacingCaptures(text, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#, groups: 2) { groups in
+ "\(groups[0])"
+ }
+
+ text = replacingCaptures(text, pattern: #"\*\*([^*]+?)\*\*"#) { "\($0)" }
+ text = replacingCaptures(text, pattern: #"__([^_]+?)__"#) { "\($0)" }
+ text = replacingCaptures(text, pattern: #"\*([^*]+?)\*"#) { "\($0)" }
+
+ for (i, span) in codeSpans.enumerated() {
+ text = text.replacingOccurrences(of: "\u{E000}\(i)\u{E000}", with: span)
+ }
+ return text
+ }
+
+ nonisolated private static func replacingCaptures(
+ _ text: String,
+ pattern: String,
+ transform: @escaping (String) -> String
+ ) -> String {
+ guard let regex = try? Regex(pattern) else { return text }
+ return text.replacing(regex) { match in
+ transform(match.output.count > 1 ? String(match.output[1].substring ?? "") : "")
+ }
+ }
+
+ nonisolated private static func replacingCaptures(
+ _ text: String,
+ pattern: String,
+ groups: Int,
+ transform: @escaping ([String]) -> String
+ ) -> String {
+ guard let regex = try? Regex(pattern) else { return text }
+ return text.replacing(regex) { match in
+ let captured = (1...groups).map { i in
+ match.output.count > i ? String(match.output[i].substring ?? "") : ""
+ }
+ return transform(captured)
+ }
+ }
+}
diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift
index 5847a93..2aa6d84 100644
--- a/oAI/ViewModels/ChatViewModel.swift
+++ b/oAI/ViewModels/ChatViewModel.swift
@@ -727,7 +727,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
exportConversation(format: format, filename: filename)
} else {
- showSystemMessage("Usage: /export md|json ")
+ showSystemMessage("Usage: /export md|html|pdf|json ")
}
case "/info":
@@ -1769,13 +1769,29 @@ Don't narrate future actions ("Let me...") - just use the tools.
return
}
+ if format == "pdf" {
+ let name = currentConversationName ?? "conversation"
+ Task { @MainActor in
+ do {
+ let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages)
+ if let url = ConversationExportService.writeToDownloads(data, filename: filename) {
+ showSystemMessage("Exported to \(url.path)")
+ } else {
+ showSystemMessage("Export failed: could not write file")
+ }
+ } catch {
+ showSystemMessage("Export failed: \(error.localizedDescription)")
+ }
+ }
+ return
+ }
+
let content: String
switch format {
case "md", "markdown":
- content = chatMessages.map { msg in
- let header = msg.role == .user ? "**User**" : "**Assistant**"
- return "\(header)\n\n\(msg.content)"
- }.joined(separator: "\n\n---\n\n")
+ content = ConversationExportService.markdown(messages: chatMessages)
+ case "html":
+ content = ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
case "json":
let dicts = chatMessages.map { msg -> [String: String] in
["role": msg.role.rawValue, "content": msg.content]
@@ -1788,20 +1804,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
return
}
default:
- showSystemMessage("Unsupported format: \(format). Use md or json.")
+ showSystemMessage("Unsupported format: \(format). Use md, html, pdf, or json.")
return
}
- // Write to Downloads folder
- let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
- ?? FileManager.default.temporaryDirectory
- let fileURL = downloads.appendingPathComponent(filename)
-
- do {
- try content.write(to: fileURL, atomically: true, encoding: .utf8)
- showSystemMessage("Exported to \(fileURL.path)")
- } catch {
- showSystemMessage("Export failed: \(error.localizedDescription)")
+ if let url = ConversationExportService.writeToDownloads(content, filename: filename) {
+ showSystemMessage("Exported to \(url.path)")
+ } else {
+ showSystemMessage("Export failed: could not write file")
}
}
diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift
index 641afd6..71d71af 100644
--- a/oAI/Views/Main/InputBar.swift
+++ b/oAI/Views/Main/InputBar.swift
@@ -54,7 +54,7 @@ struct InputBar: View {
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off",
- "/export md", "/export json",
+ "/export md", "/export html", "/export pdf", "/export json",
]
var body: some View {
@@ -291,6 +291,8 @@ struct CommandSuggestionsView: View {
("/load", "Load conversation"),
("/list", "List saved conversations"),
("/export md", "Export as Markdown"),
+ ("/export html", "Export as HTML"),
+ ("/export pdf", "Export as PDF"),
("/export json", "Export as JSON"),
("/info", "Show model information"),
("/credits", "Check account credits"),
diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift
index de668a2..d1ce2ee 100644
--- a/oAI/Views/Screens/ConversationListView.swift
+++ b/oAI/Views/Screens/ConversationListView.swift
@@ -400,6 +400,25 @@ struct ConversationListView: View {
} label: {
Label("Move to Folder", systemImage: "folder")
}
+ Menu {
+ Button {
+ exportConversation(conversation, format: "md")
+ } label: {
+ Label("Markdown", systemImage: "doc.text")
+ }
+ Button {
+ exportConversation(conversation, format: "html")
+ } label: {
+ Label("HTML", systemImage: "chevron.left.forwardslash.chevron.right")
+ }
+ Button {
+ exportConversation(conversation, format: "pdf")
+ } label: {
+ Label("PDF", systemImage: "doc.richtext")
+ }
+ } label: {
+ Label("Export", systemImage: "square.and.arrow.up")
+ }
Button {
renameConversation(conversation)
} label: {
@@ -662,21 +681,34 @@ struct ConversationListView: View {
}
}
- private func exportConversation(_ conversation: Conversation) {
+ private func exportConversation(_ conversation: Conversation, format: String = "md") {
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
!loadedMessages.isEmpty else {
return
}
- let content = loadedMessages.map { msg in
- let header = msg.role == .user ? "**User**" : "**Assistant**"
- return "\(header)\n\n\(msg.content)"
- }.joined(separator: "\n\n---\n\n")
+ let baseName = conversation.name.replacingOccurrences(of: " ", with: "_")
- let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
- ?? FileManager.default.temporaryDirectory
- let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
- let fileURL = downloads.appendingPathComponent(filename)
- try? content.write(to: fileURL, atomically: true, encoding: .utf8)
+ if format == "pdf" {
+ Task { @MainActor in
+ guard let data = try? await ConversationExportService.pdfData(name: conversation.name, messages: loadedMessages) else {
+ return
+ }
+ _ = ConversationExportService.writeToDownloads(data, filename: baseName + ".pdf")
+ }
+ return
+ }
+
+ let content: String
+ let filename: String
+ switch format {
+ case "html":
+ content = ConversationExportService.html(name: conversation.name, messages: loadedMessages)
+ filename = baseName + ".html"
+ default:
+ content = ConversationExportService.markdown(messages: loadedMessages)
+ filename = baseName + ".md"
+ }
+ _ = ConversationExportService.writeToDownloads(content, filename: filename)
}
}
diff --git a/oAI/Views/Screens/HelpView.swift b/oAI/Views/Screens/HelpView.swift
index 9063dd9..27cf139 100644
--- a/oAI/Views/Screens/HelpView.swift
+++ b/oAI/Views/Screens/HelpView.swift
@@ -134,10 +134,10 @@ private let helpCategories: [CommandCategory] = [
examples: ["/delete old-chat", "/delete test"]
),
CommandDetail(
- command: "/export md|json",
+ command: "/export md|html|pdf|json",
brief: "Export conversation",
- detail: "Exports the current conversation to a file. Supports Markdown (.md) and JSON (.json) formats. Optionally provide a custom filename.",
- examples: ["/export md", "/export json", "/export md my-chat.md"]
+ detail: "Exports the current conversation to a file. Supports Markdown (.md), HTML (.html), PDF (.pdf), and JSON (.json) formats. Optionally provide a custom filename.",
+ examples: ["/export md", "/export html", "/export pdf", "/export json", "/export md my-chat.md"]
),
]),
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift
index 132864d..79c7035 100644
--- a/oAI/oAIApp.swift
+++ b/oAI/oAIApp.swift
@@ -124,6 +124,20 @@ struct oAIApp: App {
chatViewModel.exportConversation(format: "md", filename: "\(safe).md")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
+
+ Button("Export as HTML…") {
+ let name = chatViewModel.currentConversationName ?? "conversation"
+ let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
+ chatViewModel.exportConversation(format: "html", filename: "\(safe).html")
+ }
+ .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
+
+ Button("Export as PDF…") {
+ let name = chatViewModel.currentConversationName ?? "conversation"
+ let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
+ chatViewModel.exportConversation(format: "pdf", filename: "\(safe).pdf")
+ }
+ .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
// ── Chat menu ─────────────────────────────────────────────────
diff --git a/oAITests/ConversationExportServiceTests.swift b/oAITests/ConversationExportServiceTests.swift
new file mode 100644
index 0000000..1f815e2
--- /dev/null
+++ b/oAITests/ConversationExportServiceTests.swift
@@ -0,0 +1,104 @@
+//
+// ConversationExportServiceTests.swift
+// oAITests
+//
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+// Copyright (C) 2026 Rune Olsen
+
+import Testing
+import Foundation
+@testable import oAI
+
+@Suite("ConversationExportService")
+struct ConversationExportServiceTests {
+
+ // MARK: - Markdown
+
+ @Test("Markdown format matches the established User/Assistant + --- convention")
+ func markdownFormat() {
+ let messages = [
+ Message(role: .user, content: "hi"),
+ Message(role: .assistant, content: "hello there"),
+ ]
+ let result = ConversationExportService.markdown(messages: messages)
+ #expect(result == "**User**\n\nhi\n\n---\n\n**Assistant**\n\nhello there")
+ }
+
+ // MARK: - HTML escaping
+
+ @Test("HTML-escapes angle brackets and ampersands instead of rendering them as markup")
+ func escapesHTMLSpecialCharacters() {
+ let messages = [Message(role: .assistant, content: " & more")]
+ let html = ConversationExportService.html(name: "Test", messages: messages)
+ #expect(!html.contains("