use iced::widget::{Column, Row, container, text, tooltip};
use iced::{Alignment, Element, Length};
use crate::agent::registry::RunningTool;
use crate::gui::theme;
use crate::gui::widgets;
const MAX_TOOL_VALUE_CHARS: usize = 104;
const MAX_TOOL_PAIRS_LINE_CHARS: usize = 208;
pub(crate) const MAX_TOOL_TOOLTIP_WIDTH: f32 = 560.0;
fn is_delim(c: char) -> bool {
c.is_whitespace() || matches!(c, '/' | '_' | '-')
}
pub(crate) fn truncate_at_boundary(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let hard_end = s
.char_indices()
.nth(max_chars)
.map(|(byte_idx, _)| byte_idx)
.expect("char count exceeds max_chars, so a char exists at index max_chars");
if s[hard_end..].chars().next().is_some_and(is_delim) {
return format!("{}…", s[..hard_end].trim_end());
}
let mut cut = None;
for (char_pos, (byte_idx, c)) in s.char_indices().enumerate() {
if char_pos >= max_chars {
break;
}
if is_delim(c) {
cut = Some(byte_idx + c.len_utf8());
}
}
let end = cut.unwrap_or(hard_end);
format!("{}…", s[..end].trim_end())
}
pub(crate) fn collapse_control_chars(s: &str, max_chars: usize) -> String {
s.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.take(max_chars + 1)
.collect()
}
fn value_display(value: &str) -> (String, bool) {
let single_line = collapse_control_chars(value, MAX_TOOL_VALUE_CHARS);
let was_char_cut = single_line.chars().count() > MAX_TOOL_VALUE_CHARS;
let display = truncate_at_boundary(&single_line, MAX_TOOL_VALUE_CHARS);
(display, was_char_cut)
}
fn tool_pairs_summary(tool: &RunningTool, view: ToolBlockView) -> (String, bool) {
let compact = view == ToolBlockView::Compact;
if tool.args.is_empty() {
return (String::new(), false);
}
if compact && tool.args.len() == 1 {
let (display, _) = value_display(&tool.args[0].1);
return (display, true);
}
let mut omits = false;
let rendered: Vec<String> = tool
.args
.iter()
.map(|(k, v)| {
let (display, cut) = value_display(v);
omits |= cut;
format!("{k}: {display}")
})
.collect();
let joined = rendered.join(", ");
if joined.chars().count() <= MAX_TOOL_PAIRS_LINE_CHARS {
return (joined, omits);
}
let mut out = String::new();
for pair in &rendered {
let sep = if out.is_empty() { "" } else { ", " };
let candidate_len = out.chars().count() + sep.chars().count() + pair.chars().count();
if candidate_len + 1 > MAX_TOOL_PAIRS_LINE_CHARS {
break;
}
out.push_str(sep);
out.push_str(pair);
}
if out.is_empty() {
(
truncate_at_boundary(&rendered[0], MAX_TOOL_PAIRS_LINE_CHARS),
true,
)
} else {
(format!("{out}…"), true)
}
}
fn tool_tooltip<Message: 'static>(tool: &RunningTool) -> Element<'static, Message> {
let mut pairs = tool.args.clone();
pairs.sort_by_key(|(_, v)| v.chars().count());
let mut content = Column::new().spacing(theme::SPACE_2).push(
text(tool.name.clone())
.size(theme::TEXT_11)
.font(theme::FONT_BOLD)
.color(theme::TEXT_PRIMARY),
);
for (k, v) in &pairs {
content = content.push(
text(format!("{k}: {v}"))
.size(theme::TEXT_11)
.font(crate::gui::JETBRAINS_MONO)
.color(theme::TEXT_SECONDARY)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
);
}
container(content).max_width(MAX_TOOL_TOOLTIP_WIDTH).into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolBlockView {
Compact,
Full,
}
pub(crate) fn tool_block<Message: 'static>(
tool: &RunningTool,
view: ToolBlockView,
) -> Element<'static, Message> {
let name: Element<'static, Message> = match view {
ToolBlockView::Full => widgets::selectable_text(tool.name.clone(), theme::TEXT_PRIMARY)
.size(theme::TEXT_11)
.font(theme::FONT_BOLD)
.into(),
ToolBlockView::Compact => text(tool.name.clone())
.size(theme::TEXT_11)
.font(theme::FONT_BOLD)
.color(theme::TEXT_PRIMARY)
.into(),
};
let align_y = match view {
ToolBlockView::Compact => Alignment::Center,
ToolBlockView::Full => Alignment::Start,
};
let mut line = Row::new()
.spacing(theme::SPACE_4)
.align_y(align_y)
.push(name);
let (args_text, omits) = tool_pairs_summary(tool, view);
if !args_text.is_empty() {
let args: Element<'static, Message> = match view {
ToolBlockView::Full => widgets::selectable_text(args_text, theme::TEXT_SECONDARY)
.size(theme::TEXT_11)
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into(),
ToolBlockView::Compact => text(args_text)
.size(theme::TEXT_11)
.color(theme::TEXT_SECONDARY)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into(),
};
line = line.push(args);
}
if omits {
tooltip(line, tool_tooltip(tool), tooltip::Position::Top)
.gap(theme::SPACE_4)
.style(theme::tooltip_style)
.into()
} else {
line.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_at_boundary_cuts_at_word_and_path_delimiters() {
assert_eq!(truncate_at_boundary("abc def ghi", 5), "abc…");
assert_eq!(truncate_at_boundary("abc def ghi", 7), "abc def…");
assert_eq!(
truncate_at_boundary("/Users/egordezic/Desktop/foo.rs", 26),
"/Users/egordezic/Desktop/…"
);
assert_eq!(truncate_at_boundary("aaaaaaaa", 4), "aaaa…");
assert_eq!(truncate_at_boundary("short", 10), "short");
}
#[test]
fn value_display_truncates_at_delimiter_boundary() {
assert_eq!(
value_display("/Users/egordezic/Desktop/foo.rs"),
("/Users/egordezic/Desktop/foo.rs".to_string(), false)
);
assert_eq!(value_display("a\nb"), ("a b".to_string(), false));
assert_eq!(
value_display(&"a".repeat(110)),
(format!("{}…", "a".repeat(MAX_TOOL_VALUE_CHARS)), true)
);
let long_path = format!("{}/{}", "a".repeat(60), "b".repeat(60));
assert_eq!(
value_display(&long_path),
(format!("{}…", "a".repeat(60) + "/"), true)
);
}
#[test]
fn tool_pairs_summary_truncates_at_pair_boundary() {
let short = RunningTool {
name: "read_file".to_string(),
args: vec![("path".to_string(), "a.rs".to_string())],
};
assert_eq!(
tool_pairs_summary(&short, ToolBlockView::Full),
("path: a.rs".to_string(), false)
);
let tool = RunningTool {
name: "read_file".to_string(),
args: vec![
(
"path".to_string(),
"/Users/egordezic/Desktop/project/aaa/foo_long.rs".to_string(),
),
(
"offset".to_string(),
"/Users/egordezic/Desktop/project/bbb/bar_long.rs".to_string(),
),
(
"limit".to_string(),
"/Users/egordezic/Desktop/project/ccc/baz_long.rs".to_string(),
),
(
"query".to_string(),
"/Users/egordezic/Desktop/project/ddd/qux_long.rs".to_string(),
),
(
"sort".to_string(),
"/Users/egordezic/Desktop/project/eee/quux_long.rs".to_string(),
),
(
"desc".to_string(),
"/Users/egordezic/Desktop/project/fff/corge_long.rs".to_string(),
),
],
};
let (line, omits) = tool_pairs_summary(&tool, ToolBlockView::Full);
assert!(line.chars().count() <= MAX_TOOL_PAIRS_LINE_CHARS);
assert!(line.ends_with('…'));
assert!(line.contains("path"));
assert!(!line.contains("desc"), "excluded pair must not appear");
assert!(omits, "line cut must report omission");
}
#[test]
fn tool_pairs_summary_fallback_truncates_first_pair_at_boundary() {
let tool = RunningTool {
name: "read_file".to_string(),
args: vec![("k".repeat(220), "v".to_string())],
};
let (line, omits) = tool_pairs_summary(&tool, ToolBlockView::Full);
assert_eq!(line, format!("{}…", "k".repeat(MAX_TOOL_PAIRS_LINE_CHARS)));
assert!(omits, "line cut must report omission");
}
#[test]
fn tool_pairs_summary_omits_only_when_information_is_hidden() {
let single = RunningTool {
name: "read_file".to_string(),
args: vec![("path".to_string(), "a.rs".to_string())],
};
assert_eq!(
tool_pairs_summary(&single, ToolBlockView::Compact),
("a.rs".to_string(), true)
);
assert_eq!(
tool_pairs_summary(&single, ToolBlockView::Full),
("path: a.rs".to_string(), false)
);
let over_cap = RunningTool {
name: "read_file".to_string(),
args: vec![("path".to_string(), "a".repeat(120))],
};
let (line, omits) = tool_pairs_summary(&over_cap, ToolBlockView::Full);
assert!(omits);
assert!(line.ends_with('…'));
let empty = RunningTool {
name: "read_file".to_string(),
args: Vec::new(),
};
assert_eq!(
tool_pairs_summary(&empty, ToolBlockView::Full),
(String::new(), false)
);
}
}