use crate::SessionManager;
use anyhow::Result;
use kiss_agent::AgentMessage;
use std::path::Path;
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub type SystemContext = (String, Vec<(String, String)>);
pub fn export_html(
manager: &SessionManager,
target: &Path,
system: Option<&SystemContext>,
) -> Result<()> {
let mut body = String::new();
if let Some((prompt, tools)) = system {
let tools: String = tools
.iter()
.map(|(name, description)| format!("- {name}: {description}\n"))
.collect();
body.push_str(&format!(
"<details class=\"meta\"><summary>system prompt and tools</summary><pre>{}</pre><pre>{}</pre></details>\n",
escape(prompt),
escape(&tools)
));
}
for message in manager.context_messages() {
match message.as_ref() {
AgentMessage::User(u) => {
body.push_str(&format!(
"<div class=\"msg user\"><h4>user</h4><pre>{}</pre></div>\n",
escape(&u.content.as_text())
));
}
AgentMessage::Assistant(a) => {
let mut inner = String::new();
for block in &a.content {
match block {
kiss_ai::ContentBlock::Text { text, .. } => {
inner.push_str(&format!("<pre class=\"text\">{}</pre>", escape(text)));
}
kiss_ai::ContentBlock::Thinking { thinking, .. } => {
inner.push_str(&format!(
"<details><summary>thinking</summary><pre>{}</pre></details>",
escape(thinking)
));
}
kiss_ai::ContentBlock::ToolCall(tc) => {
inner.push_str(&format!(
"<div class=\"toolcall\">→ {} <code>{}</code></div>",
escape(&tc.name),
escape(&tc.arguments.to_string())
));
}
kiss_ai::ContentBlock::Image { .. } => inner.push_str("<em>[image]</em>"),
}
}
body.push_str(&format!(
"<div class=\"msg assistant\"><h4>assistant ({})</h4>{inner}</div>\n",
escape(&a.model)
));
}
AgentMessage::ToolResult(t) => {
let text: String = t
.content
.iter()
.filter_map(|c| match c {
kiss_ai::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let class = if t.is_error { "tool error" } else { "tool" };
body.push_str(&format!(
"<details class=\"{class}\"><summary>{} result</summary><pre>{}</pre></details>\n",
escape(&t.tool_name),
escape(&text)
));
}
AgentMessage::CompactionSummary(c) => {
body.push_str(&format!("<details class=\"meta\"><summary>compaction summary</summary><pre>{}</pre></details>\n", escape(&c.summary)));
}
AgentMessage::BranchSummary(b) => {
body.push_str(&format!("<details class=\"meta\"><summary>branch summary</summary><pre>{}</pre></details>\n", escape(&b.summary)));
}
AgentMessage::BashExecution(b) => {
body.push_str(&format!(
"<details class=\"tool\"><summary>! {}</summary><pre>{}</pre></details>\n",
escape(&b.command),
escape(&b.output)
));
}
AgentMessage::Custom(c) => {
body.push_str(&format!(
"<div class=\"msg meta\"><h4>{}</h4><pre>{}</pre></div>\n",
escape(&c.custom_type),
escape(&c.content.as_text())
));
}
}
}
let title = manager
.session_name()
.unwrap_or_else(|| format!("kiss session {}", manager.session_id()));
let html = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{title}</title><style>\n\
body{{font-family:ui-monospace,monospace;max-width:960px;margin:2rem auto;padding:0 1rem;background:#14141b;color:#d6d6e0}}\n\
.msg{{margin:1rem 0;padding:.6rem 1rem;border-radius:8px;background:#1d1d29}}\n\
.msg.user{{background:#232338;border-left:3px solid #7aa2f7}}\n\
h4{{margin:.1rem 0 .4rem;color:#7aa2f7;font-size:.8rem;text-transform:uppercase}}\n\
pre{{white-space:pre-wrap;word-break:break-word;margin:.2rem 0}}\n\
details{{margin:.5rem 0;padding:.4rem .8rem;background:#191924;border-radius:6px}}\n\
details.error{{border-left:3px solid #f7768e}}\n\
summary{{cursor:pointer;color:#8b8ba0}}\n\
.toolcall{{color:#9ece6a;margin:.2rem 0}}\n\
</style></head><body><h1>{title}</h1>\n{body}</body></html>",
title = escape(&title),
);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(target, html)?;
Ok(())
}
pub fn export_jsonl(manager: &SessionManager, target: &Path) -> Result<()> {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(target, manager.to_jsonl()?)?;
Ok(())
}
pub fn export_session(
manager: &SessionManager,
target: &Path,
system: Option<&SystemContext>,
) -> Result<()> {
if target.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
export_jsonl(manager, target)
} else {
export_html(manager, target, system)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn jsonl_target_uses_portable_session_format() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("portable.jsonl");
let manager = SessionManager::in_memory(Path::new("/tmp/project"));
export_session(&manager, &target, None).unwrap();
let reopened = SessionManager::open(&target).unwrap();
assert_eq!(reopened.session_id(), manager.session_id());
}
#[test]
fn html_export_keeps_messages_a_compaction_retained() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("session.html");
let mut manager = SessionManager::in_memory(Path::new("/tmp/project"));
manager
.append_message(AgentMessage::user("summarized away"))
.unwrap();
manager
.append_compaction(
"the summary".into(),
100,
vec![AgentMessage::user("kept after compaction")],
None,
None,
)
.unwrap();
export_html(&manager, &target, None).unwrap();
let html = std::fs::read_to_string(&target).unwrap();
assert!(html.contains("the summary"));
assert!(html.contains("kept after compaction"));
}
#[test]
fn html_export_shows_the_system_prompt_and_tools() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("session.html");
let manager = SessionManager::in_memory(Path::new("/tmp/project"));
let system = (
"You are <kiss>".to_string(),
vec![("read".to_string(), "Read a file".to_string())],
);
export_session(&manager, &target, Some(&system)).unwrap();
let html = std::fs::read_to_string(&target).unwrap();
assert!(html.contains("system prompt and tools"));
assert!(html.contains("You are <kiss>"));
assert!(html.contains("- read: Read a file"));
}
}