From 05a63fe91190294658439080df442f146666dfcc Mon Sep 17 00:00:00 2001 From: Helios Agent Date: Thu, 5 Mar 2026 20:35:07 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20truncate=20before=20colorize=20(no=20dan?= =?UTF-8?q?gling=20ANSI),=20emoji=20cleanup=20(#,=20=F0=9F=93=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/client/src/display.rs | 24 ++++++++++-------------- crates/client/src/main.rs | 14 +++++++------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/crates/client/src/display.rs b/crates/client/src/display.rs index ee79f75..c56ef5e 100644 --- a/crates/client/src/display.rs +++ b/crates/client/src/display.rs @@ -8,7 +8,7 @@ use colored::Colorize; use unicode_width::UnicodeWidthStr; /// Pad an emoji/symbol string to exactly 2 terminal display columns. -/// Some symbols (ℹ, ☀, ⚠ …) render as 1-wide; we add a space so columns align. +/// Some symbols (ℹ, ☀, ⚠, # …) render as 1-wide; we add a space so columns align. fn emoji_cell(s: &str) -> String { let w = UnicodeWidthStr::width(s); if w < 2 { @@ -43,6 +43,7 @@ fn col_widths() -> (usize, usize) { } /// Truncate a string to at most `max` Unicode chars, appending `…` if cut. +/// Must be called on PLAIN (uncolored) text — ANSI codes confuse char counting. pub fn trunc(s: &str, max: usize) -> String { if max == 0 { return String::new(); @@ -50,7 +51,6 @@ pub fn trunc(s: &str, max: usize) -> String { let mut chars = s.chars(); let truncated: String = chars.by_ref().take(max).collect(); if chars.next().is_some() { - // Replace last char with ellipsis let mut t: String = truncated.chars().take(max.saturating_sub(1)).collect(); t.push('…'); t @@ -60,11 +60,13 @@ pub fn trunc(s: &str, max: usize) -> String { } /// Format one table row into a String (no trailing newline). -fn format_row(action: &str, name: &str, payload: &str, status: &str, result: &str) -> String { +/// Truncation happens on plain `result` BEFORE colorizing, so ANSI reset codes are never cut off. +fn format_row(action: &str, name: &str, payload: &str, status: &str, result: &str, err: bool) -> String { let (payload_w, result_w) = col_widths(); let p = trunc(payload, payload_w); - let r = trunc(result, result_w); - // emoji_cell ensures every action/status occupies exactly 2 terminal columns + // Truncate BEFORE colorizing — avoids dangling ANSI escape sequences + let r_plain = trunc(result, result_w); + let r = if err { r_plain.red().to_string() } else { r_plain }; format!( " {} {: { let payload = trunc(&path, 60); - display::cmd_start("⬆", "upload", &payload); + display::cmd_start("📁", "upload", &payload); match (|| -> Result<(), String> { let bytes = base64::engine::general_purpose::STANDARD .decode(&content_base64) @@ -532,11 +532,11 @@ async fn handle_message( Ok(()) })() { Ok(()) => { - display::cmd_done("⬆", "upload", &payload, true, "saved"); + display::cmd_done("📁", "upload", &payload, true, "saved"); ClientMessage::Ack { request_id } } Err(e) => { - display::cmd_done("⬆", "upload", &payload, false, &e); + display::cmd_done("📁", "upload", &payload, false, &e); ClientMessage::Error { request_id, message: e } } } @@ -544,16 +544,16 @@ async fn handle_message( ServerMessage::DownloadRequest { request_id, path } => { let payload = trunc(&path, 60); - display::cmd_start("⬇", "download", &payload); + display::cmd_start("📁", "download", &payload); match std::fs::read(&path) { Ok(bytes) => { let size = bytes.len() as u64; let content_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - display::cmd_done("⬇", "download", &payload, true, &format!("{size} bytes")); + display::cmd_done("📁", "download", &payload, true, &format!("{size} bytes")); ClientMessage::DownloadResponse { request_id, content_base64, size } } Err(e) => { - display::cmd_done("⬇", "download", &payload, false, &format!("read failed: {e}")); + display::cmd_done("📁", "download", &payload, false, &format!("read failed: {e}")); ClientMessage::Error { request_id, message: format!("Read failed: {e}") } } }