use koda_core::persistence::{Message, Role};
use koda_core::tools::{ToolEffect, classify_tool};
use std::collections::HashMap;
#[derive(Debug, Default)]
pub struct SessionMeta {
pub session_id: String,
pub title: Option<String>,
pub started_at: Option<String>,
pub model: String,
pub provider: String,
pub project_root: String,
}
const SUMMARY_RESULT_LINES: usize = 10;
const VERBOSE_RESULT_LINES: usize = 50;
const SUMMARY_BASH_CHARS: usize = 80;
const VERBOSE_BASH_CHARS: usize = 500;
const HYPERLINK_KILL_SWITCH: &str = "KODA_TRANSCRIPT_HYPERLINKS";
fn hyperlinks_enabled() -> bool {
!matches!(
std::env::var(HYPERLINK_KILL_SWITCH).ok().as_deref(),
Some("off" | "0" | "false" | "no")
)
}
fn format_utc_now() -> String {
let dt = crate::util::utc_now();
format!(
"{:04}-{:02}-{:02} {:02}:{:02} UTC",
dt.year(),
dt.month() as u8,
dt.day(),
dt.hour(),
dt.minute(),
)
}
pub fn render(messages: &[Message], meta: &SessionMeta, verbose: bool) -> String {
let mut out = String::with_capacity(if verbose { 16384 } else { 4096 });
let title = meta.title.as_deref().unwrap_or("Koda Session");
let now = format_utc_now();
out.push_str(&format!("# {title} โ {now}\n\n"));
if verbose {
render_metadata_table(&mut out, meta);
}
let mut tool_id_to_name: HashMap<String, String> = HashMap::new();
for msg in messages {
if msg.role == Role::Assistant
&& let Some(ref tc_json) = msg.tool_calls
&& let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(tc_json)
{
for call in calls {
if let (Some(id), Some(name)) =
(call["id"].as_str(), call["function"]["name"].as_str())
{
tool_id_to_name.insert(id.to_string(), name.to_string());
}
}
}
}
for msg in messages {
match msg.role {
Role::System => {}
Role::User => {
out.push_str("---\n\n");
render_role_header(&mut out, "\u{1f9d1} User", msg, verbose);
if let Some(ref content) = msg.content {
out.push_str(content.trim());
out.push_str("\n\n");
}
}
Role::Assistant => {
render_role_header(&mut out, "๐ค Assistant", msg, verbose);
if let Some(ref thinking) = msg.thinking_content
&& !thinking.trim().is_empty()
{
out.push_str("> ๐ญ **Thinking**\n");
for line in thinking.trim().lines() {
out.push_str("> ");
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
if let Some(ref content) = msg.content {
let trimmed = content.trim();
if !trimmed.is_empty() {
out.push_str(trimmed);
out.push_str("\n\n");
}
}
if let Some(ref tc_json) = msg.tool_calls
&& let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(tc_json)
{
let bash_limit = if verbose {
VERBOSE_BASH_CHARS
} else {
SUMMARY_BASH_CHARS
};
let link = hyperlinks_enabled();
for call in &calls {
let name = call["function"]["name"].as_str().unwrap_or("Tool");
let args_json = call["function"]["arguments"].as_str().unwrap_or("{}");
let detail = tool_detail_markdown(
name,
args_json,
bash_limit,
&meta.project_root,
link,
);
let icon = tool_icon(name);
out.push_str(&format!("### {icon} **{name}**"));
if !detail.is_empty() {
if detail.starts_with('[') {
out.push(' ');
out.push_str(&detail);
} else {
out.push_str(&format!(" `{detail}`"));
}
}
out.push('\n');
}
out.push('\n');
}
if verbose {
render_token_counts(&mut out, msg);
}
}
Role::Tool => {
let tool_name = msg
.tool_call_id
.as_deref()
.and_then(|id| tool_id_to_name.get(id))
.map(|s| s.as_str())
.unwrap_or("");
let content = msg.content.as_deref().unwrap_or("").trim();
let total_lines = content.lines().count();
if !content.is_empty() {
let effect = classify_tool(tool_name);
let max_lines = if verbose {
VERBOSE_RESULT_LINES
} else {
SUMMARY_RESULT_LINES
};
let show_content = verbose || effect == ToolEffect::ReadOnly;
if show_content {
out.push_str("**Output:**\n\n```\n");
let preview_lines: Vec<&str> = content.lines().take(max_lines).collect();
out.push_str(&preview_lines.join("\n"));
if total_lines > max_lines {
out.push_str(&format!("\nโฆ ({} more lines)", total_lines - max_lines));
}
out.push_str("\n```\n\n");
} else if total_lines > 0 {
out.push_str(&format!(
"> _{total_lines} line(s) of output โ run tool to see full result_\n\n"
));
}
}
}
}
}
out
}
fn render_metadata_table(out: &mut String, meta: &SessionMeta) {
out.push_str("| Field | Value |\n|---|---|\n");
out.push_str(&format!("| Session | `{}` |\n", meta.session_id));
out.push_str(&format!("| Model | {} |\n", meta.model));
out.push_str(&format!("| Provider | {} |\n", meta.provider));
out.push_str(&format!("| Project | `{}` |\n", meta.project_root));
if let Some(ref started) = meta.started_at {
out.push_str(&format!("| Started | {started} |\n"));
}
out.push('\n');
}
fn render_role_header(out: &mut String, label: &str, msg: &Message, verbose: bool) {
out.push_str(&format!("## {label}"));
if verbose && let Some(ref ts) = msg.created_at {
let time_part = ts.split('T').nth(1).and_then(|t| t.get(..8)).unwrap_or(ts);
out.push_str(&format!(" <sub>{time_part}</sub>"));
}
out.push_str("\n\n");
}
fn render_token_counts(out: &mut String, msg: &Message) {
let mut parts: Vec<String> = Vec::new();
if let Some(p) = msg.prompt_tokens {
parts.push(format!("{p} prompt"));
}
if let Some(c) = msg.completion_tokens {
parts.push(format!("{c} completion"));
}
if let Some(cr) = msg.cache_read_tokens
&& cr > 0
{
parts.push(format!("{cr} cache-read"));
}
if let Some(cc) = msg.cache_creation_tokens
&& cc > 0
{
parts.push(format!("{cc} cache-write"));
}
if let Some(t) = msg.thinking_tokens
&& t > 0
{
parts.push(format!("{t} thinking"));
}
if !parts.is_empty() {
out.push_str(&format!("<sub>tokens: {}</sub>\n\n", parts.join(" ยท ")));
}
}
fn tool_icon(name: &str) -> &'static str {
match name {
"Read" => "๐",
"Write" => "โ๏ธ",
"Edit" => "โ๏ธ",
"Delete" => "๐๏ธ",
"Bash" => "๐ป",
"Grep" => "๐",
"List" | "Glob" => "๐",
"WebFetch" => "๐",
"TodoWrite" => "๐",
"MemoryWrite" | "MemoryRead" => "๐ง ",
"InvokeAgent" => "๐ค",
"AskUser" => "๐ฌ",
_ => "๐ง",
}
}
fn tool_detail_markdown(
name: &str,
args_json: &str,
bash_limit: usize,
project_root: &str,
link: bool,
) -> String {
let args: serde_json::Value =
serde_json::from_str(args_json).unwrap_or(serde_json::Value::Null);
let raw = crate::tool_header::detail_text(name, &args, bash_limit);
if !link || raw.is_empty() {
return raw;
}
match name {
"Read" | "Write" | "Edit" | "Delete" => {
let abs = absolute_path(&raw, project_root);
format!("[{raw}]({})", file_uri(&abs))
}
"WebFetch" => format!("[{raw}]({raw})"),
_ => raw,
}
}
fn absolute_path(path: &str, project_root: &str) -> String {
if path.starts_with('/') || project_root.is_empty() {
return path.to_string();
}
let root = project_root.trim_end_matches('/');
format!("{root}/{path}")
}
fn file_uri(abs_path: &str) -> String {
let mut out = String::with_capacity(abs_path.len() + 8);
out.push_str("file://");
if !abs_path.starts_with('/') {
out.push('/');
}
for ch in abs_path.chars() {
match ch {
' ' => out.push_str("%20"),
'(' => out.push_str("%28"),
')' => out.push_str("%29"),
'[' => out.push_str("%5B"),
']' => out.push_str("%5D"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use koda_core::persistence::Message;
fn make_msg(role: Role, content: &str) -> Message {
Message {
id: 0,
session_id: "test".into(),
role,
content: Some(content.into()),
full_content: None,
tool_calls: None,
tool_call_id: None,
prompt_tokens: None,
completion_tokens: None,
cache_read_tokens: None,
cache_creation_tokens: None,
thinking_tokens: None,
thinking_content: None,
created_at: None,
}
}
fn default_meta() -> SessionMeta {
SessionMeta {
session_id: "test-session".into(),
title: None,
started_at: None,
model: "test-model".into(),
provider: "test-provider".into(),
project_root: "/tmp/project".into(),
}
}
#[test]
fn empty_messages_produces_header_only() {
let meta = SessionMeta {
title: Some("Test Session".into()),
..default_meta()
};
let out = render(&[], &meta, false);
assert!(out.contains("# Test Session"));
assert!(!out.contains("๐ง User"));
}
#[test]
fn user_message_renders_correctly() {
let msgs = vec![make_msg(Role::User, "hello koda")];
let out = render(&msgs, &default_meta(), false);
assert!(out.contains("๐ง User"));
assert!(out.contains("hello koda"));
}
#[test]
fn assistant_message_renders_correctly() {
let msgs = vec![make_msg(Role::Assistant, "I can help!")];
let out = render(&msgs, &default_meta(), false);
assert!(out.contains("๐ค Assistant"));
assert!(out.contains("I can help!"));
}
#[test]
fn system_messages_skipped() {
let msgs = vec![
make_msg(Role::System, "secret prompt"),
make_msg(Role::User, "hi"),
];
let out = render(&msgs, &default_meta(), false);
assert!(!out.contains("secret prompt"));
}
#[test]
fn tool_read_result_shown_as_code_block() {
let mut result_msg = make_msg(Role::Tool, "fn main() {}\n");
result_msg.tool_call_id = Some("call_1".into());
let mut assistant_msg = make_msg(Role::Assistant, "");
assistant_msg.tool_calls = Some(
serde_json::json!([{
"id": "call_1",
"function": { "name": "Read", "arguments": r#"{"file_path":"src/main.rs"}"# }
}])
.to_string(),
);
let msgs = vec![assistant_msg, result_msg];
let out = render(&msgs, &default_meta(), false);
assert!(out.contains("```"));
assert!(out.contains("fn main()"));
}
#[test]
fn bash_result_shows_summary_not_content() {
let mut result_msg = make_msg(Role::Tool, "line1\nline2\nline3");
result_msg.tool_call_id = Some("call_2".into());
let mut assistant_msg = make_msg(Role::Assistant, "");
assistant_msg.tool_calls = Some(
serde_json::json!([{
"id": "call_2",
"function": { "name": "Bash", "arguments": r#"{"command":"ls"}"# }
}])
.to_string(),
);
let msgs = vec![assistant_msg, result_msg];
let out = render(&msgs, &default_meta(), false);
assert!(!out.contains("line1"));
assert!(out.contains("3 line(s) of output"));
}
#[test]
fn thinking_content_renders_as_blockquote() {
let mut msg = make_msg(Role::Assistant, "The answer is 42.");
msg.thinking_content = Some("Let me think step by step: 6 x 7 = 42.".into());
let out = render(&[msg], &default_meta(), false);
assert!(
out.contains("The answer is 42."),
"response text must appear"
);
assert!(
out.contains("Thinking"),
"thinking block header must appear in transcript"
);
assert!(
out.contains("Let me think step by step"),
"thinking content must appear in transcript",
);
}
#[test]
fn verbose_header_includes_metadata() {
let meta = SessionMeta {
session_id: "sess-42".into(),
title: Some("Debug Session".into()),
started_at: Some("2026-04-14T12:00:00Z".into()),
model: "claude-sonnet-4-20250514".into(),
provider: "anthropic".into(),
project_root: "/home/user/project".into(),
};
let out = render(&[], &meta, true);
assert!(out.contains("sess-42"), "session ID in header");
assert!(out.contains("claude-sonnet-4-20250514"), "model in header");
assert!(out.contains("anthropic"), "provider in header");
assert!(out.contains("/home/user/project"), "project root in header");
}
#[test]
fn verbose_shows_token_counts() {
let mut msg = make_msg(Role::Assistant, "The answer.");
msg.prompt_tokens = Some(100);
msg.completion_tokens = Some(50);
msg.cache_read_tokens = Some(80);
let out = render(&[msg], &default_meta(), true);
assert!(out.contains("100 prompt"), "prompt tokens shown");
assert!(out.contains("50 completion"), "completion tokens shown");
assert!(out.contains("80 cache-read"), "cache-read tokens shown");
}
#[test]
fn summary_hides_token_counts() {
let mut msg = make_msg(Role::Assistant, "The answer.");
msg.prompt_tokens = Some(100);
msg.completion_tokens = Some(50);
let out = render(&[msg], &default_meta(), false);
assert!(!out.contains("100 prompt"));
}
#[test]
fn verbose_shows_timestamps() {
let mut msg = make_msg(Role::User, "hello");
msg.created_at = Some("2026-04-14T09:15:30Z".into());
let out = render(&[msg], &default_meta(), true);
assert!(out.contains("09:15:30"), "timestamp shown in verbose");
}
#[test]
fn summary_hides_timestamps() {
let mut msg = make_msg(Role::User, "hello");
msg.created_at = Some("2026-04-14T09:15:30Z".into());
let out = render(&[msg], &default_meta(), false);
assert!(!out.contains("09:15:30"), "timestamp hidden in summary");
}
#[test]
fn bash_result_shown_in_verbose_mode() {
let mut result_msg = make_msg(Role::Tool, "line1\nline2\nline3");
result_msg.tool_call_id = Some("call_3".into());
let mut assistant_msg = make_msg(Role::Assistant, "");
assistant_msg.tool_calls = Some(
serde_json::json!([{
"id": "call_3",
"function": { "name": "Bash", "arguments": r#"{"command":"ls"}"# }
}])
.to_string(),
);
let msgs = vec![assistant_msg, result_msg];
let out = render(&msgs, &default_meta(), true);
assert!(out.contains("line1"));
assert!(out.contains("line3"));
}
fn assistant_with_call(name: &str, args_json: &str) -> Message {
let mut m = make_msg(Role::Assistant, "");
m.tool_calls = Some(
serde_json::json!([{
"id": "c1",
"function": { "name": name, "arguments": args_json }
}])
.to_string(),
);
m
}
#[test]
fn read_path_emits_markdown_link_with_file_uri() {
let msg = assistant_with_call("Read", r#"{"file_path":"src/main.rs"}"#);
let meta = SessionMeta {
project_root: "/home/user/proj".into(),
..default_meta()
};
let out = render(&[msg], &meta, false);
assert!(
out.contains("[src/main.rs](file:///home/user/proj/src/main.rs)"),
"relative path should resolve under project_root, got:\n{out}"
);
}
#[test]
fn absolute_read_path_skips_root_join() {
let msg = assistant_with_call("Read", r#"{"file_path":"/etc/hosts"}"#);
let meta = SessionMeta {
project_root: "/home/user/proj".into(),
..default_meta()
};
let out = render(&[msg], &meta, false);
assert!(
out.contains("[/etc/hosts](file:///etc/hosts)"),
"absolute path should pass through, got:\n{out}"
);
}
#[test]
fn webfetch_url_becomes_self_link() {
let msg = assistant_with_call("WebFetch", r#"{"url":"https://example.com/x"}"#);
let out = render(&[msg], &default_meta(), false);
assert!(
out.contains("[https://example.com/x](https://example.com/x)"),
"URL should be a markdown self-link, got:\n{out}"
);
}
#[test]
fn bash_detail_stays_plain_codespan() {
let msg = assistant_with_call("Bash", r#"{"command":"git status"}"#);
let out = render(&[msg], &default_meta(), false);
assert!(out.contains("`git status`"), "got:\n{out}");
assert!(
!out.contains("](git status)"),
"bash should never be linked"
);
}
#[test]
fn grep_detail_stays_plain_codespan() {
let msg = assistant_with_call("Grep", r#"{"search_string":"TODO","directory":"src"}"#);
let out = render(&[msg], &default_meta(), false);
assert!(out.contains("`\"TODO\" in src`"), "got:\n{out}");
}
#[test]
fn kill_switch_disables_hyperlinks() {
let prev = std::env::var(HYPERLINK_KILL_SWITCH).ok();
unsafe { std::env::set_var(HYPERLINK_KILL_SWITCH, "off") };
let msg = assistant_with_call("Read", r#"{"file_path":"/x.rs"}"#);
let out = render(&[msg], &default_meta(), false);
unsafe {
match prev {
Some(v) => std::env::set_var(HYPERLINK_KILL_SWITCH, v),
None => std::env::remove_var(HYPERLINK_KILL_SWITCH),
}
}
assert!(out.contains("`/x.rs`"), "plain text expected, got:\n{out}");
assert!(!out.contains("file:///"), "link should be suppressed");
}
#[test]
fn dry_equivalence_with_tool_header_detail_text() {
use crate::tool_header::detail_text;
let cases: Vec<(&str, serde_json::Value, usize)> = vec![
("Read", serde_json::json!({"file_path": "a.rs"}), 80),
("Bash", serde_json::json!({"command": "echo hi"}), 80),
(
"Grep",
serde_json::json!({"search_string": "x", "directory": "."}),
80,
),
("Glob", serde_json::json!({"pattern": "**/*.rs"}), 80),
("List", serde_json::json!({"directory": "src"}), 80),
("WebFetch", serde_json::json!({"url": "https://x"}), 80),
];
for (name, args, bash) in cases {
let from_helper = detail_text(name, &args, bash);
let from_transcript = tool_detail_markdown(name, &args.to_string(), bash, "", false);
assert_eq!(
from_helper, from_transcript,
"transcript detail must match tool_header::detail_text for {name}"
);
}
}
#[test]
fn file_uri_percent_encodes_breaking_chars() {
let uri = file_uri("/My Files/[draft].md");
assert_eq!(uri, "file:///My%20Files/%5Bdraft%5D.md");
}
}