use crate::message::{ContextListing, ConversationMessage, MessageKind, SearchHit};
use crate::text;
use serde_json::Value;
use std::fmt::Write;
const DISPLAY_WIDTH: usize = 80;
pub fn message_text(msg: &ConversationMessage, include_thinking: bool) -> String {
let raw = match &msg.kind {
MessageKind::TextContent(tc) => tc.text.clone(),
MessageKind::AssistantResponse(ar) => {
let mut parts: Vec<String> = Vec::new();
if include_thinking {
parts.extend(ar.thinking.clone());
}
if !ar.text.is_empty() {
parts.push(ar.text.clone());
}
text::join_lines(&parts, "\n")
}
MessageKind::ToolResultData(tr) => tr.content.clone(),
MessageKind::BashOutput(bo) => {
let mut out = format!("$ {}", bo.command);
if !bo.output.is_empty() {
out.push('\n');
out.push_str(&bo.output);
}
out
}
};
text::sanitize(&raw)
}
pub fn message_files(msg: &ConversationMessage) -> Vec<String> {
match &msg.kind {
MessageKind::AssistantResponse(ar) => ar
.tool_calls
.iter()
.filter_map(|tc| path_argument(&tc.arguments))
.collect(),
_ => Vec::new(),
}
}
pub fn searchable_text(msg: &ConversationMessage) -> String {
let raw = match &msg.kind {
MessageKind::TextContent(tc) => tc.text.clone(),
MessageKind::AssistantResponse(ar) => {
let mut parts: Vec<String> = Vec::new();
parts.extend(ar.thinking.clone());
if !ar.text.is_empty() {
parts.push(ar.text.clone());
}
for tc in &ar.tool_calls {
parts.push(format!(
"{} {}",
tc.name,
summarize_tool_args(&tc.arguments)
));
}
text::join_lines(&parts, " ")
}
MessageKind::ToolResultData(tr) => {
if tr.content.is_empty() {
tr.tool_name.clone()
} else {
format!("{} {}", tr.tool_name, tr.content)
}
}
MessageKind::BashOutput(bo) => {
if bo.command.is_empty() {
bo.output.clone()
} else if bo.output.is_empty() {
bo.command.clone()
} else {
format!("{} {}", bo.command, bo.output)
}
}
};
text::sanitize(&raw)
}
pub fn summarize_tool_args(arguments: &Value) -> String {
if let Some(obj) = arguments.as_object() {
if let Some(path) = path_argument(arguments) {
return format!("path={path}");
}
for key in &["command", "query", "pattern", "description"] {
if let Some(val) = obj.get(*key)
&& let Some(s) = val.as_str()
{
return format!("{}={}", key, text::clip(s, 160));
}
}
if obj.is_empty() {
return String::new();
}
let mut keys: Vec<&str> = obj.keys().map(std::string::String::as_str).collect();
keys.sort_unstable();
return keys.join(", ");
}
if let Some(s) = arguments.as_str() {
return text::clip(s, 160);
}
if arguments.is_null() {
return String::new();
}
text::clip(&arguments.to_string(), 160)
}
pub fn path_argument(arguments: &Value) -> Option<String> {
let obj = arguments.as_object()?;
for key in &["path", "file_path", "filePath", "file"] {
if let Some(val) = obj.get(*key)
&& let Some(s) = val.as_str()
{
return Some(s.to_string());
}
}
None
}
pub fn plain_contexts(contexts: &[ContextListing]) -> String {
let mut out = String::from("Sessions:\n");
for c in contexts {
let path = c
.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let _ = writeln!(out, " {} {} {}", c.id, c.detail, path);
}
out.trim_end().to_string()
}
pub fn plain_messages(msgs: &[ConversationMessage]) -> String {
let mut out = String::new();
for (i, msg) in msgs.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&plain_message(msg));
}
out.trim_end().to_string()
}
fn plain_message(msg: &ConversationMessage) -> String {
match &msg.kind {
MessageKind::TextContent(tc) => {
let header = format!("entryId: {} ({})", msg.entry_id, tc.role);
if tc.text.is_empty() {
return header;
}
let body = text::wrap_text(&tc.text, DISPLAY_WIDTH, " ");
format!("{header}\n{body}")
}
MessageKind::AssistantResponse(ar) => {
let header = format!("entryId: {} (assistant)", msg.entry_id);
let mut body = String::new();
if !ar.thinking.is_empty() {
body.push_str(" thinking:\n");
for t in &ar.thinking {
body.push_str(&text::wrap_text(t, DISPLAY_WIDTH, " "));
body.push('\n');
}
}
if !ar.text.is_empty() {
body.push_str(" text:\n");
body.push_str(&text::wrap_text(&ar.text, DISPLAY_WIDTH, " "));
body.push('\n');
}
if !ar.tool_calls.is_empty() {
body.push_str(" tool_calls:\n");
for tc in &ar.tool_calls {
let label = format!("{}: {}", tc.name, summarize_tool_args(&tc.arguments));
body.push_str(&text::wrap_text(&label, DISPLAY_WIDTH, " "));
body.push('\n');
}
}
body = body.trim_end().to_string();
if body.is_empty() {
header
} else {
format!("{header}\n{body}")
}
}
MessageKind::ToolResultData(tr) => {
let error = if tr.is_error { " ⚠" } else { "" };
let header = format!("entryId: {} (tool/{}{})", msg.entry_id, tr.tool_name, error);
let body = text::wrap_text(&tr.content, DISPLAY_WIDTH, " ");
format!("{header}\n{body}")
}
MessageKind::BashOutput(bo) => {
let header = format!("entryId: {} (bash)", msg.entry_id);
let cmd = format!("$ {}", bo.command);
let body = if bo.output.is_empty() {
text::wrap_text(&cmd, DISPLAY_WIDTH, " ")
} else {
let combined = format!("{}\n{}", cmd, bo.output);
text::wrap_text(&combined, DISPLAY_WIDTH, " ")
};
format!("{header}\n{body}")
}
}
}
pub fn plain_compact(msgs: &[ConversationMessage]) -> String {
let mut out = String::new();
for msg in msgs {
let body = message_text(msg, false);
let header;
let mut lines: Vec<String> = Vec::new();
match &msg.kind {
MessageKind::TextContent(tc) => {
if body.is_empty() {
continue;
}
header = format!("entryId: {} ({})", msg.entry_id, tc.role);
lines.push(text::clip(&body, 60));
}
MessageKind::AssistantResponse(ar) => {
if body.is_empty() && ar.tool_calls.is_empty() {
continue;
}
header = format!("entryId: {} (assistant)", msg.entry_id);
if !ar.tool_calls.is_empty() {
let calls: Vec<String> = ar
.tool_calls
.iter()
.map(|tc| {
let args = summarize_tool_args(&tc.arguments);
if args.is_empty() {
tc.name.clone()
} else {
format!("{}({})", tc.name, text::clip(&args, 60))
}
})
.collect();
let calls_str = format!("tools: {}", calls.join(", "));
lines.push(text::clip(&calls_str, 70));
}
if !body.is_empty() {
lines.push(text::clip(&body, 60));
}
}
MessageKind::ToolResultData(tr) => {
if body.is_empty() {
continue;
}
let error = if tr.is_error { " ⚠" } else { "" };
header = format!("entryId: {} (tool/{}{})", msg.entry_id, tr.tool_name, error);
lines.push(text::clip(&body, 60));
}
MessageKind::BashOutput(bo) => {
if bo.command.is_empty() && bo.output.is_empty() {
continue;
}
header = format!("entryId: {} (bash)", msg.entry_id);
lines.push(text::clip(&body, 60));
}
}
out.push_str(&header);
out.push('\n');
for line in &lines {
let _ = writeln!(out, " {line}");
}
out.push('\n');
}
out.trim_end().to_string()
}
pub fn plain_compact_hits(hits: &[SearchHit]) -> String {
let mut out = String::new();
for h in hits {
let _ = writeln!(out, "entryId: {} ({})", h.entry_id, h.role);
let _ = writeln!(out, " score: {:.2}", h.score);
let first_line = h.text.lines().next().unwrap_or("");
if !first_line.is_empty() {
let _ = writeln!(out, " {}", text::clip(first_line, 70));
}
out.push('\n');
}
out.trim_end().to_string()
}
pub fn plain_hits(hits: &[SearchHit]) -> String {
let mut out = String::new();
for h in hits {
let header = format!("entryId: {} ({})", h.entry_id, h.role);
out.push_str(&header);
out.push('\n');
let _ = writeln!(out, " score: {:.2}", h.score);
if !h.files.is_empty() {
let _ = writeln!(out, " files: {}", h.files.join(", "));
}
if !h.text.is_empty() {
out.push_str(&text::wrap_text(&h.text, DISPLAY_WIDTH, " "));
out.push('\n');
}
out.push('\n');
}
out.trim_end().to_string()
}