fix: truncate before colorize (no dangling ANSI), emoji cleanup (#, 📁)

This commit is contained in:
Helios Agent 2026-03-05 20:35:07 +01:00
parent 03d80067a8
commit 05a63fe911
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
2 changed files with 17 additions and 21 deletions

View file

@ -8,7 +8,7 @@ use colored::Colorize;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
/// Pad an emoji/symbol string to exactly 2 terminal display columns. /// 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 { fn emoji_cell(s: &str) -> String {
let w = UnicodeWidthStr::width(s); let w = UnicodeWidthStr::width(s);
if w < 2 { if w < 2 {
@ -43,6 +43,7 @@ fn col_widths() -> (usize, usize) {
} }
/// Truncate a string to at most `max` Unicode chars, appending `…` if cut. /// 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 { pub fn trunc(s: &str, max: usize) -> String {
if max == 0 { if max == 0 {
return String::new(); return String::new();
@ -50,7 +51,6 @@ pub fn trunc(s: &str, max: usize) -> String {
let mut chars = s.chars(); let mut chars = s.chars();
let truncated: String = chars.by_ref().take(max).collect(); let truncated: String = chars.by_ref().take(max).collect();
if chars.next().is_some() { if chars.next().is_some() {
// Replace last char with ellipsis
let mut t: String = truncated.chars().take(max.saturating_sub(1)).collect(); let mut t: String = truncated.chars().take(max.saturating_sub(1)).collect();
t.push('…'); t.push('…');
t t
@ -60,11 +60,13 @@ pub fn trunc(s: &str, max: usize) -> String {
} }
/// Format one table row into a String (no trailing newline). /// 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 (payload_w, result_w) = col_widths();
let p = trunc(payload, payload_w); let p = trunc(payload, payload_w);
let r = trunc(result, result_w); // Truncate BEFORE colorizing — avoids dangling ANSI escape sequences
// emoji_cell ensures every action/status occupies exactly 2 terminal columns let r_plain = trunc(result, result_w);
let r = if err { r_plain.red().to_string() } else { r_plain };
format!( format!(
" {} {:<name_w$} {:<payload_w$} {} {}", " {} {:<name_w$} {:<payload_w$} {} {}",
emoji_cell(action), emoji_cell(action),
@ -77,10 +79,9 @@ fn format_row(action: &str, name: &str, payload: &str, status: &str, result: &st
) )
} }
/// Print the "running" row (🔄 spinner) without extra newline logic. /// Print the "running" row (🔄 spinner).
/// Returns the row string so callers can reuse it for the done line.
pub fn cmd_start(action: &str, name: &str, payload: &str) { pub fn cmd_start(action: &str, name: &str, payload: &str) {
let line = format_row(action, name, payload, "🔄", ""); let line = format_row(action, name, payload, "🔄", "", false);
println!("{}", line); println!("{}", line);
let _ = std::io::stdout().flush(); let _ = std::io::stdout().flush();
} }
@ -88,12 +89,7 @@ pub fn cmd_start(action: &str, name: &str, payload: &str) {
/// Overwrite the previous line (cursor up + clear) with the completed row. /// Overwrite the previous line (cursor up + clear) with the completed row.
pub fn cmd_done(action: &str, name: &str, payload: &str, success: bool, result: &str) { pub fn cmd_done(action: &str, name: &str, payload: &str, success: bool, result: &str) {
let status = if success { "" } else { "" }; let status = if success { "" } else { "" };
let result_colored = if success { let line = format_row(action, name, payload, status, result, !success);
result.to_string()
} else {
result.red().to_string()
};
let line = format_row(action, name, payload, status, &result_colored);
// \x1b[1A = cursor up 1, \r = go to col 0, \x1b[2K = clear line // \x1b[1A = cursor up 1, \r = go to col 0, \x1b[2K = clear line
print!("\x1b[1A\r\x1b[2K{}\n", line); print!("\x1b[1A\r\x1b[2K{}\n", line);
let _ = std::io::stdout().flush(); let _ = std::io::stdout().flush();

View file

@ -43,7 +43,7 @@ fn print_session_info(label: &str, sid: &uuid::Uuid) {
display::info_line("👤", "privileges:", &"no admin".yellow().to_string()); display::info_line("👤", "privileges:", &"no admin".yellow().to_string());
display::info_line("🖥", "device:", &label.bold().to_string()); display::info_line("🖥", "device:", &label.bold().to_string());
display::info_line("#️⃣", "session:", &sid.to_string().dimmed().to_string()); display::info_line("#", "session:", &sid.to_string().dimmed().to_string());
println!(); println!();
} }
@ -520,7 +520,7 @@ async fn handle_message(
ServerMessage::UploadRequest { request_id, path, content_base64 } => { ServerMessage::UploadRequest { request_id, path, content_base64 } => {
let payload = trunc(&path, 60); let payload = trunc(&path, 60);
display::cmd_start("", "upload", &payload); display::cmd_start("📁", "upload", &payload);
match (|| -> Result<(), String> { match (|| -> Result<(), String> {
let bytes = base64::engine::general_purpose::STANDARD let bytes = base64::engine::general_purpose::STANDARD
.decode(&content_base64) .decode(&content_base64)
@ -532,11 +532,11 @@ async fn handle_message(
Ok(()) Ok(())
})() { })() {
Ok(()) => { Ok(()) => {
display::cmd_done("", "upload", &payload, true, "saved"); display::cmd_done("📁", "upload", &payload, true, "saved");
ClientMessage::Ack { request_id } ClientMessage::Ack { request_id }
} }
Err(e) => { Err(e) => {
display::cmd_done("", "upload", &payload, false, &e); display::cmd_done("📁", "upload", &payload, false, &e);
ClientMessage::Error { request_id, message: e } ClientMessage::Error { request_id, message: e }
} }
} }
@ -544,16 +544,16 @@ async fn handle_message(
ServerMessage::DownloadRequest { request_id, path } => { ServerMessage::DownloadRequest { request_id, path } => {
let payload = trunc(&path, 60); let payload = trunc(&path, 60);
display::cmd_start("", "download", &payload); display::cmd_start("📁", "download", &payload);
match std::fs::read(&path) { match std::fs::read(&path) {
Ok(bytes) => { Ok(bytes) => {
let size = bytes.len() as u64; let size = bytes.len() as u64;
let content_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes); 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 } ClientMessage::DownloadResponse { request_id, content_base64, size }
} }
Err(e) => { 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}") } ClientMessage::Error { request_id, message: format!("Read failed: {e}") }
} }
} }