diff --git a/crates/ui_terminal/src/lib.rs b/crates/ui_terminal/src/lib.rs index ddd01c76..e65af60e 100644 --- a/crates/ui_terminal/src/lib.rs +++ b/crates/ui_terminal/src/lib.rs @@ -10,6 +10,7 @@ pub mod slash_popup; pub mod state; pub mod streaming; pub mod terminal_color; +pub mod text_util; pub mod textarea; pub mod tool_renderers; pub mod tool_widget; diff --git a/crates/ui_terminal/src/message.rs b/crates/ui_terminal/src/message.rs index 50e0b452..03f0f9b0 100644 --- a/crates/ui_terminal/src/message.rs +++ b/crates/ui_terminal/src/message.rs @@ -3,6 +3,7 @@ use ratatui::prelude::*; use ratatui::widgets::{Paragraph, Wrap}; use tui_markdown as md; +use super::text_util::truncate_with_ellipsis; use super::tool_renderers::ToolRendererRegistry; use super::tool_widget::{is_full_width_parameter, should_hide_parameter, ToolWidget}; use code_assistant_core::ui::ToolStatus; @@ -355,10 +356,6 @@ impl ParameterValue { pub fn get_display_value(&self) -> String { // Truncate long values for regular parameters - if self.value.len() > 100 { - format!("{}...", &self.value[..97]) - } else { - self.value.clone() - } + truncate_with_ellipsis(&self.value, 100).into_owned() } } diff --git a/crates/ui_terminal/src/text_util.rs b/crates/ui_terminal/src/text_util.rs new file mode 100644 index 00000000..8d3b1236 --- /dev/null +++ b/crates/ui_terminal/src/text_util.rs @@ -0,0 +1,79 @@ +//! Text helpers shared by the widget renderers. +//! +//! Terminal rendering constantly needs "cut this to N columns". Doing that +//! with byte slices panics on multi-byte input (`&s[..n]` inside a `ü`), so +//! all truncation goes through these helpers. + +use std::borrow::Cow; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Longest prefix of `text` that fits into `max_width` terminal columns. +/// +/// Cuts on character boundaries and never splits a wide glyph. +pub fn truncate_to_width(text: &str, max_width: usize) -> &str { + let mut width = 0usize; + for (idx, ch) in text.char_indices() { + let ch_width = ch.width().unwrap_or(0); + if width + ch_width > max_width { + return &text[..idx]; + } + width += ch_width; + } + text +} + +/// Like [`truncate_to_width`], but marks a cut with a trailing `…`. +/// +/// The ellipsis is part of the budget, so the result never exceeds +/// `max_width` columns. +pub fn truncate_with_ellipsis(text: &str, max_width: usize) -> Cow<'_, str> { + if text.width() <= max_width { + return Cow::Borrowed(text); + } + if max_width == 0 { + return Cow::Borrowed(""); + } + Cow::Owned(format!("{}…", truncate_to_width(text, max_width - 1))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_short_text_untouched() { + assert_eq!(truncate_to_width("hello", 10), "hello"); + assert_eq!(truncate_with_ellipsis("hello", 10), "hello"); + } + + #[test] + fn cuts_on_char_boundaries() { + // "Übertragung" — the cut lands inside the multi-byte 'Ü' if done + // with byte indices. + assert_eq!(truncate_to_width("Übertragung", 1), "Ü"); + assert_eq!(truncate_to_width("für", 2), "fü"); + assert_eq!(truncate_with_ellipsis("für alle", 3), "fü…"); + } + + #[test] + fn never_splits_wide_glyphs() { + // CJK characters occupy two columns each. + assert_eq!(truncate_to_width("日本語", 3), "日"); + assert_eq!(truncate_to_width("日本語", 4), "日本"); + } + + #[test] + fn degenerate_widths() { + assert_eq!(truncate_to_width("abc", 0), ""); + assert_eq!(truncate_with_ellipsis("abc", 0), ""); + assert_eq!(truncate_with_ellipsis("abc", 1), "…"); + } + + #[test] + fn result_fits_the_budget() { + for max in 0..12 { + assert!(truncate_to_width("Fragebogen für Ü", max).width() <= max); + assert!(truncate_with_ellipsis("Fragebogen für Ü", max).width() <= max); + } + } +} diff --git a/crates/ui_terminal/src/tool_renderers/command_renderer.rs b/crates/ui_terminal/src/tool_renderers/command_renderer.rs index 2d0f5316..b8ae6317 100644 --- a/crates/ui_terminal/src/tool_renderers/command_renderer.rs +++ b/crates/ui_terminal/src/tool_renderers/command_renderer.rs @@ -11,6 +11,7 @@ use super::{ }; use crate::message::ToolUseBlock; use crate::terminal_color; +use crate::text_util::truncate_to_width; use code_assistant_core::ui::ToolStatus; /// Expand tab characters to spaces (4-space tab stops). @@ -71,11 +72,7 @@ impl ToolRenderer for CommandToolRenderer { .bg(bg), ); let max_cmd_len = row_width.saturating_sub(2); - let display = if cmd.value.len() > max_cmd_len { - &cmd.value[..max_cmd_len] - } else { - cmd.value.as_str() - }; + let display = truncate_to_width(&cmd.value, max_cmd_len); buf.set_string( area.x + 4, y, @@ -103,11 +100,7 @@ impl ToolRenderer for CommandToolRenderer { Style::default().bg(bg), ); let expanded = expand_tabs(line); - let display = if expanded.len() > row_width { - &expanded[..row_width] - } else { - expanded.as_str() - }; + let display = truncate_to_width(&expanded, row_width); buf.set_string( area.x + 2, y, diff --git a/crates/ui_terminal/src/tool_renderers/compact_renderer.rs b/crates/ui_terminal/src/tool_renderers/compact_renderer.rs index d4b01d6e..c6b53fdf 100644 --- a/crates/ui_terminal/src/tool_renderers/compact_renderer.rs +++ b/crates/ui_terminal/src/tool_renderers/compact_renderer.rs @@ -5,11 +5,13 @@ use ratatui::prelude::*; use ratatui::style::{Color, Modifier, Style}; +use unicode_width::UnicodeWidthStr; use super::{ push_error_history_line, render_error_line, render_tool_header, tool_header_line, ToolRenderer, }; use crate::message::ToolUseBlock; +use crate::text_util::truncate_to_width; use code_assistant_core::ui::ToolStatus; /// Renderer for read/explore tools: read_files, list_files, list_projects, @@ -46,15 +48,11 @@ impl ToolRenderer for CompactToolRenderer { CompactLine::Item(text) => { buf.set_string(area.x + 2, y, "- ", Style::default().fg(Color::DarkGray)); let max_len = area.width.saturating_sub(4) as usize; - let display = if text.len() > max_len { - &text[..max_len] - } else { - text.as_str() - }; + let display = truncate_to_width(&text, max_len); buf.set_string(area.x + 4, y, display, Style::default().fg(Color::Gray)); } CompactLine::KeyValue(key, value) => { - let key_len = key.len() as u16; + let key_len = key.width() as u16; buf.set_string(area.x + 2, y, &key, Style::default().fg(Color::Cyan)); buf.set_string( area.x + 2 + key_len, @@ -63,11 +61,7 @@ impl ToolRenderer for CompactToolRenderer { Style::default().fg(Color::White), ); let max_len = area.width.saturating_sub(4 + key_len) as usize; - let display = if value.len() > max_len { - &value[..max_len] - } else { - value.as_str() - }; + let display = truncate_to_width(&value, max_len); buf.set_string( area.x + 4 + key_len, y, diff --git a/crates/ui_terminal/src/tool_renderers/mod.rs b/crates/ui_terminal/src/tool_renderers/mod.rs index f4bec22c..5b642e94 100644 --- a/crates/ui_terminal/src/tool_renderers/mod.rs +++ b/crates/ui_terminal/src/tool_renderers/mod.rs @@ -15,6 +15,7 @@ use ratatui::prelude::*; use ratatui::style::{Color, Modifier, Style}; use super::message::ToolUseBlock; +use super::text_util::truncate_to_width; use code_assistant_core::ui::ToolStatus; /// Trait for custom tool block renderers. @@ -154,11 +155,7 @@ pub fn render_error_line(tool_block: &ToolUseBlock, area: Rect, buf: &mut Buffer if let Some(ref message) = tool_block.status_message { if y < area.y + area.height { let max_len = area.width.saturating_sub(2) as usize; - let display = if message.len() > max_len { - &message[..max_len] - } else { - message.as_str() - }; + let display = truncate_to_width(message, max_len); buf.set_string(area.x + 2, y, display, Style::default().fg(Color::LightRed)); return y + 1; } diff --git a/crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs b/crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs index b3c7826a..2b95eaf3 100644 --- a/crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs +++ b/crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs @@ -15,6 +15,7 @@ use ratatui::widgets::Paragraph; use super::{status_color, ToolRenderer}; use crate::message::ToolUseBlock; +use crate::text_util::truncate_with_ellipsis; use code_assistant_core::agent::sub_agent::{SubAgentOutput, SubAgentToolStatus}; use code_assistant_core::ui::ToolStatus; @@ -47,16 +48,6 @@ impl ToolRenderer for SubAgentToolRenderer { } } -/// Truncate `text` to at most `max` chars, appending `…` when cut. -fn truncate(text: &str, max: usize) -> String { - if text.chars().count() > max { - let cut: String = text.chars().take(max).collect(); - format!("{cut}…") - } else { - text.to_string() - } -} - /// Build the styled lines for a sub-agent tool block. Shared by the live /// viewport, the height calculation, and the scrollback history. fn sub_agent_lines(tool_block: &ToolUseBlock) -> Vec> { @@ -74,7 +65,8 @@ fn sub_agent_lines(tool_block: &ToolUseBlock) -> Vec> { ), ]; if let Some(instructions) = tool_block.parameters.get("instructions") { - let summary = truncate(instructions.value.trim(), INSTRUCTIONS_SUMMARY_LEN); + let summary = + truncate_with_ellipsis(instructions.value.trim(), INSTRUCTIONS_SUMMARY_LEN); if !summary.is_empty() { header.push(Span::styled( format!(": {summary}"), diff --git a/crates/ui_terminal/src/tool_widget.rs b/crates/ui_terminal/src/tool_widget.rs index 77fc8438..200136bb 100644 --- a/crates/ui_terminal/src/tool_widget.rs +++ b/crates/ui_terminal/src/tool_widget.rs @@ -2,6 +2,7 @@ use code_assistant_core::ui::ToolStatus; use ratatui::prelude::*; use super::message::ToolUseBlock; +use super::text_util::{truncate_to_width, truncate_with_ellipsis}; use super::tool_renderers::ToolRendererRegistry; /// Custom ratatui widget for rendering tool use blocks. @@ -149,11 +150,7 @@ impl<'a> ToolWidget<'a> { // Error status message if let Some(ref message) = self.tool_block.status_message { if self.tool_block.status == ToolStatus::Error && current_y < area.y + area.height { - let display_text = if message.len() > area.width as usize { - &message[..area.width as usize] - } else { - message - }; + let display_text = truncate_to_width(message, area.width as usize); buf.set_string( area.x + 2, current_y, @@ -173,15 +170,12 @@ impl<'a> ToolWidget<'a> { if current_y >= area.y + area.height { break; } - let truncated = if line.len() > (area.width.saturating_sub(4)) as usize { - format!("{}...", &line[..(area.width.saturating_sub(7)) as usize]) - } else { - line.to_string() - }; + let truncated = + truncate_with_ellipsis(line, area.width.saturating_sub(4) as usize); buf.set_string( area.x + 2, current_y, - &truncated, + truncated.as_ref(), Style::default().fg(Color::Gray), ); current_y += 1; @@ -212,3 +206,38 @@ pub(super) fn should_hide_parameter(tool_name: &str, param_name: &str, param_val _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::{ParameterValue, ToolUseBlock}; + use indexmap::IndexMap; + + /// Output lines containing multi-byte characters used to panic here: + /// the truncation sliced by byte index and landed inside a `ü`. + #[test] + fn fallback_renders_multibyte_output_without_panicking() { + let mut parameters = IndexMap::new(); + parameters.insert( + "path".to_string(), + ParameterValue::new("Übertragungsprotokoll für die steuerliche Erfassung".to_string()), + ); + let tool = ToolUseBlock { + // A tool without a registered renderer → generic fallback. + name: "delete_files".to_string(), + id: "test-id".to_string(), + parameters, + status: ToolStatus::Error, + status_message: Some("Datei konnte nicht gelöscht werden — Zugriff verweigert".into()), + output: Some( + "/Users/x/Downloads/050_202_00962_Uebertragungsprotokoll_Fragebogen_zur_steuerlichen_Erfassung_für_Einzelunternehmen.pdf\n日本語のファイル名.pdf".to_string(), + ), + }; + + for width in [1u16, 7, 20, 40, 104, 120] { + let area = Rect::new(0, 0, width, 10); + let mut buf = Buffer::empty(area); + ToolWidget::new(&tool).render(area, &mut buf); + } + } +}