use crate::repl::ReplAction;
use crate::scroll_buffer::ScrollBuffer;
use crate::tui_output;
use crate::tui_render::TuiRenderer;
use koda_core::persistence::Persistence;
use koda_core::trust;
use crate::mouse_select::copy_to_clipboard;
use crate::transcript;
use koda_core::agent::KodaAgent;
use koda_core::config::KodaConfig;
use koda_core::providers::LlmProvider;
use koda_core::session::KodaSession;
use ratatui::text::{Line, Span};
use std::sync::Arc;
use tokio::sync::RwLock;
pub enum SlashAction {
Continue,
Quit,
OpenKeyMenu,
}
use tui_output::{BOLD, CYAN, DIM};
#[allow(clippy::too_many_arguments, unused_variables)]
pub async fn handle_slash_command(
buffer: &mut ScrollBuffer,
input: &str,
config: &mut KodaConfig,
provider: &Arc<RwLock<Box<dyn LlmProvider>>>,
session: &mut KodaSession,
shared_mode: &trust::SharedTrustMode,
renderer: &mut TuiRenderer,
project_root: &std::path::Path,
agent: &Arc<KodaAgent>,
pending_command: &mut Option<String>,
menu: &mut crate::tui_types::MenuContent,
) -> SlashAction {
match crate::repl::handle_command(input, config, provider).await {
ReplAction::Quit => SlashAction::Quit,
ReplAction::SwitchModel(model) => {
if let Some(resolved) = koda_core::model_alias::resolve(&model) {
let ptype = resolved.provider;
if ptype.requires_api_key() && !koda_core::runtime_env::is_set(ptype.env_key_name())
{
tui_output::err_msg(
buffer,
format!("{} not set. Run /key to configure.", ptype.env_key_name()),
);
} else if resolved.needs_auto_detect() {
tui_output::warn_msg(
buffer,
"Use /model to pick local models interactively.".to_string(),
);
} else {
config.provider_type = ptype;
config.base_url = ptype.default_base_url().to_string();
config.model = resolved.model_id.to_string();
config.model_settings.model = config.model.clone();
config.recalculate_model_derived();
*provider.write().await = koda_core::providers::create_provider(config);
{
let prov = provider.read().await;
config.query_and_apply_capabilities(prov.as_ref()).await;
}
crate::tui_wizards::save_provider(config, &session.db).await;
tui_output::ok_msg(
buffer,
format!(
"Model: {} ({}, {})",
resolved.alias, resolved.model_id, ptype
),
);
}
} else {
config.model = model.clone();
config.model_settings.model = model.clone();
config.recalculate_model_derived();
{
let prov = provider.read().await;
config.query_and_apply_capabilities(prov.as_ref()).await;
}
crate::tui_wizards::save_provider(config, &session.db).await;
tui_output::ok_msg(buffer, format!("Model set to: {model}"));
}
SlashAction::Continue
}
ReplAction::PickModel => SlashAction::Continue,
ReplAction::SetupProvider(_ptype, _base_url) => SlashAction::Continue,
ReplAction::PickProvider => SlashAction::Continue,
ReplAction::ShowHelp => {
show_help(buffer);
SlashAction::Continue
}
ReplAction::Undo => {
match agent.tools.undo.lock() {
Ok(mut undo) => match undo.undo() {
Some(summary) => tui_output::ok_msg(buffer, summary),
None => tui_output::warn_msg(buffer, "Nothing to undo.".to_string()),
},
Err(e) => tui_output::err_msg(buffer, format!("Undo error: {e}")),
}
SlashAction::Continue
}
ReplAction::ListSessions => SlashAction::Continue,
ReplAction::DeleteSession(ref id) => {
handle_delete_session(buffer, session, id, project_root).await;
SlashAction::Continue
}
ReplAction::ResumeSession(ref id) => {
handle_resume_session(buffer, session, id, project_root, shared_mode).await;
SlashAction::Continue
}
ReplAction::InjectPrompt(prompt) => {
*pending_command = Some(prompt);
SlashAction::Continue
}
ReplAction::Compact => {
crate::tui_wizards::handle_compact(buffer, session, config, provider).await;
SlashAction::Continue
}
ReplAction::Purge(ref age_filter) => {
crate::tui_wizards::handle_purge(buffer, session, age_filter.as_deref(), menu).await;
SlashAction::Continue
}
ReplAction::Expand(n) => {
handle_expand(buffer, renderer, n);
SlashAction::Continue
}
ReplAction::Verbose(v) => {
renderer.verbose = match v {
Some(val) => val,
None => !renderer.verbose,
};
let state = if renderer.verbose { "on" } else { "off" };
tui_output::emit_line(
buffer,
Line::styled(format!(" Verbose tool output: {state}"), CYAN),
);
SlashAction::Continue
}
ReplAction::ListAgents => {
crate::tui_wizards::handle_list_agents(buffer, project_root);
SlashAction::Continue
}
ReplAction::ShowDiff => {
crate::tui_wizards::handle_diff(buffer);
SlashAction::Continue
}
ReplAction::MemoryCommand(ref arg) => {
crate::tui_wizards::handle_memory(buffer, arg.as_deref(), project_root);
SlashAction::Continue
}
ReplAction::ListSkills(ref query) => {
crate::tui_wizards::handle_list_skills(buffer, query.as_deref(), &agent.tools);
SlashAction::Continue
}
ReplAction::ManageKeys => {
crate::tui_wizards::handle_keys(buffer);
SlashAction::OpenKeyMenu
}
ReplAction::CopyResponse(n) => {
handle_copy_response(buffer, session, n).await;
SlashAction::Continue
}
ReplAction::Export { ref dest, summary } => {
handle_export(
buffer,
session,
dest.as_deref(),
summary,
config,
project_root,
)
.await;
SlashAction::Continue
}
ReplAction::McpList => {
crate::tui_mcp::handle_mcp_list(buffer, session, agent).await;
SlashAction::Continue
}
ReplAction::McpAdd {
ref name,
ref command,
ref args,
} => {
crate::tui_mcp::handle_mcp_add(
buffer,
session,
agent,
name.clone(),
command.clone(),
args.clone(),
)
.await;
SlashAction::Continue
}
ReplAction::McpRemove { ref name } => {
crate::tui_mcp::handle_mcp_remove(buffer, session, agent, name.clone()).await;
SlashAction::Continue
}
ReplAction::McpAddHttp {
ref name,
ref url,
ref bearer_token,
} => {
crate::tui_mcp::handle_mcp_add_http(
buffer,
session,
agent,
name.clone(),
url.clone(),
bearer_token.clone(),
)
.await;
SlashAction::Continue
}
ReplAction::McpReconnect { ref name } => {
crate::tui_mcp::handle_mcp_reconnect(buffer, agent, name.clone()).await;
SlashAction::Continue
}
ReplAction::Handled => SlashAction::Continue,
ReplAction::NotACommand => SlashAction::Continue,
}
}
async fn handle_delete_session(
buffer: &mut ScrollBuffer,
session: &KodaSession,
id: &str,
project_root: &std::path::Path,
) {
if id == session.id {
tui_output::err_msg(buffer, "Cannot delete the current session.".into());
} else {
match session.db.list_sessions(100, project_root).await {
Ok(sessions) => {
let matches: Vec<_> = sessions.iter().filter(|s| s.id.starts_with(id)).collect();
match matches.len() {
0 => tui_output::err_msg(buffer, format!("No session found matching '{id}'.")),
1 => {
let full_id = &matches[0].id;
match session.db.delete_session(full_id).await {
Ok(true) => tui_output::ok_msg(
buffer,
format!("Deleted session {}", &full_id[..8]),
),
Ok(false) => tui_output::err_msg(buffer, "Session not found.".into()),
Err(e) => tui_output::err_msg(buffer, format!("Error: {e}")),
}
}
n => tui_output::err_msg(
buffer,
format!("Ambiguous: '{id}' matches {n} sessions. Be more specific."),
),
}
}
Err(e) => tui_output::err_msg(buffer, format!("Error: {e}")),
}
}
}
async fn handle_resume_session(
buffer: &mut ScrollBuffer,
session: &mut KodaSession,
id: &str,
project_root: &std::path::Path,
shared_mode: &trust::SharedTrustMode,
) {
use tui_output::GREEN;
if session.id.starts_with(id) {
tui_output::dim_msg(buffer, "Already in this session.".into());
} else {
match session.db.list_sessions(100, project_root).await {
Ok(sessions) => {
let matches: Vec<_> = sessions.iter().filter(|s| s.id.starts_with(id)).collect();
match matches.len() {
0 => tui_output::err_msg(buffer, format!("No session found matching '{id}'.")),
1 => {
let target = &matches[0];
session.id = target.id.clone();
session.title_set = true;
let short_id = target.id[..8].to_string();
let title_part = target
.title
.as_deref()
.map(|t| format!(" — {}", t.chars().take(40).collect::<String>()))
.unwrap_or_default();
let detail = format!(
"{title_part} {} {} msgs",
target.created_at, target.message_count
);
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2713} ", GREEN),
Span::raw("Resumed session "),
Span::styled(short_id, CYAN),
Span::styled(detail, DIM),
]),
);
if let Ok(Some(mode_str)) = session.db.get_session_mode(&session.id).await
&& let Some(m) = trust::TrustMode::parse(&mode_str)
{
trust::set_trust(shared_mode, m);
}
let idle_secs = session
.db
.get_session_idle_secs(&session.id)
.await
.ok()
.flatten();
if let Ok(msgs) = session.db.load_context(&session.id).await {
use koda_core::persistence::Role;
let user_msgs = msgs.iter().filter(|m| m.role == Role::User).count();
let tool_calls = msgs.iter().filter(|m| m.role == Role::Tool).count();
let total_tokens: i64 = msgs
.iter()
.map(|m| {
m.prompt_tokens.unwrap_or(0) + m.completion_tokens.unwrap_or(0)
})
.sum();
buffer.push_lines(tui_output::away_summary_banner(
idle_secs,
None, user_msgs,
tool_calls,
total_tokens,
));
if let Some(kind) = koda_core::db::queries::detect_interruption(&msgs) {
buffer.push_lines(tui_output::interrupted_turn_banner(&kind));
}
}
}
n => tui_output::err_msg(
buffer,
format!("Ambiguous: '{id}' matches {n} sessions. Be more specific."),
),
}
}
Err(e) => tui_output::err_msg(buffer, format!("Error: {e}")),
}
}
}
fn show_help(buffer: &mut ScrollBuffer) {
use crate::completer::SLASH_COMMANDS;
let max_cmd_len = SLASH_COMMANDS
.iter()
.map(|(cmd, _, hint)| cmd.len() + hint.map(|h| h.len() + 1).unwrap_or(0))
.max()
.unwrap_or(10);
let col = max_cmd_len.max(16) + 4;
tui_output::blank(buffer);
tui_output::emit_line(buffer, Line::styled(" Commands", BOLD));
tui_output::blank(buffer);
for &(cmd, desc, arg_hint) in SLASH_COMMANDS {
let label = match arg_hint {
Some(hint) => format!("{cmd} {hint}"),
None => cmd.to_string(),
};
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(format!(" {label:<col$}"), CYAN),
Span::styled(desc, DIM),
]),
);
}
tui_output::blank(buffer);
tui_output::emit_line(buffer, Line::styled(" Input", BOLD));
tui_output::blank(buffer);
let input_keys: &[(&str, &str)] = &[
("Enter", "Send message"),
("Alt+Enter", "Insert newline (multi-line input)"),
("@file.rs", "Attach file context to your message"),
("@image.png", "Attach image (vision-capable models)"),
(
"drag & drop",
"Drop an image into the terminal to attach it",
),
("↑ / ↓", "Cycle through input history"),
("Ctrl+R", "Reverse history search"),
("Tab", "Autocomplete @file path or /command"),
("Shift+Tab", "Cycle approval mode (auto ↔ confirm)"),
];
for (key, desc) in input_keys {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(format!(" {key:<col$}"), CYAN),
Span::styled(*desc, DIM),
]),
);
}
tui_output::blank(buffer);
tui_output::emit_line(buffer, Line::styled(" Navigation", BOLD));
tui_output::blank(buffer);
let nav_keys: &[(&str, &str)] = &[
("PgUp / PgDn", "Scroll history up / down one page"),
("Home", "Jump to top of history"),
("End", "Jump to bottom (latest output)"),
("Mouse scroll", "Scroll history"),
];
for (key, desc) in nav_keys {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(format!(" {key:<col$}"), CYAN),
Span::styled(*desc, DIM),
]),
);
}
tui_output::blank(buffer);
tui_output::emit_line(buffer, Line::styled(" Session control", BOLD));
tui_output::blank(buffer);
let session_keys: &[(&str, &str)] = &[
("Esc / Ctrl+C", "Cancel running inference"),
("Ctrl+D", "Quit koda"),
];
for (key, desc) in session_keys {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(format!(" {key:<col$}"), CYAN),
Span::styled(*desc, DIM),
]),
);
}
tui_output::blank(buffer);
}
fn handle_expand(buffer: &mut ScrollBuffer, renderer: &TuiRenderer, n: usize) {
match renderer.tool_history.get(n) {
Some(record) => {
tui_output::blank(buffer);
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(format!(" \u{1f50d} Expand: {}", record.tool_name), BOLD),
Span::styled(format!(" ({} lines)", record.output.lines().count()), DIM),
]),
);
for line in record.output.lines() {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::raw(line.to_string()),
]),
);
}
tui_output::blank(buffer);
}
None => {
let total = renderer.tool_history.len();
if total == 0 {
tui_output::dim_msg(buffer, "No tool outputs recorded yet.".into());
} else {
tui_output::warn_msg(
buffer,
format!(
"No tool output #{n}. Have {total} recorded (use /expand 1\u{2013}{total})."
),
);
}
}
}
}
async fn handle_copy_response(
buffer: &mut ScrollBuffer,
session: &koda_core::session::KodaSession,
n: usize,
) {
let messages = match session.db.load_all_messages(&session.id).await {
Ok(msgs) => msgs,
Err(e) => {
tui_output::err_msg(buffer, format!("Could not load messages: {e}"));
return;
}
};
let responses: Vec<&str> = messages
.iter()
.filter_map(|m| {
use koda_core::persistence::Role;
if m.role == Role::Assistant {
m.content.as_deref().filter(|c| !c.trim().is_empty())
} else {
None
}
})
.collect();
let total = responses.len();
if total == 0 {
tui_output::dim_msg(buffer, "No assistant responses to copy.".into());
return;
}
if n > total {
tui_output::err_msg(
buffer,
format!(
"Only {total} response{} in this session.",
if total == 1 { "" } else { "s" }
),
);
return;
}
let content = responses[total - n];
let label = if n == 1 {
"last response".to_string()
} else {
format!("response #{n} from end")
};
match copy_to_clipboard(content) {
Ok(msg) => {
let preview: String = content.chars().take(60).collect();
let preview = preview.replace('\n', " ");
tui_output::ok_msg(buffer, format!("Copied {label} {msg} — {preview}\u{2026}"));
}
Err(e) => tui_output::err_msg(buffer, e),
}
}
async fn handle_export(
buffer: &mut ScrollBuffer,
session: &koda_core::session::KodaSession,
dest: Option<&str>,
summary: bool,
config: &KodaConfig,
project_root: &std::path::Path,
) {
let messages = match session.db.load_all_messages(&session.id).await {
Ok(msgs) => msgs,
Err(e) => {
tui_output::err_msg(buffer, format!("Could not load messages: {e}"));
return;
}
};
let title_storage;
let started_storage;
let (session_title, started_at): (Option<&str>, Option<&str>) = match session
.db
.list_sessions(200, std::path::Path::new("/"))
.await
{
Ok(sessions) => {
let found = sessions.into_iter().find(|s| s.id == session.id);
title_storage = found.as_ref().and_then(|s| s.title.clone());
started_storage = found.map(|s| s.created_at);
(title_storage.as_deref(), started_storage.as_deref())
}
Err(_) => (None, None),
};
let meta = transcript::SessionMeta {
session_id: session.id.clone(),
title: session_title.map(|s| s.to_string()),
started_at: started_at.map(|s| s.to_string()),
model: config.model.clone(),
provider: session.provider.provider_name().to_string(),
project_root: project_root.display().to_string(),
};
let verbose = !summary;
let md = transcript::render(&messages, &meta, verbose);
let path_owned;
let path: &str = match dest {
Some(p) => match validate_export_dest(p) {
Ok(()) => p,
Err(msg) => {
tui_output::err_msg(buffer, msg.into());
return;
}
},
None => {
path_owned = export_default_filename(&messages);
&path_owned
}
};
match write_export_file(std::path::Path::new(path), &md) {
Ok(lines) => {
tui_output::ok_msg(buffer, format!("Saved {lines} lines \u{2192} {path}"));
}
Err(e) => {
tui_output::err_msg(buffer, format!("Could not write {path}: {e}"));
}
}
}
fn validate_export_dest(dest: &str) -> Result<(), &'static str> {
let dest_path = std::path::Path::new(dest);
if dest_path.is_absolute() || dest.contains("..") {
return Err("Export path must be relative to the current directory \
(no absolute paths or \"..\" traversal).");
}
Ok(())
}
fn write_export_file(path: &std::path::Path, content: &str) -> std::io::Result<usize> {
std::fs::write(path, content)?;
Ok(content.lines().count())
}
fn export_default_filename(messages: &[koda_core::persistence::Message]) -> String {
let dt = crate::util::utc_now();
let ts = format!(
"{:04}{:02}{:02}-{:02}{:02}{:02}",
dt.year(),
dt.month() as u8,
dt.day(),
dt.hour(),
dt.minute(),
dt.second(),
);
let slug: String = messages
.iter()
.find(|m| {
use koda_core::persistence::Role;
m.role == Role::User
})
.and_then(|m| m.content.as_deref())
.map(|c| {
c.lines()
.next()
.unwrap_or("")
.trim()
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.take(50)
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
.to_lowercase()
})
.filter(|s| !s.is_empty())
.map(|s| {
let truncated: String = s.chars().take(40).collect();
truncated.trim_end_matches('-').to_string()
})
.unwrap_or_default();
if slug.is_empty() {
format!("koda-{ts}.md")
} else {
format!("koda-{ts}-{slug}.md")
}
}
#[cfg(test)]
mod tests {
use super::{export_default_filename, validate_export_dest, write_export_file};
use koda_core::persistence::{Message, Role};
fn user_msg(content: &str) -> Message {
Message {
id: 0,
session_id: "test".into(),
role: Role::User,
content: Some(content.to_string()),
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,
}
}
#[test]
fn filename_format_no_messages() {
let name = export_default_filename(&[]);
assert!(name.starts_with("koda-"), "got: {name}");
assert!(name.ends_with(".md"), "got: {name}");
}
#[test]
fn filename_includes_slug_from_first_user_msg() {
let msgs = vec![user_msg("Refactor the auth module")];
let name = export_default_filename(&msgs);
assert!(name.contains("refactor"), "got: {name}");
assert!(name.contains("auth"), "got: {name}");
assert!(name.ends_with(".md"), "got: {name}");
}
#[test]
fn filename_slug_is_lowercase_hyphenated() {
let msgs = vec![user_msg("Fix Bug In Parser")];
let name = export_default_filename(&msgs);
assert!(name.contains("fix-bug-in-parser"), "got: {name}");
}
#[test]
fn filename_slug_capped_at_40_chars() {
let long = "a".repeat(200);
let msgs = vec![user_msg(&long)];
let name = export_default_filename(&msgs);
let slug_part = name
.trim_start_matches("koda-")
.split('-')
.skip(2) .collect::<Vec<_>>()
.join("-");
let slug_no_ext = slug_part.trim_end_matches(".md");
assert!(
slug_no_ext.len() <= 40,
"slug too long ({} chars): {slug_no_ext}",
slug_no_ext.len()
);
}
#[test]
fn validate_export_dest_accepts_simple_filename() {
assert!(validate_export_dest("transcript.md").is_ok());
assert!(validate_export_dest("koda-export.md").is_ok());
}
#[test]
fn validate_export_dest_accepts_relative_subdir_path() {
assert!(validate_export_dest("exports/today.md").is_ok());
assert!(validate_export_dest("./output.md").is_ok());
}
#[test]
fn validate_export_dest_rejects_absolute_path() {
let err = validate_export_dest("/tmp/leak.md").unwrap_err();
assert!(
err.contains("relative"),
"error must mention 'relative': {err}"
);
}
#[cfg(target_os = "windows")]
#[test]
fn validate_export_dest_rejects_windows_drive_path() {
assert!(validate_export_dest(r"C:\foo\bar.md").is_err());
}
#[test]
fn validate_export_dest_rejects_parent_traversal() {
for p in ["../escape.md", "foo/../bar.md", "../../../etc/passwd"] {
let err = validate_export_dest(p).unwrap_err();
assert!(
err.contains("\"..\"") || err.contains("traversal"),
"error for {p:?} must mention traversal: {err}"
);
}
}
#[test]
fn validate_export_dest_rejects_any_double_dot_substring() {
assert!(
validate_export_dest("v1..v2.md").is_err(),
"current behavior: substring check is intentionally strict"
);
}
#[test]
fn write_export_file_creates_file_with_content() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("out.md");
let content = "# Header\n\nbody line\nfinal line\n";
let lines = write_export_file(&path, content).expect("write");
assert!(path.exists(), "file must exist on disk");
let read_back = std::fs::read_to_string(&path).expect("read");
assert_eq!(read_back, content, "file contents must match input");
assert_eq!(lines, 4, "line count must match content.lines() count");
}
#[test]
fn write_export_file_returns_zero_for_empty_content() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("empty.md");
let lines = write_export_file(&path, "").expect("write empty");
assert_eq!(lines, 0);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
}
#[test]
fn write_export_file_overwrites_existing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("clobber.md");
std::fs::write(&path, "OLD CONTENT THAT MUST BE GONE").unwrap();
let new_content = "new line one\nnew line two\n";
let lines = write_export_file(&path, new_content).expect("overwrite");
assert_eq!(lines, 2);
assert_eq!(std::fs::read_to_string(&path).unwrap(), new_content);
}
#[test]
fn write_export_file_fails_when_parent_dir_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("nope/does-not-exist/out.md");
let err =
write_export_file(&path, "x").expect_err("writing to nonexistent parent must fail");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
use crate::transcript;
fn meta_for_test() -> transcript::SessionMeta {
transcript::SessionMeta {
session_id: "sess-895".into(),
title: Some("Export Coverage".into()),
started_at: Some("2026-04-17T19:00:00Z".into()),
model: "claude-sonnet-4".into(),
provider: "anthropic".into(),
project_root: "/tmp/proj".into(),
}
}
fn assistant_with_tool_call(text: &str, tool_call_id: &str, tool_name: &str) -> Message {
let mut msg = user_msg(text);
msg.role = Role::Assistant;
msg.tool_calls = Some(
serde_json::json!([{
"id": tool_call_id,
"function": { "name": tool_name, "arguments": r#"{"file_path":"src/main.rs"}"# }
}])
.to_string(),
);
msg
}
fn tool_result(call_id: &str, content: &str) -> Message {
let mut m = user_msg(content);
m.role = Role::Tool;
m.tool_call_id = Some(call_id.into());
m
}
#[test]
fn end_to_end_verbose_export_includes_tool_output_in_file() {
let msgs = vec![
user_msg("please read main.rs"),
assistant_with_tool_call("", "call_v", "Read"),
tool_result("call_v", "fn main() { println!(\"hi\"); }"),
];
let md = transcript::render(&msgs, &meta_for_test(), true);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("verbose.md");
let lines = write_export_file(&path, &md).expect("write");
let on_disk = std::fs::read_to_string(&path).expect("read");
assert!(
on_disk.contains("fn main()"),
"verbose export file must contain Read tool output"
);
assert!(
on_disk.contains("sess-895"),
"verbose export must include session metadata header"
);
assert_eq!(
lines,
md.lines().count(),
"reported line count matches rendered md"
);
}
#[test]
fn end_to_end_summary_export_omits_bash_output_from_file() {
let msgs = vec![
user_msg("list files please"),
{
let mut m = assistant_with_tool_call("", "call_s", "Bash");
m.tool_calls = Some(
serde_json::json!([{
"id": "call_s",
"function": { "name": "Bash", "arguments": r#"{"command":"ls"}"# }
}])
.to_string(),
);
m
},
tool_result("call_s", "alpha.txt\nbeta.txt\ngamma.txt"),
];
let md = transcript::render(&msgs, &meta_for_test(), false);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("summary.md");
write_export_file(&path, &md).expect("write");
let on_disk = std::fs::read_to_string(&path).expect("read");
assert!(
!on_disk.contains("alpha.txt"),
"summary export must NOT include raw Bash output. got:\n{on_disk}"
);
assert!(
on_disk.contains("line(s) of output"),
"summary export must include the placeholder summary. got:\n{on_disk}"
);
}
}