use std::collections::HashMap;
use koda_core::persistence::{Message, Role, SessionEvent};
use koda_core::tools::{ToolCatalog, ToolEffect};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use crate::tui_output::{BOLD, DIM, READ_CONTENT, TOOL_PREFIX, WRITE_CONTENT};
const TOOL_OUTPUT_PREVIEW_LINES: usize = 3;
pub fn render_history_messages(messages: &[Message]) -> Vec<Line<'static>> {
render_history(messages, &[])
}
pub fn render_history(messages: &[Message], events: &[SessionEvent]) -> Vec<Line<'static>> {
let mut events_by_parent: HashMap<&str, Vec<&SessionEvent>> = HashMap::new();
use koda_core::persistence::session_event_kind as sek;
for ev in events {
if ev.kind != sek::SUB_AGENT_EVENT {
continue;
}
if let Some(parent) = ev.parent_tool_call_id.as_deref() {
events_by_parent.entry(parent).or_default().push(ev);
}
}
let mut lines: Vec<Line<'static>> = Vec::new();
let mut tool_id_to_name: HashMap<String, String> = HashMap::new();
for msg in messages {
match msg.role {
Role::System => {
}
Role::User => {
render_user_message(&mut lines, msg);
}
Role::Assistant => {
if 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(), tool_call_field(&call, "name"))
{
tool_id_to_name.insert(id.to_string(), name.to_string());
}
}
}
render_assistant_message(&mut lines, 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("");
render_tool_result(&mut lines, msg, tool_name);
if tool_name == "InvokeAgent"
&& let Some(call_id) = msg.tool_call_id.as_deref()
&& let Some(child_events) = events_by_parent.get(call_id)
&& !child_events.is_empty()
{
render_sub_agent_activity(&mut lines, child_events);
}
}
}
}
if !lines.is_empty() {
lines.push(Line::default());
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"\u{2500}\u{2500}\u{2500} session resumed \u{2500}\u{2500}\u{2500}",
DIM,
),
]));
lines.push(Line::default());
}
lines
}
fn render_user_message(lines: &mut Vec<Line<'static>>, msg: &Message) {
lines.push(Line::default());
if let Some(ref content) = msg.content {
let mut iter = content.lines();
if let Some(first) = iter.next() {
lines.push(Line::from(vec![
Span::styled(
" \u{276f} ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(first.to_string(), BOLD),
]));
}
for rest in iter {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(rest.to_string()),
]));
}
}
}
fn render_assistant_message(lines: &mut Vec<Line<'static>>, msg: &Message) {
if msg.content.as_deref().is_none_or(|c| c.trim().is_empty())
&& msg.tool_calls.is_none()
&& msg.thinking_content.as_deref().is_none_or(str::is_empty)
{
return;
}
lines.push(Line::styled(" \u{2500}\u{2500}\u{2500}", DIM));
if let Some(ref thinking) = msg.thinking_content
&& !thinking.is_empty()
{
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("\u{1f4ad} Thinking", DIM),
]));
for line in thinking.lines() {
lines.push(Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(line.to_string(), DIM),
]));
}
}
if let Some(ref content) = msg.content
&& !content.is_empty()
{
for line in content.lines() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(line.to_string()),
]));
}
}
if let Some(ref tc_json) = msg.tool_calls {
render_tool_call_headers(lines, tc_json);
}
}
fn render_tool_call_headers(lines: &mut Vec<Line<'static>>, tc_json: &str) {
let calls: Vec<serde_json::Value> = match serde_json::from_str(tc_json) {
Ok(v) => v,
Err(_) => return,
};
for call in &calls {
let name = tool_call_field(call, "name").unwrap_or("unknown");
let args = tool_call_field(call, "arguments").unwrap_or("{}");
lines.push(crate::tool_header::build_header_line_from_str(
"", name, args,
));
}
}
fn tool_call_field<'a>(call: &'a serde_json::Value, field: &str) -> Option<&'a str> {
let canonical = match field {
"name" => "function_name",
"arguments" => "arguments",
_ => field,
};
call.get(canonical).and_then(|v| v.as_str()).or_else(|| {
call.get("function")
.and_then(|f| f.get(field))
.and_then(|v| v.as_str())
})
}
fn render_tool_result(lines: &mut Vec<Line<'static>>, msg: &Message, tool_name: &str) {
let content = msg.content.as_deref().unwrap_or("");
let total_lines = content.lines().count();
let content_style =
match ToolCatalog::default_static().classify_call(tool_name, &serde_json::Value::Null) {
ToolEffect::ReadOnly => READ_CONTENT,
_ => WRITE_CONTENT,
};
if total_lines == 0 {
lines.push(Line::from(vec![
Span::styled(" \u{2514} ", TOOL_PREFIX),
Span::styled("(empty)", DIM),
]));
return;
}
if total_lines <= TOOL_OUTPUT_PREVIEW_LINES {
for line in content.lines() {
lines.push(Line::from(vec![
Span::styled(" \u{2502} ", TOOL_PREFIX),
Span::styled(line.to_string(), content_style),
]));
}
} else {
for line in content.lines().take(TOOL_OUTPUT_PREVIEW_LINES) {
lines.push(Line::from(vec![
Span::styled(" \u{2502} ", TOOL_PREFIX),
Span::styled(line.to_string(), content_style),
]));
}
let hidden = total_lines - TOOL_OUTPUT_PREVIEW_LINES;
lines.push(Line::from(vec![
Span::styled(" \u{2514} ", TOOL_PREFIX),
Span::styled(format!("... {hidden} more line(s)"), DIM),
]));
}
}
fn render_sub_agent_activity(lines: &mut Vec<Line<'static>>, events: &[&SessionEvent]) {
lines.push(Line::from(vec![
Span::styled(" \u{2502} ", TOOL_PREFIX),
Span::styled(
"\u{250c}\u{2500}\u{2500} sub-agent activity \u{2500}\u{2500}".to_string(),
DIM,
),
]));
for ev in events {
lines.push(Line::from(vec![
Span::styled(" \u{2502} ", TOOL_PREFIX),
Span::styled(ev.payload.clone(), READ_CONTENT),
]));
}
lines.push(Line::from(vec![
Span::styled(" \u{2502} ", TOOL_PREFIX),
Span::styled("\u{2514}\u{2500}\u{2500}".to_string(), DIM),
]));
}
#[cfg(test)]
mod tests {
use super::*;
fn 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,
thinking_blocks: None,
created_at: None,
}
}
#[test]
fn test_empty_messages() {
let lines = render_history_messages(&[]);
assert!(lines.is_empty());
}
#[test]
fn test_user_message_rendering() {
let messages = vec![msg(Role::User, "hello world")];
let lines = render_history_messages(&messages);
assert!(lines.len() >= 2);
let prompt_line = &lines[1];
let text: String = prompt_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(text.contains("hello world"));
assert!(text.contains('\u{276f}'));
}
#[test]
fn test_assistant_message_rendering() {
let messages = vec![msg(Role::User, "hello"), msg(Role::Assistant, "Hi there!")];
let lines = render_history_messages(&messages);
let all_text: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(all_text.contains("Hi there!"));
assert!(all_text.contains("\u{2500}\u{2500}\u{2500}"));
}
#[test]
fn test_tool_result_short() {
let messages = vec![msg(Role::Tool, "line 1\nline 2")];
let lines = render_history_messages(&messages);
let all_text: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(all_text.contains("line 1"));
assert!(all_text.contains("line 2"));
assert!(!all_text.contains("more line"));
}
#[test]
fn test_tool_result_long_truncated() {
let long_output: String = (0..20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let messages = vec![msg(Role::Tool, &long_output)];
let lines = render_history_messages(&messages);
let all_text: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(all_text.contains("line 0"));
assert!(all_text.contains("more line"));
}
#[test]
fn test_system_messages_skipped() {
let messages = vec![
msg(Role::System, "You are a helpful assistant"),
msg(Role::User, "hello"),
];
let lines = render_history_messages(&messages);
let all_text: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(!all_text.contains("helpful assistant"));
}
#[test]
fn test_tool_detail_summary() {
let typed = crate::tool_header::build_header_line(
"",
"Grep",
&serde_json::json!({"search_string": "foo", "directory": "src"}),
);
let history = crate::tool_header::build_header_line_from_str(
"",
"Grep",
r#"{"search_string": "foo", "directory": "src"}"#,
);
let typed_text: String = typed.spans.iter().map(|s| s.content.as_ref()).collect();
let history_text: String = history.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(typed_text, history_text);
assert!(history_text.contains("\"foo\""));
assert!(history_text.contains("src"));
}
#[test]
fn test_session_resumed_separator() {
let messages = vec![msg(Role::User, "hello")];
let lines = render_history_messages(&messages);
let all_text: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(all_text.contains("session resumed"));
}
#[test]
fn render_uses_canonical_tool_call_persistence_shape() {
use koda_core::providers::ToolCall;
let tcs = vec![ToolCall {
id: "call_abc".into(),
function_name: "WebFetch".into(),
arguments: r#"{"url":"https://example.com"}"#.into(),
thought_signature: None,
}];
let tc_json = serde_json::to_string(&tcs).expect("ToolCall must serialize");
let mut assistant = msg(Role::Assistant, "");
assistant.tool_calls = Some(tc_json);
let lines = render_history_messages(&[assistant]);
let all: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
all.contains("WebFetch"),
"tool name must render from canonical shape; got: {all}"
);
assert!(
!all.contains("unknown"),
"the '\u{25cf} unknown' bug from #1324 R7 must not regress; got: {all}"
);
}
#[test]
fn render_also_accepts_legacy_openai_wire_shape() {
let calls = serde_json::json!([{
"id": "call_xyz",
"function": {"name": "Grep", "arguments": "{}"}
}]);
let mut assistant = msg(Role::Assistant, "");
assistant.tool_calls = Some(calls.to_string());
let lines = render_history_messages(&[assistant]);
let all: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
all.contains("Grep"),
"OpenAI shape must also work; got: {all}"
);
assert!(!all.contains("unknown"), "got: {all}");
}
fn invoke_agent_pair(call_id: &str, agent_name: &str) -> (Message, Message, String) {
let assistant = Message {
tool_calls: Some(format!(
r#"[{{"id":"{call_id}","function":{{"name":"InvokeAgent","arguments":"{{\"agent\":\"{agent_name}\"}}"}}}}]"#
)),
..msg(Role::Assistant, "")
};
let tool_result = Message {
tool_call_id: Some(call_id.to_string()),
..msg(
Role::Tool,
&format!("Background agent '{agent_name}' started (agent:1)."),
)
};
(assistant, tool_result, call_id.to_string())
}
fn sub_agent_event(parent_call_id: &str, payload: &str) -> SessionEvent {
use koda_core::persistence::session_event_kind as sek;
SessionEvent {
id: 0,
session_id: "test".into(),
kind: sek::SUB_AGENT_EVENT.to_string(),
payload: payload.to_string(),
parent_tool_call_id: Some(parent_call_id.to_string()),
created_at: None,
}
}
fn collect_text(lines: &[Line<'static>]) -> String {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn sub_agent_events_fold_under_invoke_agent_result() {
let (assistant, tool_result, call_id) = invoke_agent_pair("call_1", "explore");
let messages = vec![assistant, tool_result];
let events = vec![
sub_agent_event(&call_id, " \u{1f527} List"),
sub_agent_event(&call_id, " \u{1f527} Read koda-core/src/lib.rs"),
sub_agent_event(&call_id, " \u{1f527} Grep"),
];
let lines = render_history(&messages, &events);
let all = collect_text(&lines);
assert!(
all.contains("sub-agent activity"),
"missing activity block header: {all}"
);
assert!(
all.contains("\u{250c}\u{2500}\u{2500}"),
"missing opening corner: {all}"
);
assert!(
all.contains("\u{2514}\u{2500}\u{2500}"),
"missing closing corner: {all}"
);
assert!(all.contains("\u{1f527} List"), "missing List event: {all}");
assert!(
all.contains("\u{1f527} Read koda-core/src/lib.rs"),
"missing Read event: {all}"
);
assert!(all.contains("\u{1f527} Grep"), "missing Grep event: {all}");
}
#[test]
fn no_events_means_no_activity_block() {
let (assistant, tool_result, _) = invoke_agent_pair("call_1", "explore");
let messages = vec![assistant, tool_result];
let lines = render_history(&messages, &[]);
let all = collect_text(&lines);
assert!(
!all.contains("sub-agent activity"),
"unexpected activity block: {all}"
);
}
#[test]
fn events_only_fold_under_their_own_invoke_agent_call() {
let (a1, t1, _) = invoke_agent_pair("call_1", "explore");
let (a2, t2, _) = invoke_agent_pair("call_2", "verify");
let messages = vec![a1, t1, a2, t2];
let events = vec![sub_agent_event("call_1", " \u{1f527} ExploreOnly")];
let lines = render_history(&messages, &events);
let all = collect_text(&lines);
let occurrences = all.matches("ExploreOnly").count();
assert_eq!(occurrences, 1, "event should fold once only: {all}");
let explore_idx = all.find("ExploreOnly").expect("event present");
let second_started_idx = all
.match_indices("started (agent:1)")
.nth(1)
.map(|(i, _)| i)
.expect("two spawn results expected");
assert!(
explore_idx < second_started_idx,
"event should fold under call_1, not call_2: {all}"
);
}
#[test]
fn non_invoke_agent_tool_results_are_unaffected() {
let assistant = Message {
tool_calls: Some(
r#"[{"id":"call_1","function":{"name":"Read","arguments":"{}"}}]"#.to_string(),
),
..msg(Role::Assistant, "")
};
let tool_result = Message {
tool_call_id: Some("call_1".to_string()),
..msg(Role::Tool, "file contents...")
};
let messages = vec![assistant, tool_result];
let events = vec![sub_agent_event("call_1", " \u{1f527} Should not appear")];
let lines = render_history(&messages, &events);
let all = collect_text(&lines);
assert!(
!all.contains("sub-agent activity"),
"non-InvokeAgent tool must not fold events: {all}"
);
assert!(
!all.contains("Should not appear"),
"event payload must not leak: {all}"
);
}
#[test]
fn non_sub_agent_event_kinds_are_ignored() {
use koda_core::persistence::session_event_kind as sek;
let (assistant, tool_result, call_id) = invoke_agent_pair("call_1", "explore");
let messages = vec![assistant, tool_result];
let events = vec![
SessionEvent {
id: 0,
session_id: "test".into(),
kind: sek::INFO.to_string(),
payload: "top-level info, not folded".to_string(),
parent_tool_call_id: Some(call_id.clone()),
created_at: None,
},
sub_agent_event(&call_id, " \u{1f527} RealEvent"),
];
let lines = render_history(&messages, &events);
let all = collect_text(&lines);
assert!(
all.contains("RealEvent"),
"sub_agent_event must fold: {all}"
);
assert!(
!all.contains("top-level info, not folded"),
"info events must not leak into activity block: {all}"
);
}
#[test]
fn render_history_messages_remains_back_compat() {
let (assistant, tool_result, _) = invoke_agent_pair("call_1", "explore");
let messages = vec![assistant, tool_result];
let via_shim = render_history_messages(&messages);
let via_new = render_history(&messages, &[]);
let shim_text = collect_text(&via_shim);
let new_text = collect_text(&via_new);
assert_eq!(shim_text, new_text, "shim and new fn must agree");
}
#[test]
fn an_empty_reply_draws_nothing() {
let empty = Message {
content: None,
..msg(Role::Assistant, "")
};
let lines = render_history_messages(&[empty]);
assert_eq!(collect_text(&lines).trim(), "");
}
}