use std::collections::HashMap;
use koda_core::persistence::{Message, Role};
use koda_core::tools::{ToolEffect, classify_tool};
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>> {
let mut lines: Vec<Line<'static>> = Vec::new();
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 => {
render_user_message(&mut lines, msg);
}
Role::Assistant => {
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 !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) {
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 = call["function"]["name"].as_str().unwrap_or("unknown");
let args = call["function"]["arguments"].as_str().unwrap_or("{}");
let detail = tool_detail_summary(name, args);
let dot_color = tool_dot_color(name);
lines.push(Line::from(vec![
Span::styled("\u{25cf} ", Style::default().fg(dot_color)),
Span::styled(name.to_string(), BOLD),
Span::raw(" "),
Span::styled(detail, DIM),
]));
}
}
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 classify_tool(tool_name) {
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 tool_detail_summary(name: &str, args_json: &str) -> String {
let args: serde_json::Value =
serde_json::from_str(args_json).unwrap_or(serde_json::Value::Null);
match name {
"Read" | "Write" | "Edit" | "Delete" => {
args["file_path"].as_str().unwrap_or("").to_string()
}
"Bash" => {
let cmd = args["command"].as_str().unwrap_or("");
if cmd.len() > 60 {
format!("{}...", &cmd[..57])
} else {
cmd.to_string()
}
}
"Grep" => {
let pattern = args["search_string"]
.as_str()
.or_else(|| args["pattern"].as_str())
.unwrap_or("");
let dir = args["directory"].as_str().unwrap_or(".");
format!("{pattern} in {dir}")
}
"List" => args["directory"]
.as_str()
.or_else(|| args["path"].as_str())
.unwrap_or(".")
.to_string(),
"WebFetch" => args["url"].as_str().unwrap_or("").to_string(),
_ => {
if let Some(obj) = args.as_object() {
for (_, v) in obj.iter().take(1) {
if let Some(s) = v.as_str() {
let truncated = if s.len() > 60 {
format!("{}...", &s[..57])
} else {
s.to_string()
};
return truncated;
}
}
}
String::new()
}
}
}
fn tool_dot_color(name: &str) -> Color {
match name {
"Read" | "Grep" | "List" | "Glob" => Color::Cyan,
"Write" | "Edit" => Color::Yellow,
"Delete" => Color::Red,
"Bash" => Color::Green,
"WebFetch" => Color::Blue,
_ => Color::Magenta,
}
}
#[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,
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() {
assert_eq!(
tool_detail_summary("Read", r#"{"file_path": "src/main.rs"}"#),
"src/main.rs"
);
assert_eq!(
tool_detail_summary("Bash", r#"{"command": "ls -la"}"#),
"ls -la"
);
assert_eq!(
tool_detail_summary("Grep", r#"{"search_string": "foo", "directory": "src"}"#),
"foo in 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"));
}
}