use serde_json::Value;
use unicode_width::UnicodeWidthChar;
const SUMMARY_MAX: usize = 88;
const URL_MAX: usize = 64;
pub fn sanitize_terminal_text(text: &str) -> String {
strip_ansi_escapes(text)
.chars()
.filter_map(sanitize_char)
.collect()
}
fn sanitize_char(ch: char) -> Option<char> {
if ch == '\u{FFFD}' {
return Some(' ');
}
match UnicodeWidthChar::width(ch) {
None | Some(0) => None,
Some(w) if w > 2 => Some(' '),
_ => Some(ch),
}
}
fn is_csi_final_byte(ch: char) -> bool {
ch.is_ascii() && (0x40..=0x7E).contains(&(ch as u8))
}
fn strip_ansi_escapes(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if is_csi_final_byte(c) {
break;
}
}
}
continue;
}
if ch == '\r' {
continue;
}
out.push(ch);
}
out
}
pub fn format_tool_started_summary(name: &str, input: &Value) -> String {
match name {
"web_search" => field_quote(input, &["query"]),
"fetch_url" | "fetch" => field_plain(input, &["url"], URL_MAX),
"read_file" | "write_file" | "edit_file" | "apply_patch" => {
field_plain(input, &["path", "file_path"], SUMMARY_MAX)
}
"bash" | "shell" | "run_terminal_cmd" => {
field_plain(input, &["command", "cmd"], SUMMARY_MAX)
}
"list_dir" => field_plain(input, &["path", "directory"], SUMMARY_MAX),
"grep" | "search" => {
let pattern = field_plain(input, &["pattern", "query"], 40);
let path = field_plain(input, &["path"], 32);
if pattern.is_empty() {
path
} else if path.is_empty() {
pattern
} else {
truncate_plain(&format!("{pattern} in {path}"), SUMMARY_MAX)
}
}
_ => compact_value(input),
}
}
pub fn format_tool_result_summary(name: &str, content: &str, success: bool) -> String {
if !success {
return truncate_plain(content, SUMMARY_MAX);
}
if content.trim().is_empty() {
return "ok".to_string();
}
if let Ok(value) = serde_json::from_str::<Value>(content) {
return summarize_json_result(name, &value, content);
}
match name {
"web_search" => summarize_text_search(content),
"fetch_url" | "fetch" => summarize_fetched_body(content),
"read_file" => summarize_text_preview(content, "file"),
"bash" | "shell" | "run_terminal_cmd" => summarize_text_preview(content, "output"),
_ => summarize_generic_body(content),
}
}
fn summarize_json_result(name: &str, value: &Value, raw: &str) -> String {
if let Some(arr) = value.as_array() {
return format!("{} results", arr.len());
}
if let Some(results) = value.get("results").and_then(Value::as_array) {
let n = results.len();
if let Some(query) = value
.get("query")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
return truncate_plain(&format!("{n} results · «{query}»"), SUMMARY_MAX);
}
return format!("{n} results");
}
if let Some(url) = value.get("url").and_then(|v| v.as_str()) {
return truncate_plain(&format!("{url} · {} bytes", raw.len()), SUMMARY_MAX);
}
match name {
"web_search" => summarize_text_search(raw),
"fetch_url" | "fetch" => summarize_fetched_body(raw),
_ => compact_value(value),
}
}
fn summarize_text_search(content: &str) -> String {
if let Ok(value) = serde_json::from_str::<Value>(content)
&& let Some(results) = value.get("results").and_then(Value::as_array)
{
let n = results.len();
if let Some(query) = value.get("query").and_then(|v| v.as_str()) {
return truncate_plain(&format!("{n} results · «{query}»"), SUMMARY_MAX);
}
return format!("{n} results");
}
summarize_generic_body(content)
}
fn summarize_fetched_body(content: &str) -> String {
let bytes = content.len();
let lines = content.lines().count();
let lower = content.to_ascii_lowercase();
if lower.contains("<html") || lower.contains("<!doctype") {
return format!("html · {bytes} bytes · {lines} lines");
}
if content.trim_start().starts_with('{') || content.trim_start().starts_with('[') {
return format!("json · {bytes} bytes");
}
format!("text · {bytes} bytes · {lines} lines")
}
fn summarize_text_preview(content: &str, label: &str) -> String {
let collapsed: String = content.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.is_empty() {
return "ok".to_string();
}
truncate_plain(&format!("{label}: {collapsed}"), SUMMARY_MAX)
}
fn summarize_generic_body(content: &str) -> String {
let bytes = content.len();
let lines = content.lines().count();
if lines <= 1 {
return truncate_plain(content, SUMMARY_MAX);
}
format!("{bytes} bytes · {lines} lines")
}
fn field_quote(value: &Value, keys: &[&str]) -> String {
field_str(value, keys)
.map(|s| truncate_plain(&format!("«{s}»"), SUMMARY_MAX))
.unwrap_or_else(|| compact_value(value))
}
fn field_plain(value: &Value, keys: &[&str], max: usize) -> String {
field_str(value, keys)
.map(|s| truncate_plain(&s, max))
.unwrap_or_else(|| compact_value(value))
}
fn field_str(value: &Value, keys: &[&str]) -> Option<String> {
for key in keys {
if let Some(s) = value
.get(*key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
return Some(s.to_string());
}
}
None
}
fn compact_value(value: &Value) -> String {
let collapsed = match value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
let collapsed: String = collapsed.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_plain(&collapsed, SUMMARY_MAX)
}
pub fn truncate_plain(text: &str, max: usize) -> String {
if text.chars().count() <= max {
text.to_string()
} else {
let cut: String = text.chars().take(max).collect();
format!("{cut}…")
}
}
pub fn format_compact_count(n: usize) -> String {
if n >= 1_000_000 {
format!("{:.1}M chars", n as f64 / 1_000_000.0)
} else if n >= 10_000 {
format!("{:.0}k chars", n as f64 / 1_000.0)
} else {
format!("{n} chars")
}
}
pub fn should_skip_harness_label(label: &str) -> bool {
matches!(
label,
"gate_skip" | "gate_pass" | "checklist_persist" | "context_snapshot"
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn web_search_started_summary() {
let s = format_tool_started_summary("web_search", &json!({"query": "东莞 天气"}));
assert!(s.contains("东莞"));
assert!(!s.contains("{"));
}
#[test]
fn fetch_result_summary() {
let body = "<html><body>weather</body></html>";
let s = format_tool_result_summary("fetch_url", body, true);
assert!(s.contains("html"));
assert!(s.contains("bytes"));
}
#[test]
fn sanitize_strips_replacement_char() {
let s = sanitize_terminal_text("晴\u{FFFD}天");
assert!(!s.contains('\u{FFFD}'));
assert_eq!(s, "晴 天");
}
#[test]
fn sanitize_drops_zero_width_chars_not_spaces() {
let input = "w\u{200B}a\u{200B}s";
let s = sanitize_terminal_text(input);
assert_eq!(
s, "was",
"zero-width chars must be removed, not replaced with spaces"
);
}
#[test]
fn sanitize_drops_zero_width_nonjoiner() {
let input = "re\u{200C}think\u{200C}s";
let s = sanitize_terminal_text(input);
assert_eq!(s, "rethinks");
}
#[test]
fn compact_count_formats_thousands() {
assert_eq!(format_compact_count(434_948), "435k chars");
}
#[test]
fn strip_ansi_csi_final_byte_tilde() {
let input = "\x1b[12~hello";
assert_eq!(strip_ansi_escapes(input), "hello");
}
#[test]
fn strip_ansi_drops_lone_escape_byte() {
assert_eq!(strip_ansi_escapes("\x1bPhello"), "Phello");
}
}