use unicode_segmentation::UnicodeSegmentation; /// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating. /// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells. pub(crate) fn format_and_truncate_tool_result( text: &str, max_lines: usize, line_width: usize, ) -> String { // Work out the maximum number of graphemes we can display for a result. // It's not guaranteed that 1 grapheme = 1 cell, so we subtract 1 per line as a fudge factor. // It also won't handle future terminal resizes properly, but it's an OK approximation for now. let max_graphemes = (max_lines * line_width).saturating_sub(max_lines); if let Some(formatted_json) = format_json_compact(text) { truncate_text(&formatted_json, max_graphemes) } else { truncate_text(text, max_graphemes) } } /// Format JSON text in a compact single-line format with spaces for better Ratatui wrapping. /// Ex: `{"a":"b",c:["d","e"]}` -> `{"a": "b", "c": ["d", "e"]}` /// Returns the formatted JSON string if the input is valid JSON, otherwise returns None. /// This is a little complicated, but it's necessary because Ratatui's wrapping is *very* limited /// and can only do line breaks at whitespace. If we use the default serde_json format, we get lines /// without spaces that Ratatui can't wrap nicely. If we use the serde_json pretty format as-is, /// it's much too sparse and uses too many terminal rows. /// Relevant issue: https://github.com/ratatui/ratatui/issues/293 pub(crate) fn format_json_compact(text: &str) -> Option { let json = serde_json::from_str::(text).ok()?; let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string()); // Convert multi-line pretty JSON to compact single-line format by removing newlines and excess whitespace let mut result = String::new(); let mut chars = json_pretty.chars().peekable(); let mut in_string = false; let mut escape_next = false; // Iterate over the characters in the JSON string, adding spaces after : and , but only when not in a string while let Some(ch) = chars.next() { match ch { '"' if !escape_next => { in_string = !in_string; result.push(ch); } '\\' if in_string => { escape_next = !escape_next; result.push(ch); } '\n' | '\r' if !in_string => { // Skip newlines when not in a string } ' ' | '\t' if !in_string => { // Add a space after : and , but only when not in a string if let Some(&next_ch) = chars.peek() { if let Some(last_ch) = result.chars().last() { if (last_ch == ':' || last_ch == ',') && !matches!(next_ch, '}' | ']') { result.push(' '); } } } } _ => { if escape_next && in_string { escape_next = false; } result.push(ch); } } } Some(result) } /// Truncate `text` to `max_graphemes` graphemes. Using graphemes to avoid accidentally truncating in the middle of a multi-codepoint character. pub(crate) fn truncate_text(text: &str, max_graphemes: usize) -> String { let mut graphemes = text.grapheme_indices(true); // Check if there's a grapheme at position max_graphemes (meaning there are more than max_graphemes total) if let Some((byte_index, _)) = graphemes.nth(max_graphemes) { // There are more than max_graphemes, so we need to truncate if max_graphemes >= 3 { // Truncate to max_graphemes - 3 and add "..." to stay within limit let mut truncate_graphemes = text.grapheme_indices(true); if let Some((truncate_byte_index, _)) = truncate_graphemes.nth(max_graphemes - 3) { let truncated = &text[..truncate_byte_index]; format!("{truncated}...") } else { text.to_string() } } else { // max_graphemes < 3, so just return first max_graphemes without "..." let truncated = &text[..byte_index]; truncated.to_string() } } else { // There are max_graphemes or fewer graphemes, return original text text.to_string() } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; use pretty_assertions::assert_eq; #[test] fn test_truncate_text() { let text = "Hello, world!"; let truncated = truncate_text(text, 8); assert_eq!(truncated, "Hello..."); } #[test] fn test_truncate_empty_string() { let text = ""; let truncated = truncate_text(text, 5); assert_eq!(truncated, ""); } #[test] fn test_truncate_max_graphemes_zero() { let text = "Hello"; let truncated = truncate_text(text, 0); assert_eq!(truncated, ""); } #[test] fn test_truncate_max_graphemes_one() { let text = "Hello"; let truncated = truncate_text(text, 1); assert_eq!(truncated, "H"); } #[test] fn test_truncate_max_graphemes_two() { let text = "Hello"; let truncated = truncate_text(text, 2); assert_eq!(truncated, "He"); } #[test] fn test_truncate_max_graphemes_three_boundary() { let text = "Hello"; let truncated = truncate_text(text, 3); assert_eq!(truncated, "..."); } #[test] fn test_truncate_text_shorter_than_limit() { let text = "Hi"; let truncated = truncate_text(text, 10); assert_eq!(truncated, "Hi"); } #[test] fn test_truncate_text_exact_length() { let text = "Hello"; let truncated = truncate_text(text, 5); assert_eq!(truncated, "Hello"); } #[test] fn test_truncate_emoji() { let text = "👋🌍🚀✨💫"; let truncated = truncate_text(text, 3); assert_eq!(truncated, "..."); let truncated_longer = truncate_text(text, 4); assert_eq!(truncated_longer, "👋..."); } #[test] fn test_truncate_unicode_combining_characters() { let text = "é́ñ̃"; // Characters with combining marks let truncated = truncate_text(text, 2); assert_eq!(truncated, "é́ñ̃"); } #[test] fn test_truncate_very_long_text() { let text = "a".repeat(1000); let truncated = truncate_text(&text, 10); assert_eq!(truncated, "aaaaaaa..."); assert_eq!(truncated.len(), 10); // 7 'a's + 3 dots } #[test] fn test_format_json_compact_simple_object() { let json = r#"{ "name": "John", "age": 30 }"#; let result = format_json_compact(json).unwrap(); assert_eq!(result, r#"{"name": "John", "age": 30}"#); } #[test] fn test_format_json_compact_nested_object() { let json = r#"{ "user": { "name": "John", "details": { "age": 30, "city": "NYC" } } }"#; let result = format_json_compact(json).unwrap(); assert_eq!( result, r#"{"user": {"name": "John", "details": {"age": 30, "city": "NYC"}}}"# ); } #[test] fn test_format_json_compact_array() { let json = r#"[ 1, 2, { "key": "value" }, "string" ]"#; let result = format_json_compact(json).unwrap(); assert_eq!(result, r#"[1, 2, {"key": "value"}, "string"]"#); } #[test] fn test_format_json_compact_already_compact() { let json = r#"{"compact":true}"#; let result = format_json_compact(json).unwrap(); assert_eq!(result, r#"{"compact": true}"#); } #[test] fn test_format_json_compact_with_whitespace() { let json = r#" { "name": "John", "hobbies": [ "reading", "coding" ] } "#; let result = format_json_compact(json).unwrap(); assert_eq!( result, r#"{"name": "John", "hobbies": ["reading", "coding"]}"# ); } #[test] fn test_format_json_compact_invalid_json() { let invalid_json = r#"{"invalid": json syntax}"#; let result = format_json_compact(invalid_json); assert!(result.is_none()); } #[test] fn test_format_json_compact_empty_object() { let json = r#"{}"#; let result = format_json_compact(json).unwrap(); assert_eq!(result, "{}"); } #[test] fn test_format_json_compact_empty_array() { let json = r#"[]"#; let result = format_json_compact(json).unwrap(); assert_eq!(result, "[]"); } #[test] fn test_format_json_compact_primitive_values() { assert_eq!(format_json_compact("42").unwrap(), "42"); assert_eq!(format_json_compact("true").unwrap(), "true"); assert_eq!(format_json_compact("false").unwrap(), "false"); assert_eq!(format_json_compact("null").unwrap(), "null"); assert_eq!(format_json_compact(r#""string""#).unwrap(), r#""string""#); } }