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