pub fn split_message(text: &str, max_len: usize) -> Vec<String> {
if text.chars().count() <= max_len {
return vec![text.to_string()];
}
let safety_margin = 20usize.min(max_len.saturating_sub(1));
let effective_max = (max_len - safety_margin).max(1);
let mut chunks: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_chars: usize = 0;
let mut in_code_block = false;
let mut code_lang = String::new();
let flush = |current: &mut String,
current_chars: &mut usize,
chunks: &mut Vec<String>,
in_code_block: bool,
code_lang: &str| {
if in_code_block {
current.push_str("\n```");
}
chunks.push(std::mem::take(current));
*current_chars = 0;
if in_code_block {
let header = format!("```{code_lang}\n");
*current_chars = header.chars().count();
*current = header;
}
};
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
if in_code_block {
in_code_block = false;
} else {
in_code_block = true;
code_lang = trimmed.strip_prefix("```").unwrap_or("").to_string();
}
}
let line_chars = line.chars().count();
if line_chars > effective_max {
if current_chars > 0 {
flush(
&mut current,
&mut current_chars,
&mut chunks,
in_code_block,
&code_lang,
);
}
for part in split_long_line(line, effective_max) {
let part_chars = part.chars().count();
let needed = part_chars + usize::from(current_chars > 0);
if current_chars + needed > effective_max && current_chars > 0 {
flush(
&mut current,
&mut current_chars,
&mut chunks,
in_code_block,
&code_lang,
);
}
if current_chars > 0 {
current.push('\n');
current_chars += 1;
}
current.push_str(&part);
current_chars += part_chars;
}
continue;
}
let needed = line_chars + usize::from(current_chars > 0);
if current_chars + needed > effective_max && current_chars > 0 {
flush(
&mut current,
&mut current_chars,
&mut chunks,
in_code_block,
&code_lang,
);
}
if current_chars > 0 {
current.push('\n');
current_chars += 1;
}
current.push_str(line);
current_chars += line_chars;
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
fn split_long_line(line: &str, max_chars: usize) -> Vec<String> {
let chars: Vec<char> = line.chars().collect();
let total = chars.len();
if total <= max_chars {
return vec![line.to_string()];
}
let mut parts: Vec<String> = Vec::new();
let mut start = 0;
while start < total {
let end = (start + max_chars).min(total);
if end == total {
parts.push(chars[start..end].iter().collect());
break;
}
let window_start = end.saturating_sub(100).max(start + 1);
let break_at = find_natural_break(&chars[window_start..end])
.map(|rel| window_start + rel + 1) .unwrap_or(end);
parts.push(chars[start..break_at].iter().collect());
start = break_at;
}
parts
}
fn find_natural_break(chars: &[char]) -> Option<usize> {
for i in (0..chars.len()).rev() {
if matches!(chars[i], '。' | '!' | '?' | '…' | '⋯') {
return Some(i);
}
}
for i in (0..chars.len()).rev() {
if matches!(chars[i], '.' | '!' | '?') {
let next = chars.get(i + 1);
if next.is_none() || next == Some(&' ') || next == Some(&'\t') {
return Some(i);
}
}
}
for i in (0..chars.len()).rev() {
if matches!(chars[i], ',' | '、' | ';') {
return Some(i);
}
}
for i in (0..chars.len()).rev() {
if matches!(chars[i], ',' | ';') {
let next = chars.get(i + 1);
if next.is_none() || next == Some(&' ') {
return Some(i);
}
}
}
(0..chars.len()).rev().find(|&i| chars[i] == ' ')
}
pub fn tool_call_summary(name: &str, args: &str, max_len: usize) -> String {
let detail = match name {
"bash" => {
let cmd = extract_json_field(args, "command").unwrap_or_default();
let lang = detect_bash_lang(&cmd);
if lang != "bash" {
format!("[{lang}] {}", truncate(&cmd, 150))
} else {
truncate(&cmd, 150)
}
}
"file_read" | "file_write" | "file_edit" => {
extract_json_field(args, "path").unwrap_or_default()
}
"search" => extract_json_field(args, "pattern")
.map(|p| format!("/{p}/"))
.unwrap_or_default(),
"skill" => extract_json_field(args, "skill")
.unwrap_or_else(|| extract_json_field(args, "name").unwrap_or_default()),
"subagent" => extract_json_field(args, "prompt")
.map(|p| truncate(&p, 80))
.unwrap_or_default(),
_ => {
if args.len() > 60 {
format!("{}...", truncate(args, 60))
} else {
args.to_string()
}
}
};
let full = format!("🔧 {name}: {detail}");
truncate(&full, max_len)
}
pub fn detect_bash_lang(command: &str) -> &'static str {
let cmd = command.trim_start();
if cmd.starts_with("python3 ") || cmd.starts_with("python ") {
"python"
} else if cmd.starts_with("node ") {
"javascript"
} else if cmd.starts_with("ruby ") {
"ruby"
} else if cmd.starts_with("go ") {
"go"
} else if cmd.starts_with("cargo ") || cmd.starts_with("rustc ") {
"rust"
} else if cmd.starts_with("swift ") || cmd.starts_with("swiftc ") {
"swift"
} else if cmd.starts_with("java ")
|| cmd.starts_with("javac ")
|| cmd.starts_with("gradle ")
|| cmd.starts_with("mvn ")
{
"java"
} else {
"bash"
}
}
pub fn format_response(text: &str) -> String {
strip_ansi(text.trim())
}
pub fn format_plan(plan: &str) -> String {
format!("📋 **Plan**\n\n{}", strip_ansi(plan.trim()))
}
#[allow(clippy::too_many_arguments)]
pub fn format_status(
session_id: &str,
project: &str,
model: &str,
busy: bool,
queue_len: usize,
streaming: &str,
workspace: &str,
suppressed: bool,
) -> String {
let state = if busy {
if suppressed {
"🟡 Cancelling…"
} else {
"🟢 Running"
}
} else {
"⚪ Idle"
};
let short_id: String = session_id.chars().take(8).collect();
let short_project = shorten_path_for_display(project);
let queue_info = if queue_len > 0 {
format!("\n Queued: {queue_len} message(s)")
} else {
String::new()
};
format!(
"**Status**\n\
Session: `{short_id}`\n\
Project: `{short_project}`\n\
Model: `{model}`\n\
Streaming: `{streaming}`\n\
Workspace: `{workspace}`\n\
State: {state}{queue_info}",
)
}
pub fn shorten_path_for_display(path: &str) -> String {
let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
let display = match &home {
Some(h) if path.starts_with(h.as_str()) => {
format!("~{}", &path[h.len()..])
}
_ => path.to_string(),
};
let parts: Vec<&str> = display.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() <= 2 {
display
} else {
format!("~/…/{}", parts[parts.len() - 2..].join("/"))
}
}
pub fn format_time_ago(timestamp: &str) -> String {
let then = chrono::DateTime::parse_from_rfc3339(timestamp.trim())
.ok()
.map(|dt| dt.to_utc().timestamp())
.unwrap_or(0);
if then == 0 {
return timestamp.chars().take(16).collect();
}
let now = chrono::Utc::now().timestamp();
let diff = now - then;
if diff < 0 {
return "just now".to_string();
}
if diff < 60 {
return "just now".to_string();
}
if diff < 3600 {
return format!("{}m ago", diff / 60);
}
if diff < 86400 {
return format!("{}h ago", diff / 3600);
}
format!("{}d ago", diff / 86400)
}
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let end = max.saturating_sub(3);
let truncated: String = s.chars().take(end).collect();
format!("{truncated}...")
}
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next(); for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
} else {
out.push(ch);
}
}
out
}
fn extract_json_field(json: &str, field: &str) -> Option<String> {
let needle = format!("\"{}\"", field);
let pos = json.find(&needle)?;
let after_key = &json[pos + needle.len()..];
let after_colon = after_key.trim_start().strip_prefix(':')?;
let after_colon = after_colon.trim_start();
if let Some(content) = after_colon.strip_prefix('"') {
let mut end = 0;
let mut escaped = false;
for ch in content.chars() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
break;
}
end += ch.len_utf8();
}
Some(content[..end].to_string())
} else {
let end = after_colon
.find([',', '}', ']'])
.unwrap_or(after_colon.len());
Some(after_colon[..end].trim().to_string())
}
}
pub fn format_completion_summary(message_count: usize, elapsed_secs: u64) -> String {
let time_str = if elapsed_secs < 60 {
format!("{elapsed_secs}s")
} else {
format!("{}m {}s", elapsed_secs / 60, elapsed_secs % 60)
};
format!("✅ **Done** — {message_count} messages · {time_str}")
}
pub fn format_user_error(error: &str) -> String {
let msg = error.trim();
let cleaned = msg
.strip_prefix("Error: ")
.unwrap_or(msg)
.strip_prefix("API error: ")
.unwrap_or(msg);
if cleaned.chars().count() > 500 {
format!("❌ {}…", truncate(cleaned, 497))
} else {
format!("❌ {cleaned}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_short_message() {
let chunks = split_message("hello", 100);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0], "hello");
}
#[test]
fn split_long_message() {
let text = "line1\nline2\nline3\nline4";
let chunks = split_message(text, 15);
assert!(chunks.len() > 1);
}
#[test]
fn split_preserves_code_blocks() {
let text = "before\n```rust\nfn main() {}\nlet x = 1;\nlet y = 2;\n```\nafter";
let chunks = split_message(text, 40);
assert!(chunks.len() > 1, "should split into multiple chunks");
let rejoined = chunks.join("\n");
assert!(rejoined.contains("fn main()"));
assert!(rejoined.contains("let y = 2;"));
assert!(rejoined.contains("after"));
}
#[test]
fn truncate_utf8_safe() {
let s = "가나다라마바사아자차카타파하히";
let t = truncate(s, 10);
assert!(
std::str::from_utf8(t.as_bytes()).is_ok(),
"must be valid UTF-8"
);
assert!(t.ends_with("..."), "must end with ellipsis, got: {t:?}");
assert!(t.chars().count() <= 10, "must not exceed max chars");
}
#[test]
fn split_long_korean_paragraph() {
let text = "안녕하세요. 오늘은 좋은 날씨입니다. 하늘이 맑고 바람이 시원합니다. 공원에 산책을 나가고 싶네요. 꽃도 피고 새도 울고 정말 봄 같은 날씨입니다.";
let chunks = split_message(text, 30);
let rejoined: String = chunks.join("");
assert!(rejoined.contains("안녕하세요"));
assert!(rejoined.contains("봄 같은 날씨입니다"));
for chunk in chunks {
assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
}
}
#[test]
fn split_char_count_not_bytes() {
let text = "가나다라마바사아자차"; let chunks = split_message(text, 15);
assert_eq!(
chunks.len(),
1,
"10-char Korean text should fit in 15-char limit"
);
}
#[test]
fn split_at_cjk_sentence_boundary() {
let text = "첫 번째 문장입니다。두 번째 문장입니다。세 번째 문장입니다。";
let chunks = split_long_line(text, 15);
for chunk in &chunks {
assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
}
assert!(
chunks[0].ends_with('。'),
"should break at CJK sentence end, got: {:?}",
chunks[0]
);
}
#[test]
fn tool_summary_bash() {
let args = r#"{"command":"ls -la","timeout":5000}"#;
let summary = tool_call_summary("bash", args, 100);
assert!(summary.contains("ls -la"));
}
#[test]
fn strip_ansi_codes() {
let s = "\x1b[31mred\x1b[0m normal";
assert_eq!(strip_ansi(s), "red normal");
}
#[test]
fn format_time_ago_recent() {
let now = chrono::Utc::now().to_rfc3339();
assert_eq!(format_time_ago(&now), "just now");
}
#[test]
fn format_time_ago_minutes() {
let five_min_ago = (chrono::Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
let result = format_time_ago(&five_min_ago);
assert!(result.contains("m ago"), "expected 'Xm ago', got: {result}");
}
#[test]
fn format_time_ago_hours() {
let three_hours_ago = (chrono::Utc::now() - chrono::Duration::hours(3)).to_rfc3339();
let result = format_time_ago(&three_hours_ago);
assert!(result.contains("h ago"), "expected 'Xh ago', got: {result}");
}
#[test]
fn format_time_ago_days() {
let two_days_ago = (chrono::Utc::now() - chrono::Duration::days(2)).to_rfc3339();
let result = format_time_ago(&two_days_ago);
assert!(result.contains("d ago"), "expected 'Xd ago', got: {result}");
}
#[test]
fn format_completion_summary_brief() {
let summary = format_completion_summary(5, 30);
assert!(summary.contains("Done"));
assert!(summary.contains("5 messages"));
assert!(summary.contains("30s"));
}
#[test]
fn format_completion_summary_minutes() {
let summary = format_completion_summary(10, 125);
assert!(summary.contains("2m 5s"));
}
#[test]
fn format_user_error_short() {
let result = format_user_error("Something went wrong");
assert!(result.starts_with("❌"));
assert!(result.contains("Something went wrong"));
}
#[test]
fn format_user_error_strips_prefix() {
let result = format_user_error("Error: API error: timeout");
assert!(
!result.contains("Error: API error:"),
"should strip prefixes"
);
assert!(result.contains("timeout"));
}
#[test]
fn detect_bash_lang_various() {
assert_eq!(detect_bash_lang("python3 script.py"), "python");
assert_eq!(detect_bash_lang("node app.js"), "javascript");
assert_eq!(detect_bash_lang("cargo build"), "rust");
assert_eq!(detect_bash_lang("ls -la"), "bash");
assert_eq!(detect_bash_lang("go run ."), "go");
}
#[test]
fn shorten_path_display() {
let result = shorten_path_for_display("/Users/test/projects/my-app");
assert!(
result.contains("my-app") || result.contains("/"),
"got: {result}"
);
}
}