1use std::cell::RefCell;
2use std::rc::Rc;
3use std::time::Instant;
4
5use crate::agent::extension::{ToolRenderContext, ToolRenderer};
6use crate::agent::ui::theme::{RabTheme, current_theme};
7use crate::tui::Component;
8use crate::tui::component::{RenderCache, RenderCacheKey};
9use crate::tui::components::Text;
10use crate::tui::components::r#box::TuiBox;
11use crate::tui::keybindings::{self, ACTION_APP_TOOLS_EXPAND};
12use crate::tui::util::truncate_to_width;
13
14const PREVIEW_LINES: usize = 10;
16
17const BASH_PREVIEW_LINES: usize = 5;
19
20pub struct ToolExecComponent {
29 name: String,
30 renderer: Option<Box<dyn ToolRenderer>>,
31 args: serde_json::Value,
32 output: Option<String>,
33 is_error: bool,
34 is_complete: bool,
35 expanded: bool,
36 started_at: Option<Instant>,
38 final_duration: Option<f64>,
41 last_timer_tick: Option<Instant>,
43 is_bash: bool,
45 was_truncated: bool,
46 full_output_path: Option<String>,
47 exit_code: Option<i32>,
48 cancelled: bool,
49 file_path: Option<String>,
51 dirty: bool,
53 cache: Option<RenderCache>,
55}
56
57impl ToolExecComponent {
58 pub fn new(
59 name: impl Into<String>,
60 renderer: Option<Box<dyn ToolRenderer>>,
61 args: serde_json::Value,
62 ) -> Self {
63 Self {
64 name: name.into(),
65 renderer,
66 args,
67 output: None,
68 is_error: false,
69 is_complete: false,
70 expanded: false,
71 started_at: None,
72 final_duration: None,
73 last_timer_tick: None,
74 is_bash: false,
75 was_truncated: false,
76 full_output_path: None,
77 exit_code: None,
78 cancelled: false,
79 file_path: None,
80 dirty: true,
81 cache: None,
82 }
83 }
84
85 pub fn set_started_at(&mut self, instant: std::time::Instant) {
89 self.started_at = Some(instant);
90 self.last_timer_tick = Some(instant);
92 self.mark_dirty();
93 }
94
95 pub fn set_file_path(&mut self, path: impl Into<String>) {
96 self.file_path = Some(path.into());
97 self.mark_dirty();
98 }
99
100 pub fn set_bash(&mut self, is_bash: bool) {
101 self.is_bash = is_bash;
102 self.mark_dirty();
103 }
104
105 pub fn set_final_duration(&mut self, secs: f64) {
108 self.final_duration = Some(secs);
109 self.mark_dirty();
110 }
111
112 pub fn set_truncated(&mut self, truncated: bool, full_output_path: Option<String>) {
113 self.was_truncated = truncated;
114 self.full_output_path = full_output_path;
115 self.mark_dirty();
116 }
117
118 pub fn set_exit_code(&mut self, code: i32) {
119 self.exit_code = Some(code);
120 self.mark_dirty();
121 }
122
123 pub fn set_cancelled(&mut self, cancelled: bool) {
124 self.cancelled = cancelled;
125 self.mark_dirty();
126 }
127
128 pub fn set_result(&mut self, output: impl Into<String>, is_error: bool) {
129 self.output = Some(output.into());
130 self.is_error = is_error;
131 self.is_complete = true;
132 if self.final_duration.is_none()
135 && let Some(start) = self.started_at
136 {
137 self.final_duration = Some(start.elapsed().as_secs_f64());
138 }
139 self.mark_dirty();
140 }
141
142 pub fn set_args(&mut self, args: serde_json::Value) {
143 self.args = args;
144 self.mark_dirty();
145 }
146
147 fn mark_dirty(&mut self) {
149 self.dirty = true;
150 self.cache = None;
151 }
152
153 fn live_duration(&self) -> Option<f64> {
157 if let Some(dur) = self.final_duration {
158 return Some(dur);
159 }
160 self.started_at.map(|t| t.elapsed().as_secs_f64())
161 }
162
163 pub fn tick_timer(&mut self) -> bool {
168 if self.is_complete || self.started_at.is_none() {
169 return false;
170 }
171 let now = Instant::now();
172 let should_invalidate = self
173 .last_timer_tick
174 .is_none_or(|last| now.duration_since(last) >= std::time::Duration::from_secs(1));
175 if should_invalidate {
176 self.last_timer_tick = Some(now);
177 self.mark_dirty();
178 return true;
179 }
180 false
181 }
182
183 fn state_hash(&self) -> u64 {
185 use std::collections::hash_map::DefaultHasher;
186 use std::hash::{Hash, Hasher};
187 let mut hasher = DefaultHasher::new();
188 self.name.hash(&mut hasher);
189 self.args.to_string().hash(&mut hasher);
190 self.is_error.hash(&mut hasher);
191 self.is_complete.hash(&mut hasher);
192 self.live_duration().map(|s| s.to_bits()).hash(&mut hasher);
195 self.exit_code.hash(&mut hasher);
196 self.cancelled.hash(&mut hasher);
197 self.was_truncated.hash(&mut hasher);
198 self.output.hash(&mut hasher);
199 hasher.finish()
200 }
201}
202
203impl Component for ToolExecComponent {
204 fn set_expanded(&mut self, expanded: bool) {
205 self.expanded = expanded;
206 self.mark_dirty();
207 }
208
209 fn render(&self, width: usize) -> Vec<String> {
210 let theme = current_theme();
211
212 if let Some(ref renderer) = self.renderer {
214 return self.render_with_renderer(renderer.as_ref(), &theme, width);
215 }
216
217 self.render_generic(&theme, width)
219 }
220
221 fn invalidate(&mut self) {
222 self.mark_dirty();
223 }
224
225 fn is_dirty(&self) -> bool {
226 self.dirty
227 }
228
229 fn clear_dirty(&mut self) {
230 self.dirty = false;
231 }
232
233 fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
234 Some(RenderCacheKey {
238 width,
239 expanded: self.expanded,
240 state_hash: self.state_hash(),
241 })
242 }
243
244 fn get_cached_render(&self) -> Option<&RenderCache> {
245 self.cache.as_ref()
246 }
247
248 fn set_cached_render(&mut self, cache: RenderCache) {
249 self.cache = Some(cache);
250 self.dirty = false;
251 }
252}
253
254impl ToolExecComponent {
255 fn render_with_renderer(
257 &self,
258 renderer: &dyn ToolRenderer,
259 theme: &RabTheme,
260 width: usize,
261 ) -> Vec<String> {
262 let is_partial = !self.is_complete;
263
264 let expand_key = crate::agent::ui::components::tool_messages::format_key_hint(
266 crate::tui::keybindings::ACTION_APP_TOOLS_EXPAND,
267 );
268 let ctx = ToolRenderContext {
269 expanded: self.expanded,
270 args_complete: self.is_complete,
271 is_partial,
272 is_error: self.is_error,
273 cwd: String::new(),
274 duration_secs: self.live_duration(),
275 exit_code: self.exit_code,
276 cancelled: self.cancelled,
277 was_truncated: self.was_truncated,
278 full_output_path: self.full_output_path.clone(),
279 file_path: self.file_path.clone(),
280 expand_key,
281 };
282
283 if renderer.render_self() {
285 let mut lines: Vec<String> = Vec::new();
286 lines.push(String::new());
288
289 let call_lines = renderer.render_call(&self.args, width, theme, &ctx);
290 if !call_lines.is_empty() {
291 lines.extend(call_lines);
292 }
293
294 if let Some(ref output) = self.output {
296 let result_lines = renderer.render_result(output, width, theme, &ctx);
297 if !result_lines.is_empty() {
298 lines.extend(result_lines);
299 }
300 }
301 return lines;
302 }
303
304 let bg_key = if !self.is_complete {
306 "toolPendingBg"
307 } else if self.is_error {
308 "toolErrorBg"
309 } else {
310 "toolSuccessBg"
311 };
312 let bg_ansi = theme.bg_ansi(bg_key).to_string();
313 let theme_clone = theme.clone();
314
315 let mut msg_box = TuiBox::new(
316 1,
317 1,
318 Some(std::boxed::Box::new(move |s: &str| -> String {
319 format!("{}{}\x1b[49m", bg_ansi, s)
320 })),
321 );
322
323 let call_lines = renderer.render_call(&self.args, width, &theme_clone, &ctx);
325 let header_text = Text::new(call_lines.join("\n"), 0, 0, None);
326 msg_box.add_child(std::boxed::Box::new(header_text));
327
328 if let Some(ref output) = self.output {
330 let result_lines = renderer.render_result(output, width, &theme_clone, &ctx);
331 if !result_lines.is_empty() {
332 let result_text = Text::new(result_lines.join("\n"), 0, 0, None);
333 msg_box.add_child(std::boxed::Box::new(result_text));
334 }
335 }
336
337 msg_box.render(width)
338 }
339
340 fn render_generic(&self, theme: &RabTheme, width: usize) -> Vec<String> {
342 let bg_key = if !self.is_complete {
343 "toolPendingBg"
344 } else if self.is_error {
345 "toolErrorBg"
346 } else {
347 "toolSuccessBg"
348 };
349 let bg_ansi = theme.bg_ansi(bg_key).to_string();
350
351 let mut msg_box = TuiBox::new(
352 1,
353 1,
354 Some(std::boxed::Box::new(move |s: &str| -> String {
355 format!("{}{}\x1b[49m", bg_ansi, s)
356 })),
357 );
358
359 let header_styled = format_generic_call_header(&self.name, &self.args, theme);
361 let header_text = Text::new(header_styled, 0, 0, None);
362 msg_box.add_child(std::boxed::Box::new(header_text));
363
364 let skip_output = self.name == "write" && self.is_complete && !self.is_error;
366 if let Some(ref output) = self.output
367 && !skip_output
368 {
369 if self.is_bash {
370 msg_box.add_child(std::boxed::Box::new(BashResult::new(
371 output,
372 self.is_error,
373 self.expanded,
374 self.live_duration(),
375 self.was_truncated,
376 self.full_output_path.as_deref(),
377 self.exit_code,
378 self.cancelled,
379 theme,
380 )));
381 } else {
382 if crate::tui::util::is_image_line(output) {
384 let kitty_seq = crate::tui::image::kitty_image_sequence(output);
385 if !kitty_seq.is_empty() {
386 msg_box.add_child(std::boxed::Box::new(Text::new(kitty_seq, 0, 0, None)));
388 msg_box.add_child(std::boxed::Box::new(Text::new(
390 String::new(),
391 0,
392 0,
393 None,
394 )));
395 } else {
396 msg_box.add_child(std::boxed::Box::new(Text::new(
397 output.clone(),
398 0,
399 0,
400 None,
401 )));
402 }
403 } else {
404 let fg_key = if self.is_error { "error" } else { "toolOutput" };
405 let fg_ansi = theme.fg_ansi(fg_key).to_string();
406
407 let display_text = if self.expanded {
408 output.clone()
409 } else {
410 let lines: Vec<&str> = output.lines().collect();
411 if lines.len() > PREVIEW_LINES {
412 let preview = lines[..PREVIEW_LINES].join("\n");
413 format!(
414 "{}\n... ({} more lines)",
415 preview,
416 lines.len() - PREVIEW_LINES
417 )
418 } else {
419 output.clone()
420 }
421 };
422
423 let styled_lines: Vec<String> = if self.name == "read" && !self.is_error {
425 if let Some(ref path) = self.file_path {
426 let lang = crate::tui::components::path_to_language(path);
427 #[cfg(feature = "syntect")]
428 if lang.is_some() {
429 let hl =
430 crate::tui::components::highlight_code(&display_text, lang);
431 if !hl.is_empty() {
432 hl
433 } else {
434 display_text
435 .lines()
436 .map(|line| format!("{}{}\x1b[39m", fg_ansi, line))
437 .collect()
438 }
439 } else {
440 display_text
441 .lines()
442 .map(|line| format!("{}{}\x1b[39m", fg_ansi, line))
443 .collect()
444 }
445 } else {
446 display_text
447 .lines()
448 .map(|line| format!("{}{}\x1b[39m", fg_ansi, line))
449 .collect()
450 }
451 } else {
452 display_text
453 .lines()
454 .map(|line| format!("{}{}\x1b[39m", fg_ansi, line))
455 .collect()
456 };
457
458 let result_text = Text::new(styled_lines.join("\n"), 0, 0, None);
459 msg_box.add_child(std::boxed::Box::new(result_text));
460 }
461 }
462 }
463
464 msg_box.render(width)
465 }
466}
467
468fn format_generic_call_header(name: &str, args: &serde_json::Value, theme: &RabTheme) -> String {
470 match name {
471 "bash" => {
472 let cmd = args
473 .get("command")
474 .and_then(|v| v.as_str())
475 .unwrap_or("...");
476 let timeout = args.get("timeout").and_then(|v| v.as_i64());
477 let timeout_suffix = timeout
478 .map(|t| theme.fg("muted", &format!(" (timeout {}s)", t)))
479 .unwrap_or_default();
480 format!(
481 "{}{}",
482 theme.fg("toolTitle", &theme.bold(&format!("$ {}", cmd))),
483 timeout_suffix
484 )
485 }
486 "read" => {
487 let path = args
488 .get("file_path")
489 .or_else(|| args.get("path"))
490 .and_then(|v| v.as_str())
491 .unwrap_or("");
492 let short = shorten_path(path);
493 let path_disp = if short.is_empty() {
494 String::new()
495 } else {
496 theme.fg("accent", &short)
497 };
498 let range = format_line_range(args, theme);
499 format!(
500 "{} {} {}",
501 theme.fg("toolTitle", &theme.bold("read")),
502 path_disp,
503 range
504 )
505 }
506 "write" => {
507 let path = args
508 .get("file_path")
509 .or_else(|| args.get("path"))
510 .and_then(|v| v.as_str())
511 .unwrap_or("");
512 let short = shorten_path(path);
513 let path_disp = if short.is_empty() {
514 String::new()
515 } else {
516 theme.fg("accent", &short)
517 };
518 format!(
519 "{} {}",
520 theme.fg("toolTitle", &theme.bold("write")),
521 path_disp
522 )
523 }
524 "edit" => {
525 let path = args
526 .get("file_path")
527 .or_else(|| args.get("path"))
528 .and_then(|v| v.as_str())
529 .unwrap_or("");
530 let short = shorten_path(path);
531 let path_disp = if short.is_empty() {
532 String::new()
533 } else {
534 theme.fg("accent", &short)
535 };
536 format!(
537 "{} {}",
538 theme.fg("toolTitle", &theme.bold("edit")),
539 path_disp
540 )
541 }
542 "ls" => {
543 let path = args
544 .get("file_path")
545 .or_else(|| args.get("path"))
546 .and_then(|v| v.as_str())
547 .unwrap_or(".");
548 let limit = args.get("limit").and_then(|v| v.as_u64());
549 let short = shorten_path(path);
550 let limit_str = limit.map(|l| format!(" (limit {})", l)).unwrap_or_default();
551 format!(
552 "{} {}{}",
553 theme.fg("toolTitle", &theme.bold("ls")),
554 theme.fg("accent", &short),
555 limit_str
556 )
557 }
558 _ => {
559 let args_str = serde_json::to_string(args).unwrap_or_default();
560 let suffix = if args_str.is_empty() || args_str == "{}" {
561 String::new()
562 } else {
563 format!(" {}", theme.fg("muted", &args_str))
564 };
565 format!("{}{}", theme.fg("toolTitle", &theme.bold(name)), suffix)
566 }
567 }
568}
569
570fn format_line_range(args: &serde_json::Value, theme: &RabTheme) -> String {
572 let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
573 let limit = args.get("limit").and_then(|v| v.as_u64());
574 if offset == 0 && limit.is_none() {
575 return String::new();
576 }
577 let start = if offset > 0 { offset } else { 1 };
578 let range_str = match limit {
579 Some(l) => format!(":{}-{}", start, start + l - 1),
580 None => format!(":{}", start),
581 };
582 theme.fg("warning", &range_str)
583}
584
585fn shorten_path(path: &str) -> String {
587 if let Ok(home) = std::env::var("HOME") {
588 path.replacen(&home, "~", 1)
589 } else {
590 path.to_string()
591 }
592}
593
594fn format_key_hint(action_id: &str) -> String {
596 let keys = keybindings::get_keybindings().get_keys(action_id);
599 if keys.is_empty() {
600 return String::new();
601 }
602 keys[0].clone()
603}
604
605struct BashResult {
610 output: String,
611 is_error: bool,
612 expanded: bool,
613 duration_secs: Option<f64>,
614 was_truncated: bool,
615 full_output_path: Option<String>,
616 exit_code: Option<i32>,
617 cancelled: bool,
618 theme: RabTheme,
619}
620
621impl BashResult {
622 #[allow(clippy::too_many_arguments)]
623 fn new(
624 output: &str,
625 is_error: bool,
626 expanded: bool,
627 duration_secs: Option<f64>,
628 was_truncated: bool,
629 full_output_path: Option<&str>,
630 exit_code: Option<i32>,
631 cancelled: bool,
632 theme: &RabTheme,
633 ) -> Self {
634 let clean_output = strip_context_truncation_footer(output);
635 Self {
636 output: clean_output,
637 is_error,
638 expanded,
639 duration_secs,
640 was_truncated,
641 full_output_path: full_output_path.map(|s| s.to_string()),
642 exit_code,
643 cancelled,
644 theme: theme.clone(),
645 }
646 }
647}
648
649impl Component for BashResult {
650 fn render(&self, width: usize) -> Vec<String> {
651 let theme = &self.theme;
652 let fg_ansi = if self.is_error {
653 theme.fg_ansi("error")
654 } else {
655 theme.fg_ansi("toolOutput")
656 }
657 .to_string();
658 let dim_ansi = theme.fg_ansi("muted").to_string();
659 let warning_ansi = theme.fg_ansi("warning").to_string();
660 let expand_key = format_key_hint(ACTION_APP_TOOLS_EXPAND);
661
662 let mut lines: Vec<String> = Vec::new();
663
664 let all_lines: Vec<&str> = self.output.split('\n').collect();
665
666 if all_lines.is_empty() || (all_lines.len() == 1 && all_lines[0].is_empty()) {
667 return lines;
668 }
669
670 let (preview_lines, hidden_line_count) = if self.expanded {
672 (all_lines.clone(), 0)
673 } else {
674 truncate_to_visual_lines(&all_lines, width, BASH_PREVIEW_LINES)
675 };
676
677 if !self.expanded && hidden_line_count > 0 {
678 let hint = if expand_key.is_empty() {
679 format!(
680 "\x1b[0m{}... {} earlier lines\x1b[39m",
681 dim_ansi, hidden_line_count
682 )
683 } else {
684 format!(
685 "\x1b[0m{}... ({} earlier lines, {} to expand)\x1b[39m",
686 dim_ansi, hidden_line_count, expand_key,
687 )
688 };
689 let truncated = truncate_to_width(&hint, width, "...", false);
690 lines.push(truncated);
691 }
692
693 for line in &preview_lines {
694 let styled = if line.is_empty() {
695 String::new()
696 } else {
697 format!("{}{}\x1b[39m", fg_ansi, line)
698 };
699 let truncated = truncate_to_width(&styled, width, "...", false);
700 lines.push(truncated);
701 }
702
703 let is_complete = self.exit_code.is_some() || self.cancelled;
704 if let Some(secs) = self.duration_secs {
705 let label = if is_complete { "Took" } else { "Elapsed" };
706 let duration_text = format!("{}{} {:.1}s\x1b[39m", dim_ansi, label, secs);
707 lines.push(duration_text);
708 }
709
710 if self.cancelled {
711 lines.push(format!("{} (cancelled)\x1b[39m", warning_ansi));
712 } else if let Some(code) = self.exit_code
713 && code != 0
714 {
715 lines.push(format!("{} (exit {})\x1b[39m", warning_ansi, code));
716 }
717
718 if self.was_truncated {
719 if let Some(ref path) = self.full_output_path {
720 lines.push(format!(
721 "{}Output truncated. Full output: {}\x1b[39m",
722 warning_ansi, path
723 ));
724 } else {
725 lines.push(format!("{}Output truncated.\x1b[39m", warning_ansi));
726 }
727 }
728
729 lines
730 }
731
732 fn invalidate(&mut self) {}
733}
734
735use crate::tui::visual_truncate::truncate_to_visual_lines;
740
741fn strip_context_truncation_footer(output: &str) -> String {
742 let lines: Vec<&str> = output.lines().collect();
743 if lines.len() < 3 {
744 return output.to_string();
745 }
746
747 let last = lines.last().map_or("", |v| v).trim();
748 if last.starts_with('[')
749 && (last.contains("Showing lines") || last.contains("Showing last"))
750 && last.contains("Full output:")
751 {
752 let before: Vec<&str> = lines[..lines.len() - 1].to_vec();
753 if !before.is_empty() && before[before.len() - 1].is_empty() {
754 before[..before.len() - 1].join("\n")
755 } else {
756 before.join("\n")
757 }
758 } else {
759 output.to_string()
760 }
761}
762
763pub struct RcToolExec(pub Rc<RefCell<ToolExecComponent>>);
768
769impl Clone for RcToolExec {
770 fn clone(&self) -> Self {
771 Self(self.0.clone())
772 }
773}
774
775impl Component for RcToolExec {
776 fn render(&self, width: usize) -> Vec<String> {
777 self.0.borrow().render(width)
778 }
779
780 fn set_expanded(&mut self, expanded: bool) {
781 self.0.borrow_mut().set_expanded(expanded);
782 }
783
784 fn invalidate(&mut self) {
785 self.0.borrow_mut().invalidate();
786 }
787
788 fn is_dirty(&self) -> bool {
789 self.0.borrow().is_dirty()
790 }
791
792 fn clear_dirty(&mut self) {
793 self.0.borrow_mut().clear_dirty();
794 }
795
796 fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
797 self.0.borrow().cache_key(width)
798 }
799
800 fn get_cached_render(&self) -> Option<&RenderCache> {
801 None
803 }
804
805 fn set_cached_render(&mut self, cache: RenderCache) {
806 self.0.borrow_mut().set_cached_render(cache);
807 }
808}
809
810pub struct ToolCallComponent {
815 name: String,
816 args: String,
817 expanded: bool,
818}
819
820impl ToolCallComponent {
821 pub fn new(name: impl Into<String>, args: impl Into<String>) -> Self {
822 Self {
823 name: name.into(),
824 args: args.into(),
825 expanded: false,
826 }
827 }
828}
829
830impl Component for ToolCallComponent {
831 fn set_expanded(&mut self, expanded: bool) {
832 self.expanded = expanded;
833 }
834 fn render(&self, width: usize) -> Vec<String> {
835 let theme = current_theme();
836 let bg_ansi = theme.bg_ansi("toolPendingBg").to_string();
837
838 let mut styled = String::new();
839 styled.push_str("\x1b[1m");
840 styled.push_str(theme.fg_ansi("toolTitle"));
841 styled.push_str(&self.name);
842 styled.push_str("\x1b[22m");
843
844 if !self.args.is_empty() && self.args != "{}" {
845 styled.push_str(" ");
846 styled.push_str(theme.fg_ansi("muted"));
847 styled.push_str(&self.args);
848 }
849 styled.push_str("\x1b[39m");
850
851 let mut msg_box = TuiBox::new(
852 1,
853 1,
854 Some(std::boxed::Box::new(move |s: &str| -> String {
855 format!("{}{}\x1b[49m", bg_ansi, s)
856 })),
857 );
858 msg_box.add_child(std::boxed::Box::new(Text::new(styled, 0, 0, None)));
859 msg_box.render(width)
860 }
861 fn invalidate(&mut self) {}
862}
863
864pub struct ToolResultComponent {
865 content: String,
866 is_error: bool,
867 expanded: bool,
868}
869
870impl ToolResultComponent {
871 pub fn new(content: impl Into<String>, is_error: bool) -> Self {
872 Self {
873 content: content.into(),
874 is_error,
875 expanded: false,
876 }
877 }
878}
879
880impl Component for ToolResultComponent {
881 fn set_expanded(&mut self, expanded: bool) {
882 self.expanded = expanded;
883 }
884 fn render(&self, width: usize) -> Vec<String> {
885 let theme = current_theme();
886 let bg_key = if self.is_error {
887 "toolErrorBg"
888 } else {
889 "toolSuccessBg"
890 };
891 let fg_key = if self.is_error { "error" } else { "toolOutput" };
892 let bg_ansi = theme.bg_ansi(bg_key).to_string();
893 let styled = theme.fg(fg_key, &self.content);
894
895 let mut msg_box = TuiBox::new(
896 1,
897 0,
898 Some(std::boxed::Box::new(move |s: &str| -> String {
899 format!("{}{}\x1b[49m", bg_ansi, s)
900 })),
901 );
902 msg_box.add_child(std::boxed::Box::new(Text::new(styled, 0, 0, None)));
903 msg_box.render(width)
904 }
905 fn invalidate(&mut self) {}
906}
907
908#[cfg(test)]
909mod tests {
910 use crate::tui::visual_truncate::{truncate_to_visual_lines, visual_line_count};
911
912 #[test]
913 fn test_visual_line_count_ascii() {
914 assert_eq!(visual_line_count("hello", 80), 1);
915 assert_eq!(visual_line_count("", 80), 1);
916 }
917
918 #[test]
919 fn test_visual_line_count_wrapping() {
920 let line = "a".repeat(100);
922 assert_eq!(visual_line_count(&line, 80), 2);
923
924 let line = "a".repeat(160);
926 assert_eq!(visual_line_count(&line, 80), 2);
927
928 let line = "a".repeat(161);
930 assert_eq!(visual_line_count(&line, 80), 3);
931 }
932
933 #[test]
934 fn test_visual_line_count_zero_width() {
935 assert_eq!(visual_line_count("hello", 0), 1);
936 }
937
938 #[test]
939 fn test_truncate_to_visual_lines_no_truncation() {
940 let lines = vec!["short", "also short"];
941 let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 10);
942 assert_eq!(selected.len(), 2);
943 assert_eq!(hidden, 0);
944 }
945
946 #[test]
947 fn test_truncate_to_visual_lines_with_wrapping() {
948 let line1 = "a".repeat(100);
950 let line2 = "b".repeat(100);
951 let line3 = "c".repeat(100);
952 let lines = vec![line1.as_str(), line2.as_str(), line3.as_str()];
953
954 let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 4);
957 assert_eq!(selected.len(), 2);
958 assert_eq!(hidden, 1);
959 assert_eq!(selected[0], line2.as_str());
960 assert_eq!(selected[1], line3.as_str());
961 }
962
963 #[test]
964 fn test_truncate_to_visual_lines_exact_fit() {
965 let line1 = "a".repeat(100);
967 let line2 = "b".repeat(100);
968 let lines = vec![line1.as_str(), line2.as_str()];
969
970 let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 4);
971 assert_eq!(selected.len(), 2);
972 assert_eq!(hidden, 0);
973 }
974
975 #[test]
976 fn test_truncate_to_visual_lines_empty() {
977 let lines: Vec<&str> = vec![];
978 let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 5);
979 assert!(selected.is_empty());
980 assert_eq!(hidden, 0);
981 }
982
983 #[test]
984 fn test_truncate_to_visual_lines_mixed_widths() {
985 let short1 = "short";
987 let long = "x".repeat(100); let short2 = "also short";
989 let lines = vec![short1, long.as_str(), short2];
990
991 let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 3);
994 assert_eq!(selected.len(), 2);
995 assert_eq!(hidden, 1);
996 assert_eq!(selected[0], long.as_str());
997 assert_eq!(selected[1], short2);
998 }
999}