112 lines
4.4 KiB
Rust
112 lines
4.4 KiB
Rust
//! Pure functions for exporting a conversation transcript as human-readable Markdown.
|
|
//!
|
|
//! Used by the `/export` slash command (and its dispatch handler). The converter walks
|
|
//! `RenderBlock`s and produces `## User` / `## Assistant` / `## Tools` sections with
|
|
//! compact one-line tool summaries. Non-conversation blocks (system chrome, thinking,
|
|
//! subagent lifecycle, etc.) are intentionally skipped so the output is useful for
|
|
//! "continue elsewhere" or archival.
|
|
|
|
use super::{RenderBlock, ToolCallBlock};
|
|
|
|
/// Convert an iterator of `RenderBlock` references into a Markdown transcript.
|
|
///
|
|
/// The output is a clean, readable document suitable for saving or clipboard:
|
|
/// - `## User` for user prompts (raw text)
|
|
/// - `## Assistant` for agent responses (prefers raw source Markdown via `copy_text(true)`)
|
|
/// - `## Tools` section with one-line summaries for every tool call kind
|
|
///
|
|
/// Consecutive assistant messages are coalesced under a single header.
|
|
/// Thinking / system / subagent / credit / etc. blocks are skipped.
|
|
///
|
|
/// This function is pure and easily unit-testable with synthetic blocks (including `Stub`).
|
|
pub fn render_blocks_to_markdown<'a>(blocks: impl IntoIterator<Item = &'a RenderBlock>) -> String {
|
|
let mut out = String::new();
|
|
let mut last_was_agent = false;
|
|
let mut in_tools_section = false;
|
|
|
|
for b in blocks {
|
|
match b {
|
|
RenderBlock::UserPrompt(u) => {
|
|
if in_tools_section {
|
|
out.push('\n');
|
|
in_tools_section = false;
|
|
}
|
|
out.push_str("## User\n\n");
|
|
out.push_str(&u.copy_text());
|
|
out.push_str("\n\n");
|
|
last_was_agent = false;
|
|
}
|
|
RenderBlock::AgentMessage(a) => {
|
|
if !last_was_agent {
|
|
if in_tools_section {
|
|
out.push('\n');
|
|
in_tools_section = false;
|
|
}
|
|
out.push_str("## Assistant\n\n");
|
|
}
|
|
// Prefer raw source Markdown for fidelity in the exported .md
|
|
out.push_str(&a.copy_text(true));
|
|
out.push_str("\n\n");
|
|
last_was_agent = true;
|
|
}
|
|
RenderBlock::ToolCall(tc) => {
|
|
if !in_tools_section {
|
|
out.push_str("## Tools\n\n");
|
|
in_tools_section = true;
|
|
}
|
|
out.push_str("- ");
|
|
out.push_str(&tool_summary(tc));
|
|
out.push('\n');
|
|
last_was_agent = false;
|
|
}
|
|
// Skip all non-conversation chrome: Thinking, System, SessionEvent, BgTask,
|
|
// Subagent, Btw, CreditLimit, Stub, etc. Thinking blocks are
|
|
// treated as intra-Assistant glue (no new header).
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let trimmed_len = out.trim_end().len();
|
|
out.truncate(trimmed_len);
|
|
out
|
|
}
|
|
|
|
fn tool_summary(tc: &ToolCallBlock) -> String {
|
|
match tc {
|
|
ToolCallBlock::Read(r) => {
|
|
let range = r
|
|
.line_range
|
|
.as_ref()
|
|
.map_or(String::new(), |lr| format!(" ({})", lr));
|
|
format!("Read: {}{}", r.path, range)
|
|
}
|
|
ToolCallBlock::Edit(e) => format!("Edit: {}", e.path),
|
|
ToolCallBlock::Execute(ex) => {
|
|
let desc = ex
|
|
.description
|
|
.as_deref()
|
|
.map_or(String::new(), |d| format!(" ({})", d));
|
|
format!("Execute: {}{}", ex.command, desc)
|
|
}
|
|
ToolCallBlock::ListDir(l) => format!("ListDir: {}", l.path),
|
|
ToolCallBlock::Search(s) => format!("Search: {}", s.pattern),
|
|
ToolCallBlock::WebFetch(w) => format!("WebFetch: {}", w.url),
|
|
ToolCallBlock::WebSearch(w) => format!("WebSearch: {}", w.query),
|
|
ToolCallBlock::UseTool(u) => format!("UseTool: {}", u.tool_name),
|
|
ToolCallBlock::IntegrationSearch(_) => "IntegrationSearch (MCP tool discovery)".into(),
|
|
ToolCallBlock::MemorySearch(_) => "MemorySearch".into(),
|
|
ToolCallBlock::Skill(o) | ToolCallBlock::Other(o) => format!("Tool: {}", o.name),
|
|
ToolCallBlock::Lifecycle(_) => "Lifecycle event".into(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn empty_blocks_yield_empty_string() {
|
|
let out = render_blocks_to_markdown(std::iter::empty::<&RenderBlock>());
|
|
assert!(out.is_empty());
|
|
}
|
|
}
|