返回 CodeWhale
tool_output.rs
根目录 / crates / tui / src / tui / history / tool_output.rs
1 //! Preserved tool-output rendering and line selection.
2
3 use ratatui::style::Style;
4 use ratatui::text::{Line, Span};
5 use serde_json::Value;
6 use unicode_segmentation::UnicodeSegmentation;
7 use unicode_width::UnicodeWidthStr;
8
9 use codewhale_palette as palette;
10
11 use super::constants::{TOOL_OUTPUT_HEAD_LINES, TOOL_OUTPUT_TAIL_LINES, TOOL_TEXT_LIMIT};
12 use super::{
13 RenderMode, details_affordance_line, looks_like_file_path, render_card_detail_line,
14 render_card_detail_line_single, render_card_detail_line_single_styled,
15 render_card_detail_line_styled, tool_value_style, truncate_text,
16 };
17
18 pub(super) fn render_tool_output_mode(
19 output: &str,
20 width: u16,
21 line_limit: usize,
22 mode: RenderMode,
23 ) -> Vec<Line<'static>> {
24 render_preserved_output_mode(output, width, line_limit, mode, "result")
25 }
26
27 pub(super) fn render_exec_output_mode(
28 output: &str,
29 width: u16,
30 line_limit: usize,
31 mode: RenderMode,
32 ) -> Vec<Line<'static>> {
33 render_preserved_output_mode(output, width, line_limit, mode, "output")
34 }
35
36 #[derive(Debug, Clone, PartialEq, Eq)]
37 pub struct OutputRow {
38 pub text: String,
39 pub intact: bool,
40 /// SGR-styled segments of `text` when the source line carried colour
41 /// (`cargo`, `git`, `gh` with colour forced on, anything run through a
42 /// PTY). Concatenated they equal `text`; `None` for plain rows so the
43 /// common path allocates nothing extra.
44 pub styled: Option<Vec<StyledSegment>>,
45 }
46
47 /// One run of `OutputRow::text` with the style the tool's own SGR codes
48 /// asked for. Only what is painted keeps the colour: the model, the session
49 /// store, the pager, clipboard and exports still see stripped text.
50 pub type StyledSegment = (String, Style);
51
52 /// Heuristic: does the output look like a unified diff? Returns true when
53 /// the output contains at least one hunk header (`@@`) or a `diff --git`
54 /// line, which are reliable markers of unified diff content (#380).
55 pub(crate) fn output_looks_like_diff(output: &str) -> bool {
56 let mut lines = output.lines();
57 // Check first 5 lines for diff markers
58 for _ in 0..5 {
59 let Some(line) = lines.next() else { break };
60 let trimmed = line.trim();
61 if trimmed.starts_with("@@") || trimmed.starts_with("diff --git") {
62 return true;
63 }
64 }
65 false
66 }
67
68 fn summarize_string_value(text: &str, max_len: usize, count_only: bool) -> String {
69 let trimmed = text.trim();
70 let len = trimmed.chars().count();
71 if count_only || len > max_len {
72 return format!("<{len} chars>");
73 }
74 truncate_text(trimmed, max_len)
75 }
76
77 fn summarize_inline_value(value: &Value, max_len: usize, count_only: bool) -> String {
78 match value {
79 Value::String(s) => summarize_string_value(s, max_len, count_only),
80 Value::Array(items) => format!("<{} items>", items.len()),
81 Value::Object(map) => format!("<{} keys>", map.len()),
82 Value::Bool(b) => b.to_string(),
83 Value::Number(num) => num.to_string(),
84 Value::Null => "null".to_string(),
85 }
86 }
87
88 fn is_noisy_tool_arg_key(key: &str) -> bool {
89 matches!(
90 key,
91 "limit"
92 | "max_count"
93 | "max_output_tokens"
94 | "offset"
95 | "page"
96 | "page_size"
97 | "per_page"
98 | "response_length"
99 | "timeout_ms"
100 | "yield_time_ms"
101 )
102 }
103
104 #[must_use]
105 pub fn summarize_tool_args(input: &Value) -> Option<String> {
106 let obj = input.as_object()?;
107 if obj.is_empty() {
108 return None;
109 }
110
111 let mut parts = Vec::new();
112
113 if let Some(value) = obj.get("path") {
114 parts.push(format!(
115 "path: {}",
116 summarize_inline_value(value, 80, false)
117 ));
118 }
119 if let Some(value) = obj.get("command") {
120 parts.push(format!(
121 "command: {}",
122 summarize_inline_value(value, 80, false)
123 ));
124 }
125 if let Some(value) = obj.get("query") {
126 parts.push(format!(
127 "query: {}",
128 summarize_inline_value(value, 80, false)
129 ));
130 }
131 if let Some(value) = obj.get("prompt") {
132 parts.push(format!(
133 "prompt: {}",
134 summarize_inline_value(value, 80, false)
135 ));
136 }
137 if let Some(value) = obj.get("text") {
138 parts.push(format!(
139 "text: {}",
140 summarize_inline_value(value, 80, false)
141 ));
142 }
143 if let Some(value) = obj.get("pattern") {
144 parts.push(format!(
145 "pattern: {}",
146 summarize_inline_value(value, 80, false)
147 ));
148 }
149 if let Some(value) = obj.get("model") {
150 parts.push(format!(
151 "model: {}",
152 summarize_inline_value(value, 40, false)
153 ));
154 }
155 if let Some(value) = obj.get("profile") {
156 parts.push(format!(
157 "profile: {}",
158 summarize_inline_value(value, 40, false)
159 ));
160 }
161 if let Some(value) = obj.get("level") {
162 parts.push(format!(
163 "level: {}",
164 summarize_inline_value(value, 40, false)
165 ));
166 }
167 if let Some(value) = obj.get("file_id") {
168 parts.push(format!(
169 "file_id: {}",
170 summarize_inline_value(value, 40, false)
171 ));
172 }
173 if let Some(value) = obj.get("task_id") {
174 parts.push(format!(
175 "task_id: {}",
176 summarize_inline_value(value, 40, false)
177 ));
178 }
179 if let Some(value) = obj.get("voice_id") {
180 parts.push(format!(
181 "voice_id: {}",
182 summarize_inline_value(value, 40, false)
183 ));
184 }
185 if let Some(value) = obj.get("content") {
186 parts.push(format!(
187 "content: {}",
188 summarize_inline_value(value, 0, true)
189 ));
190 }
191
192 if parts.is_empty()
193 && let Some((key, value)) = obj
194 .iter()
195 .find(|(key, _)| !is_noisy_tool_arg_key(key.as_str()))
196 {
197 return Some(format!(
198 "{}: {}",
199 key,
200 summarize_inline_value(value, 80, false)
201 ));
202 }
203
204 if parts.is_empty() {
205 None
206 } else {
207 Some(parts.join(", "))
208 }
209 }
210
211 #[must_use]
212 pub fn summarize_tool_output(output: &str) -> String {
213 if let Ok(json) = serde_json::from_str::<Value>(output) {
214 if let Some(obj) = json.as_object() {
215 if let Some(error) = obj.get("error").or(obj.get("status_msg")) {
216 return format!("Error: {}", summarize_inline_value(error, 120, false));
217 }
218
219 let mut parts = Vec::new();
220
221 if let Some(status) = obj.get("status").and_then(|v| v.as_str()) {
222 parts.push(format!("status: {status}"));
223 }
224 if let Some(message) = obj.get("message").and_then(|v| v.as_str()) {
225 parts.push(truncate_text(message, TOOL_TEXT_LIMIT));
226 }
227 if let Some(task_id) = obj.get("task_id").and_then(|v| v.as_str()) {
228 parts.push(format!("task_id: {task_id}"));
229 }
230 if let Some(file_id) = obj.get("file_id").and_then(|v| v.as_str()) {
231 parts.push(format!("file_id: {file_id}"));
232 }
233 if let Some(url) = obj
234 .get("file_url")
235 .or_else(|| obj.get("url"))
236 .and_then(|v| v.as_str())
237 {
238 parts.push(format!("url: {}", truncate_text(url, 120)));
239 }
240 if let Some(data) = obj.get("data") {
241 parts.push(format!("data: {}", summarize_inline_value(data, 80, true)));
242 }
243
244 if !parts.is_empty() {
245 return parts.join(" | ");
246 }
247
248 if let Some(content) = obj
249 .get("content")
250 .or(obj.get("result"))
251 .or(obj.get("output"))
252 {
253 return summarize_inline_value(content, TOOL_TEXT_LIMIT, false);
254 }
255 }
256
257 return summarize_inline_value(&json, TOOL_TEXT_LIMIT, true);
258 }
259
260 truncate_text(output, TOOL_TEXT_LIMIT)
261 }
262
263 /// Summary information extracted from an MCP tool output payload.
264 pub struct McpOutputSummary {
265 pub content: Option<String>,
266 pub is_image: bool,
267 pub is_error: Option<bool>,
268 }
269
270 /// Summarize raw MCP output into UI-friendly content.
271 #[must_use]
272 pub fn summarize_mcp_output(output: &str) -> McpOutputSummary {
273 if let Ok(json) = serde_json::from_str::<Value>(output) {
274 let is_error = json
275 .get("isError")
276 .and_then(serde_json::Value::as_bool)
277 .or_else(|| json.get("is_error").and_then(serde_json::Value::as_bool));
278
279 if let Some(blocks) = json.get("content").and_then(|v| v.as_array()) {
280 let mut lines = Vec::new();
281 let mut is_image = false;
282
283 for block in blocks {
284 let block_type = block
285 .get("type")
286 .and_then(|v| v.as_str())
287 .unwrap_or("unknown");
288 match block_type {
289 "text" => {
290 let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
291 if !text.is_empty() {
292 lines.push(format!("- text: {}", truncate_text(text, 200)));
293 }
294 }
295 "image" | "image_url" => {
296 is_image = true;
297 let url = block
298 .get("url")
299 .or_else(|| block.get("image_url"))
300 .and_then(|v| v.as_str());
301 if let Some(url) = url {
302 lines.push(format!("- image: {}", truncate_text(url, 200)));
303 } else {
304 lines.push("- image".to_string());
305 }
306 }
307 "resource" | "resource_link" => {
308 let uri = block
309 .get("uri")
310 .or_else(|| block.get("url"))
311 .and_then(|v| v.as_str())
312 .unwrap_or("<resource>");
313 lines.push(format!("- resource: {}", truncate_text(uri, 200)));
314 }
315 other => {
316 lines.push(format!("- {other} content"));
317 }
318 }
319 }
320
321 return McpOutputSummary {
322 content: if lines.is_empty() {
323 None
324 } else {
325 Some(lines.join("\n"))
326 },
327 is_image,
328 is_error,
329 };
330 }
331 }
332
333 McpOutputSummary {
334 content: Some(summarize_tool_output(output)),
335 is_image: output_is_image(output),
336 is_error: None,
337 }
338 }
339
340 #[must_use]
341 pub fn output_is_image(output: &str) -> bool {
342 // Sniff the extensions case-insensitively over the raw bytes. Lowercasing
343 // the whole output first copied every byte of a payload that can run to
344 // hundreds of kilobytes, once per MCP completion, to answer a question
345 // about eight ASCII suffixes.
346 const IMAGE_EXTENSIONS: [&str; 8] = [
347 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ppm",
348 ];
349
350 let bytes = output.as_bytes();
351 IMAGE_EXTENSIONS.iter().any(|ext| {
352 let needle = ext.as_bytes();
353 bytes
354 .windows(needle.len())
355 .any(|window| window.eq_ignore_ascii_case(needle))
356 })
357 }
358
359 fn render_preserved_output_mode(
360 output: &str,
361 width: u16,
362 line_limit: usize,
363 mode: RenderMode,
364 first_label: &str,
365 ) -> Vec<Line<'static>> {
366 let mut lines = Vec::new();
367 if output.trim().is_empty() {
368 // #3031: In compact/Live mode, suppress "(no output)" — the tool
369 // header already carries the success/failure status. Transcript
370 // mode still records it for exports/clipboard/pager.
371 if mode == RenderMode::Transcript {
372 lines.push(Line::from(Span::styled(
373 " (no output)",
374 Style::default().fg(palette::TEXT_MUTED).italic(),
375 )));
376 }
377 return lines;
378 }
379
380 // Hash once; reuse for both the rows cache and the indices cache below.
381 let content_hash = crate::tui::output_rows_cache::hash_str(output);
382 let all_lines =
383 crate::tui::output_rows_cache::get_or_compute_rows_with_hash(content_hash, width, || {
384 output_rows(output, width)
385 });
386
387 if matches!(mode, RenderMode::Transcript) {
388 // Full-content path: emit every wrapped line with no head/tail split,
389 // no "+N more" affordance.
390 for (idx, row) in all_lines.iter().enumerate() {
391 render_output_row(
392 &mut lines,
393 if idx == 0 { Some(first_label) } else { None },
394 row,
395 width,
396 );
397 }
398 return lines;
399 }
400
401 let selected = crate::tui::output_rows_cache::get_or_compute_indices(
402 content_hash,
403 width,
404 line_limit,
405 || selected_output_indices(&all_lines, line_limit),
406 );
407 let mut previous: Option<usize> = None;
408 for (rendered_idx, idx) in selected.iter().copied().enumerate() {
409 if let Some(prev) = previous {
410 let omitted = idx.saturating_sub(prev + 1);
411 if omitted > 0 {
412 lines.push(details_affordance_line(
413 &format!(
414 "{omitted} lines omitted; {}",
415 crate::tui::key_shortcuts::tool_details_shortcut_action_hint("output")
416 ),
417 Style::default().fg(palette::TEXT_MUTED),
418 ));
419 }
420 }
421
422 let row = &all_lines[idx];
423 render_output_row(
424 &mut lines,
425 if rendered_idx == 0 {
426 Some(first_label)
427 } else {
428 None
429 },
430 row,
431 width,
432 );
433 previous = Some(idx);
434 }
435
436 lines
437 }
438
439 fn output_rows(output: &str, width: u16) -> Vec<OutputRow> {
440 let wrap_width = width.saturating_sub(4).max(1) as usize;
441 let mut rows = Vec::new();
442 let mut sanitized = String::with_capacity(output.len());
443 for line in output.lines() {
444 sanitized.clear();
445 crate::tui::osc8::strip_ansi_into(line, &mut sanitized);
446 let styled = styled_segments(line, &sanitized);
447 let intact = is_path_or_url_like(&sanitized);
448 if intact {
449 rows.push(OutputRow {
450 text: sanitized.clone(),
451 intact: true,
452 styled,
453 });
454 } else {
455 let parts = wrap_text(&sanitized, wrap_width);
456 let mut styled_parts = styled.map(|segments| split_segments(&segments, &parts));
457 for (idx, wrapped) in parts.into_iter().enumerate() {
458 rows.push(OutputRow {
459 text: wrapped,
460 intact: false,
461 styled: styled_parts
462 .as_mut()
463 .map(|split| std::mem::take(&mut split[idx])),
464 });
465 }
466 }
467 }
468 if rows.is_empty() {
469 rows.push(OutputRow {
470 text: String::new(),
471 intact: false,
472 styled: None,
473 });
474 }
475 rows
476 }
477
478 /// Parse the SGR codes in `line` into styled segments whose text
479 /// concatenates to `plain` (the fully stripped line). Returns `None` when the
480 /// line carries no escape at all, when nothing in it sets a style, or when
481 /// the parse disagrees with the plain strip — the plain path is then the
482 /// truth, exactly as before.
483 fn styled_segments(line: &str, plain: &str) -> Option<Vec<StyledSegment>> {
484 use ansi_to_tui::IntoText;
485
486 if !line.contains('\x1b') {
487 return None;
488 }
489 let mut kept = String::with_capacity(line.len());
490 crate::tui::osc8::strip_ansi_keep_sgr_into(line, &mut kept);
491 let text = kept.into_text().ok()?;
492 let mut segments: Vec<StyledSegment> = Vec::new();
493 for parsed in text.lines {
494 for span in parsed.spans {
495 if span.content.is_empty() {
496 continue;
497 }
498 let style = tool_style(span.style);
499 match segments.last_mut() {
500 Some((last, last_style)) if *last_style == style => {
501 last.push_str(&span.content);
502 }
503 _ => segments.push((span.content.into_owned(), style)),
504 }
505 }
506 }
507 let round_trip: String = segments.iter().map(|(text, _)| text.as_str()).collect();
508 if round_trip != plain || segments.iter().all(|(_, style)| *style == Style::default()) {
509 return None;
510 }
511 Some(segments)
512 }
513
514 /// What the tool asked for, expressed relative to the cell's own ink. A
515 /// tool's *reset* (`ESC[0m`, or `Color::Reset`) means "back to the
516 /// terminal default", and in the transcript that default is the value style
517 /// the cell already paints — so resets become "nothing set" instead of a
518 /// `Style::reset()` that would wipe the cell's dim/state colour when
519 /// patched over it. Only positive requests (a colour, bold, underline)
520 /// survive.
521 fn tool_style(style: Style) -> Style {
522 let keep = |colour: Option<ratatui::style::Color>| {
523 colour.filter(|c| *c != ratatui::style::Color::Reset)
524 };
525 let mut out = Style::default().add_modifier(style.add_modifier);
526 if let Some(fg) = keep(style.fg) {
527 out = out.fg(fg);
528 }
529 if let Some(bg) = keep(style.bg) {
530 out = out.bg(bg);
531 }
532 out
533 }
534
535 /// Re-split styled segments along the boundaries `wrap_text` chose, so each
536 /// wrapped part keeps the colours of the characters it holds. `parts` must
537 /// concatenate to the segments' text (which is how `wrap_text` splits).
538 pub(super) fn split_segments(
539 segments: &[StyledSegment],
540 parts: &[String],
541 ) -> Vec<Vec<StyledSegment>> {
542 let mut chars = segments
543 .iter()
544 .flat_map(|(text, style)| text.chars().map(move |ch| (ch, *style)));
545 parts
546 .iter()
547 .map(|part| {
548 let mut out: Vec<StyledSegment> = Vec::new();
549 for (ch, style) in chars.by_ref().take(part.chars().count()) {
550 match out.last_mut() {
551 Some((last, last_style)) if *last_style == style => last.push(ch),
552 _ => out.push((ch.to_string(), style)),
553 }
554 }
555 out
556 })
557 .collect()
558 }
559
560 fn selected_output_indices(rows: &[OutputRow], line_limit: usize) -> Vec<usize> {
561 let total = rows.len();
562 if total <= line_limit || line_limit == 0 {
563 return (0..total).collect();
564 }
565
566 // Small previews must retain the result/error at the tail as well as
567 // the opening context. The usual 20-row budget still keeps 10 + 6.
568 let head = TOOL_OUTPUT_HEAD_LINES
569 .min(line_limit.div_ceil(2))
570 .min(total);
571 let tail = TOOL_OUTPUT_TAIL_LINES
572 .min(line_limit.saturating_sub(head))
573 .min(total.saturating_sub(head));
574 let mut selected = std::collections::BTreeSet::new();
575 selected.extend(0..head);
576 selected.extend(total.saturating_sub(tail)..total);
577
578 let budget = line_limit.saturating_sub(selected.len());
579 if budget > 0 {
580 let mut important: Vec<(usize, usize)> = rows
581 .iter()
582 .enumerate()
583 .skip(head)
584 .take(total.saturating_sub(head + tail))
585 .filter_map(|(idx, row)| output_importance_rank(&row.text).map(|rank| (idx, rank)))
586 .collect();
587 important.sort_by_key(|(idx, rank)| (*rank, *idx));
588 for (idx, _) in important.into_iter().take(budget) {
589 selected.insert(idx);
590 }
591 }
592
593 // The importance pass only fires on lines that look like errors, warnings
594 // or paths. Plain output — a list of names, a table, a build log with
595 // nothing alarming in it — matches none of them, so the card used to show
596 // `head + tail` rows and silently forfeit the rest of its budget. A
597 // 20-line command then rendered 16 rows and claimed the other four were
598 // "omitted". Spend whatever is left by growing the head downward, which
599 // keeps the shown region contiguous and readable top-down.
600 let mut next = head;
601 while selected.len() < line_limit.min(total) && next < total {
602 selected.insert(next);
603 next += 1;
604 }
605
606 selected.into_iter().collect()
607 }
608
609 fn output_importance_rank(line: &str) -> Option<usize> {
610 let lower = line.to_ascii_lowercase();
611 if [
612 "error",
613 "failed",
614 "failure",
615 "fatal",
616 "panic",
617 "exception",
618 "traceback",
619 "denied",
620 "not found",
621 "no such file",
622 "cannot",
623 "can't",
624 ]
625 .iter()
626 .any(|needle| lower.contains(needle))
627 {
628 return Some(0);
629 }
630 if lower.contains("warning") || lower.contains("warn") {
631 return Some(1);
632 }
633 if is_path_or_url_like(line) {
634 return Some(2);
635 }
636 None
637 }
638
639 fn is_path_or_url_like(line: &str) -> bool {
640 let trimmed = line.trim();
641 if trimmed.contains("://") || trimmed.starts_with("file:") {
642 return true;
643 }
644 let has_separator = trimmed.contains('/') || trimmed.contains('\\');
645 let has_extension = trimmed
646 .split_whitespace()
647 .any(|part| part.rsplit_once('.').is_some_and(|(_, ext)| ext.len() <= 8));
648 has_separator && has_extension
649 }
650
651 /// Detect whether a line contains a `path:line` pattern that could be
652 /// opened by `first_file_line_reference`. Returns a distinctive style
653 /// (underline + blue) when the pattern matches, or `None` otherwise.
654 /// The style is applied over the existing value style so the line
655 /// remains readable.
656 fn file_line_style(text: &str) -> Option<Style> {
657 let trimmed = text.trim();
658 if let Some((before, after)) = trimmed.rsplit_once(':')
659 && !before.is_empty()
660 && after.chars().all(|c| c.is_ascii_digit())
661 && looks_like_file_path(before)
662 {
663 Some(
664 Style::default()
665 .fg(palette::WHALE_ACTION)
666 .add_modifier(ratatui::style::Modifier::UNDERLINED),
667 )
668 } else {
669 None
670 }
671 }
672
673 /// Apply inline diff highlighting to a single text line.
674 ///
675 /// Returns the appropriate style for the line based on its prefix:
676 /// - Lines starting with `+` (after trimming) => `palette::DIFF_ADDED` (green)
677 /// - Lines starting with `-` (after trimming) => `palette::STATUS_ERROR` (red)
678 /// - Lines starting with `@@` => `palette::WHALE_ACTION` (cyan/blue)
679 /// - All other lines => None (use default style)
680 fn diff_line_style(text: &str) -> Option<Style> {
681 let trimmed = text.trim_start();
682 if trimmed.starts_with("@@") {
683 Some(Style::default().fg(palette::WHALE_ACTION))
684 } else if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
685 Some(Style::default().fg(palette::DIFF_ADDED))
686 } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
687 Some(Style::default().fg(palette::STATUS_ERROR))
688 } else {
689 None
690 }
691 }
692
693 fn render_output_row(
694 lines: &mut Vec<Line<'static>>,
695 label: Option<&str>,
696 row: &OutputRow,
697 width: u16,
698 ) {
699 // #374: apply file:line highlighting when the row text contains
700 // a `path:line` pattern. Diff style takes precedence (colored
701 // prefix lines should stay colored), but if no diff style matched,
702 // check for a file:line pattern and highlight it distinctively.
703 let diff_style = diff_line_style(&row.text);
704 let file_style = file_line_style(&row.text);
705 let value_style = diff_style.or(file_style).unwrap_or_else(tool_value_style);
706 if let Some(segments) = &row.styled {
707 if row.intact {
708 lines.push(render_card_detail_line_single_styled(
709 label,
710 segments,
711 value_style,
712 ));
713 } else {
714 lines.extend(render_card_detail_line_styled(
715 label,
716 &row.text,
717 segments,
718 value_style,
719 width,
720 ));
721 }
722 } else if row.intact {
723 lines.push(render_card_detail_line_single(
724 label,
725 &row.text,
726 value_style,
727 ));
728 } else {
729 lines.extend(render_card_detail_line(
730 label,
731 &row.text,
732 value_style,
733 width,
734 ));
735 }
736 }
737
738 pub(super) fn wrap_plain_line(line: &str, style: Style, width: u16) -> Vec<Line<'static>> {
739 let mut lines = Vec::new();
740 for part in wrap_text(line, width.max(1) as usize) {
741 lines.push(Line::from(Span::styled(part, style)));
742 }
743 lines
744 }
745
746 pub(super) fn wrap_text(text: &str, width: usize) -> Vec<String> {
747 if width == 0 {
748 return vec![text.to_string()];
749 }
750 if text.is_empty() {
751 return vec![String::new()];
752 }
753
754 let mut lines = Vec::new();
755 let mut current = String::new();
756
757 for grapheme in text.graphemes(true) {
758 let tentative = if current.is_empty() {
759 grapheme.to_string()
760 } else {
761 let mut t = current.clone();
762 t.push_str(grapheme);
763 t
764 };
765
766 if UnicodeWidthStr::width(tentative.as_str()) > width && !current.is_empty() {
767 lines.push(std::mem::take(&mut current));
768 }
769
770 current.push_str(grapheme);
771 }
772
773 lines.push(current);
774
775 if lines.is_empty() {
776 vec![String::new()]
777 } else {
778 lines
779 }
780 }
781
782 #[cfg(test)]
783 mod ansi_colour_tests {
784 use super::*;
785 use ratatui::style::{Color, Modifier};
786
787 fn styled_text(rows: &[OutputRow]) -> Vec<String> {
788 rows.iter()
789 .map(|row| {
790 row.styled
791 .as_ref()
792 .map(|segments| segments.iter().map(|(t, _)| t.as_str()).collect())
793 .unwrap_or_default()
794 })
795 .collect()
796 }
797
798 #[test]
799 fn plain_line_carries_no_styled_segments() {
800 let rows = output_rows(" Compiling codewhale v0.9.12", 80);
801 assert_eq!(rows.len(), 1);
802 assert_eq!(rows[0].text, " Compiling codewhale v0.9.12");
803 assert!(rows[0].styled.is_none());
804 }
805
806 #[test]
807 fn cargo_style_line_keeps_its_green_bold_verb() {
808 let line = "\x1b[1m\x1b[32m Compiling\x1b[0m codewhale v0.9.12";
809 let rows = output_rows(line, 80);
810 assert_eq!(rows.len(), 1);
811 assert_eq!(rows[0].text, " Compiling codewhale v0.9.12");
812 let segments = rows[0]
813 .styled
814 .as_ref()
815 .expect("coloured line keeps segments");
816 assert_eq!(segments[0].0, " Compiling");
817 assert_eq!(segments[0].1.fg, Some(Color::Green));
818 assert!(segments[0].1.add_modifier.contains(Modifier::BOLD));
819 assert_eq!(segments[1].0, " codewhale v0.9.12");
820 assert_eq!(segments[1].1, Style::default());
821 assert_eq!(styled_text(&rows), vec![rows[0].text.clone()]);
822 }
823
824 #[test]
825 fn osc8_link_is_stripped_while_its_sgr_colour_survives() {
826 let line =
827 "see \x1b]8;;https://example.com/x\x1b\\\x1b[31mthe docs\x1b[0m\x1b]8;;\x1b\\ now";
828 let rows = output_rows(line, 80);
829 assert_eq!(rows.len(), 1);
830 assert_eq!(rows[0].text, "see the docs now");
831 let segments = rows[0]
832 .styled
833 .as_ref()
834 .expect("SGR inside an OSC 8 wrapper");
835 assert_eq!(segments[0], ("see ".to_string(), Style::default()));
836 assert_eq!(segments[1].0, "the docs");
837 assert_eq!(segments[1].1.fg, Some(Color::Red));
838 assert_eq!(segments[2], (" now".to_string(), Style::default()));
839 }
840
841 #[test]
842 fn wrapped_rows_split_the_colour_along_wrap_boundaries() {
843 let line = format!("\x1b[33m{}\x1b[0m{}", "y".repeat(10), "p".repeat(10));
844 // width 12 → wrap width 8: rows of 8/8/4 characters.
845 let rows = output_rows(&line, 12);
846 assert_eq!(rows.len(), 3);
847 assert_eq!(
848 styled_text(&rows),
849 rows.iter().map(|r| r.text.clone()).collect::<Vec<_>>()
850 );
851 let second = rows[1].styled.as_ref().unwrap();
852 assert_eq!(
853 second[0],
854 ("yy".to_string(), Style::default().fg(Color::Yellow))
855 );
856 assert_eq!(second[1], ("pppppp".to_string(), Style::default()));
857 }
858
859 #[test]
860 fn painted_span_patches_tool_colour_over_the_cell_style() {
861 let rows = output_rows("\x1b[31merror\x1b[0m: boom", 80);
862 let mut lines = Vec::new();
863 render_output_row(&mut lines, Some("output"), &rows[0], 80);
864 assert_eq!(lines.len(), 1);
865 let spans = &lines[0].spans;
866 // rail, label, gap, "error", ": boom"
867 assert_eq!(spans[3].content, "error");
868 assert_eq!(spans[3].style.fg, Some(Color::Red));
869 assert_eq!(spans[4].content, ": boom");
870 assert_eq!(spans[4].style, tool_value_style());
871 let plain: String = spans[3..].iter().map(|s| s.content.as_ref()).collect();
872 assert_eq!(plain, "error: boom");
873 }
874
875 #[test]
876 fn coloured_intact_path_stays_on_one_line_with_the_plain_hitbox() {
877 let path = "crates/tui/src/tui/history/tool_output.rs:812";
878 let coloured = format!("\x1b[35m{path}\x1b[0m");
879 let plain_rows = output_rows(path, 20);
880 let styled_rows = output_rows(&coloured, 20);
881 assert_eq!(plain_rows.len(), 1);
882 assert_eq!(styled_rows.len(), 1);
883 assert!(plain_rows[0].intact && styled_rows[0].intact);
884 assert!(styled_rows[0].styled.is_some());
885
886 let mut plain = Vec::new();
887 render_output_row(&mut plain, Some("output"), &plain_rows[0], 20);
888 let mut styled = Vec::new();
889 render_output_row(&mut styled, Some("output"), &styled_rows[0], 20);
890 assert_eq!(plain.len(), 1, "plain intact row is one line");
891 assert_eq!(styled.len(), 1, "coloured intact row is one line too");
892 let text = |line: &Line<'static>| {
893 line.spans
894 .iter()
895 .map(|s| s.content.as_ref())
896 .collect::<String>()
897 };
898 assert_eq!(text(&styled[0]), text(&plain[0]));
899 // Same prefix (rail + label + gap), so the click region is identical.
900 assert_eq!(
901 styled[0].spans[..3]
902 .iter()
903 .map(|s| s.content.as_ref())
904 .collect::<Vec<_>>(),
905 plain[0].spans[..3]
906 .iter()
907 .map(|s| s.content.as_ref())
908 .collect::<Vec<_>>()
909 );
910 assert_eq!(styled[0].spans[3].style.fg, Some(Color::Magenta));
911 }
912
913 #[test]
914 fn a_line_that_only_resets_stays_on_the_plain_path() {
915 let rows = output_rows("done\x1b[0m", 80);
916 assert_eq!(rows[0].text, "done");
917 assert!(rows[0].styled.is_none());
918 }
919
920 #[test]
921 fn image_sniffing_is_case_insensitive_over_the_raw_bytes() {
922 assert!(output_is_image("saved to /tmp/Chart.PNG"));
923 assert!(output_is_image("shot.jpeg"));
924 assert!(output_is_image(".WEBP"));
925 assert!(!output_is_image("no image here"));
926 // The historical `contains` contract is preserved: a name that merely
927 // embeds an extension still counts.
928 assert!(output_is_image("weird.pngx"));
929 // A byte-window scan must not panic on multi-byte text.
930 assert!(!output_is_image("日本語のテキストのみ"));
931 assert!(!output_is_image(""));
932 }
933
934 #[test]
935 fn wrap_text_breaks_between_graphemes() {
936 // An emoji-presentation heart and a keycap are one two-column
937 // grapheme each, the way Ratatui counts cells.
938 for text in ["ab\u{2764}\u{fe0f}cd", "ab1\u{fe0f}\u{20e3}cd"] {
939 for width in 2..=6 {
940 let lines = wrap_text(text, width);
941 let rejoined: Vec<&str> =
942 lines.iter().flat_map(|line| line.graphemes(true)).collect();
943 assert_eq!(
944 rejoined,
945 text.graphemes(true).collect::<Vec<_>>(),
946 "width {width} split a grapheme: {lines:?}"
947 );
948 for line in &lines {
949 assert!(line.width() <= width, "line {line:?} exceeds width {width}");
950 }
951 }
952 }
953 }
954 }
955
955 lines RUST