diff --git a/src/cli/config.rs b/src/cli/config.rs index fc1312f6..f1e107ec 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -127,7 +127,8 @@ async fn list_settings( } let display_value = if value.len() > 60 { - format!("{}...", &value[..57]) + let end = crate::util::floor_char_boundary(&value, 57); + format!("{}...", &value[..end]) } else { value }; diff --git a/src/cli/memory.rs b/src/cli/memory.rs index 2d0606a8..4c04fd2c 100644 --- a/src/cli/memory.rs +++ b/src/cli/memory.rs @@ -256,7 +256,8 @@ fn truncate_content(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { - format!("{}...", &s[..max_len]) + let end = crate::util::floor_char_boundary(s, max_len); + format!("{}...", &s[..end]) } } @@ -292,4 +293,19 @@ mod tests { assert_eq!(truncate_content("hello", 10), "hello"); assert_eq!(truncate_content("hello world", 5), "hello..."); } + + #[test] + fn test_truncate_content_multibyte_does_not_panic() { + // "cafe\u{0301}" = "café" where é is e + combining accent (2 bytes for accent) + // Slicing at byte 5 would land inside the combining character + let s = "caf\u{00e9} au lait"; // café = 5 bytes (é is 2 bytes) + let result = truncate_content(s, 4); // byte 4 is inside é + assert!(result.ends_with("...")); + assert!(!result.is_empty()); + + // 4-byte emoji: slicing mid-emoji must not panic + let emoji = "Hi \u{1F600} there"; // 😀 is 4 bytes + let result = truncate_content(emoji, 4); // byte 4 is inside 😀 + assert!(result.ends_with("...")); + } } diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index 1f6dbb77..39f76b30 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -451,7 +451,7 @@ impl NearAiChatProvider { provider: "nearai_chat".to_string(), reason: format!( "No model names found in response: {}", - &response_text[..response_text.len().min(300)] + &response_text[..crate::util::floor_char_boundary(&response_text, 300)] ), }) }