use crate::message::{ContextListing, ConversationMessage, DisplaySpan, MessageKind, SearchHit};
use crate::text;
use serde_json::Value;
use std::fmt::Write;
const DISPLAY_WIDTH: usize = 80;
pub fn message_line_spans(
msgs: &[ConversationMessage],
) -> std::collections::HashMap<String, DisplaySpan> {
let mut spans = std::collections::HashMap::new();
let mut line = 1;
for (i, msg) in msgs.iter().enumerate() {
if i > 0 {
line += 1;
}
let rendered = plain_message_with_span(msg, None);
let count = rendered.lines().count().max(1);
spans.insert(
msg.entry_id.clone(),
DisplaySpan {
start_line: line,
end_line: line + count - 1,
},
);
line += count;
}
spans
}
pub fn format_span(span: DisplaySpan) -> String {
if span.start_line == span.end_line {
format!("line {}", span.start_line)
} else {
format!("lines {}-{}", span.start_line, span.end_line)
}
}
fn header_with_span(entry_id: &str, role: &str, span: Option<DisplaySpan>) -> String {
match span {
Some(span) => format!("entryId: {entry_id} ({role}) [{}]", format_span(span)),
None => format!("entryId: {entry_id} ({role})"),
}
}
pub fn message_text(msg: &ConversationMessage, include_thinking: bool) -> String {
match &msg.kind {
MessageKind::TextContent(tc) => text::sanitize(&tc.text),
MessageKind::AssistantResponse(ar) => {
let mut parts: Vec<&str> = Vec::new();
if include_thinking {
parts.extend(ar.thinking.iter().map(String::as_str));
}
if !ar.text.is_empty() {
parts.push(&ar.text);
}
text::sanitize(&parts.join("\n"))
}
MessageKind::ToolResultData(tr) => text::sanitize(&tr.content),
MessageKind::BashOutput(bo) => {
let mut out = format!("$ {}", bo.command);
if !bo.output.is_empty() {
out.push('\n');
out.push_str(&bo.output);
}
text::sanitize(&out)
}
}
}
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 {
match &msg.kind {
MessageKind::TextContent(tc) => text::sanitize(&tc.text),
MessageKind::AssistantResponse(ar) => {
let mut parts: Vec<String> = 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::sanitize(&text::join_lines(&parts, " "))
}
MessageKind::ToolResultData(tr) => {
if tr.content.is_empty() {
text::sanitize(&tr.tool_name)
} else {
text::sanitize(&format!("{} {}", tr.tool_name, tr.content))
}
}
MessageKind::BashOutput(bo) => {
if bo.command.is_empty() {
text::sanitize(&bo.output)
} else if bo.output.is_empty() {
text::sanitize(&bo.command)
} else {
text::sanitize(&format!("{} {}", bo.command, bo.output))
}
}
}
}
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) {
if 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) {
if 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 markdown_source(output: &str) -> String {
output
.lines()
.map(markdown_line)
.collect::<Vec<_>>()
.join("\n")
}
pub fn markdown_tty(output: &str) -> String {
markdown_source(output)
.lines()
.map(markdown_tty_line)
.collect::<Vec<_>>()
.join("\n")
}
fn markdown_line(line: &str) -> String {
if let Some(title) = compact_section_title(line) {
return format!("## {title}");
}
if line == "Sessions:" {
return "## Sessions".to_string();
}
if let Some(rest) = line.strip_prefix("entryId: ") {
return format!("## `{rest}`");
}
if line.starts_with("results for ") || line.starts_with("no results for ") {
return format!("## {line}");
}
if let Some(session) = line.strip_prefix(" ") {
if !session.is_empty() {
return format!("- {session}");
}
}
line.to_string()
}
fn markdown_tty_line(line: &str) -> String {
if let Some(title) = line.strip_prefix("## ") {
format!("\x1b[1m{title}\x1b[0m")
} else {
line.to_string()
}
}
fn compact_section_title(line: &str) -> Option<&str> {
let title = line.strip_prefix('[')?.strip_suffix(']')?;
matches!(
title,
"Session Goal"
| "Files And Changes"
| "Commits"
| "Outstanding Context"
| "User Preferences"
)
.then_some(title)
}
pub fn plain_messages(msgs: &[ConversationMessage]) -> String {
let mut out = String::new();
let spans = message_line_spans(msgs);
for (i, msg) in msgs.iter().enumerate() {
if i > 0 {
out.push('\n');
}
let span = spans.get(&msg.entry_id).copied();
out.push_str(&plain_message_with_span(msg, span));
}
out.trim_end().to_string()
}
fn plain_message_with_span(msg: &ConversationMessage, span: Option<DisplaySpan>) -> String {
match &msg.kind {
MessageKind::TextContent(tc) => {
let header = header_with_span(&msg.entry_id, &tc.role, span);
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 = header_with_span(&msg.entry_id, "assistant", span);
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 role = format!("tool/{}{error}", tr.tool_name);
let header = header_with_span(&msg.entry_id, &role, span);
let body = text::wrap_text(&tr.content, DISPLAY_WIDTH, " ");
format!("{header}\n{body}")
}
MessageKind::BashOutput(bo) => {
let header = header_with_span(&msg.entry_id, "bash", span);
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 spans = message_line_spans(msgs);
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 =
header_with_span(&msg.entry_id, &tc.role, spans.get(&msg.entry_id).copied());
lines.push(text::clip(&body, 60));
}
MessageKind::AssistantResponse(ar) => {
if body.is_empty() && ar.tool_calls.is_empty() {
continue;
}
header = header_with_span(
&msg.entry_id,
"assistant",
spans.get(&msg.entry_id).copied(),
);
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 { "" };
let role = format!("tool/{}{error}", tr.tool_name);
header = header_with_span(&msg.entry_id, &role, spans.get(&msg.entry_id).copied());
lines.push(text::clip(&body, 60));
}
MessageKind::BashOutput(bo) => {
if bo.command.is_empty() && bo.output.is_empty() {
continue;
}
header = header_with_span(&msg.entry_id, "bash", spans.get(&msg.entry_id).copied());
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, "{}", header_with_span(&h.entry_id, &h.role, h.span));
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 = header_with_span(&h.entry_id, &h.role, h.span);
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()
}