Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/ui_terminal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 2 additions & 5 deletions crates/ui_terminal/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
}
}
79 changes: 79 additions & 0 deletions crates/ui_terminal/src/text_util.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
13 changes: 3 additions & 10 deletions crates/ui_terminal/src/tool_renderers/command_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 5 additions & 11 deletions crates/ui_terminal/src/tool_renderers/compact_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 2 additions & 5 deletions crates/ui_terminal/src/tool_renderers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 3 additions & 11 deletions crates/ui_terminal/src/tool_renderers/sub_agent_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Line<'static>> {
Expand All @@ -74,7 +65,8 @@ fn sub_agent_lines(tool_block: &ToolUseBlock) -> Vec<Line<'static>> {
),
];
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}"),
Expand Down
51 changes: 40 additions & 11 deletions crates/ui_terminal/src/tool_widget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Loading