kiss-coding 0.0.40

Coding harness: sessions, tools, compaction, settings, skills
Documentation
//! Session -> standalone HTML export.

use crate::SessionManager;
use anyhow::Result;
use kiss_agent::AgentMessage;
use std::path::Path;

fn escape(text: &str) -> String {
    text.replace('&', "&")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

/// The system prompt and each tool's name and description, when a live
/// session exports. Session files do not store them.
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)
        ));
    }
    // The conversation as the model sees it, including compaction summaries
    // and the messages a compaction kept.
    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 &lt;kiss&gt;"));
        assert!(html.contains("- read: Read a file"));
    }
}