返回 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 crate::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, tool_value_style, truncate_text,
14 };
15
16 pub(super) fn render_tool_output_mode(
17 output: &str,
18 width: u16,
19 line_limit: usize,
20 mode: RenderMode,
21 ) -> Vec<Line<'static>> {
22 render_preserved_output_mode(output, width, line_limit, mode, "result")
23 }
24
25 pub(super) fn render_exec_output_mode(
26 output: &str,
27 width: u16,
28 line_limit: usize,
29 mode: RenderMode,
30 ) -> Vec<Line<'static>> {
31 render_preserved_output_mode(output, width, line_limit, mode, "output")
32 }
33
34 #[derive(Debug, Clone, PartialEq, Eq)]
35 pub struct OutputRow {
36 pub text: String,
37 pub intact: bool,
38 }
39
40 /// Heuristic: does the output look like a unified diff? Returns true when
41 /// the output contains at least one hunk header (`@@`) or a `diff --git`
42 /// line, which are reliable markers of unified diff content (#380).
43 pub(crate) fn output_looks_like_diff(output: &str) -> bool {
44 let mut lines = output.lines();
45 // Check first 5 lines for diff markers
46 for _ in 0..5 {
47 let Some(line) = lines.next() else { break };
48 let trimmed = line.trim();
49 if trimmed.starts_with("@@") || trimmed.starts_with("diff --git") {
50 return true;
51 }
52 }
53 false
54 }
55
56 fn summarize_string_value(text: &str, max_len: usize, count_only: bool) -> String {
57 let trimmed = text.trim();
58 let len = trimmed.chars().count();
59 if count_only || len > max_len {
60 return format!("<{len} chars>");
61 }
62 truncate_text(trimmed, max_len)
63 }
64
65 fn summarize_inline_value(value: &Value, max_len: usize, count_only: bool) -> String {
66 match value {
67 Value::String(s) => summarize_string_value(s, max_len, count_only),
68 Value::Array(items) => format!("<{} items>", items.len()),
69 Value::Object(map) => format!("<{} keys>", map.len()),
70 Value::Bool(b) => b.to_string(),
71 Value::Number(num) => num.to_string(),
72 Value::Null => "null".to_string(),
73 }
74 }
75
76 fn is_noisy_tool_arg_key(key: &str) -> bool {
77 matches!(
78 key,
79 "limit"
80 | "max_count"
81 | "max_output_tokens"
82 | "offset"
83 | "page"
84 | "page_size"
85 | "per_page"
86 | "response_length"
87 | "timeout_ms"
88 | "yield_time_ms"
89 )
90 }
91
92 #[must_use]
93 pub fn summarize_tool_args(input: &Value) -> Option<String> {
94 let obj = input.as_object()?;
95 if obj.is_empty() {
96 return None;
97 }
98
99 let mut parts = Vec::new();
100
101 if let Some(value) = obj.get("path") {
102 parts.push(format!(
103 "path: {}",
104 summarize_inline_value(value, 80, false)
105 ));
106 }
107 if let Some(value) = obj.get("command") {
108 parts.push(format!(
109 "command: {}",
110 summarize_inline_value(value, 80, false)
111 ));
112 }
113 if let Some(value) = obj.get("query") {
114 parts.push(format!(
115 "query: {}",
116 summarize_inline_value(value, 80, false)
117 ));
118 }
119 if let Some(value) = obj.get("prompt") {
120 parts.push(format!(
121 "prompt: {}",
122 summarize_inline_value(value, 80, false)
123 ));
124 }
125 if let Some(value) = obj.get("text") {
126 parts.push(format!(
127 "text: {}",
128 summarize_inline_value(value, 80, false)
129 ));
130 }
131 if let Some(value) = obj.get("pattern") {
132 parts.push(format!(
133 "pattern: {}",
134 summarize_inline_value(value, 80, false)
135 ));
136 }
137 if let Some(value) = obj.get("model") {
138 parts.push(format!(
139 "model: {}",
140 summarize_inline_value(value, 40, false)
141 ));
142 }
143 if let Some(value) = obj.get("profile") {
144 parts.push(format!(
145 "profile: {}",
146 summarize_inline_value(value, 40, false)
147 ));
148 }
149 if let Some(value) = obj.get("level") {
150 parts.push(format!(
151 "level: {}",
152 summarize_inline_value(value, 40, false)
153 ));
154 }
155 if let Some(value) = obj.get("file_id") {
156 parts.push(format!(
157 "file_id: {}",
158 summarize_inline_value(value, 40, false)
159 ));
160 }
161 if let Some(value) = obj.get("task_id") {
162 parts.push(format!(
163 "task_id: {}",
164 summarize_inline_value(value, 40, false)
165 ));
166 }
167 if let Some(value) = obj.get("voice_id") {
168 parts.push(format!(
169 "voice_id: {}",
170 summarize_inline_value(value, 40, false)
171 ));
172 }
173 if let Some(value) = obj.get("content") {
174 parts.push(format!(
175 "content: {}",
176 summarize_inline_value(value, 0, true)
177 ));
178 }
179
180 if parts.is_empty()
181 && let Some((key, value)) = obj
182 .iter()
183 .find(|(key, _)| !is_noisy_tool_arg_key(key.as_str()))
184 {
185 return Some(format!(
186 "{}: {}",
187 key,
188 summarize_inline_value(value, 80, false)
189 ));
190 }
191
192 if parts.is_empty() {
193 None
194 } else {
195 Some(parts.join(", "))
196 }
197 }
198
199 #[must_use]
200 pub fn summarize_tool_output(output: &str) -> String {
201 if let Ok(json) = serde_json::from_str::<Value>(output) {
202 if let Some(obj) = json.as_object() {
203 if let Some(error) = obj.get("error").or(obj.get("status_msg")) {
204 return format!("Error: {}", summarize_inline_value(error, 120, false));
205 }
206
207 let mut parts = Vec::new();
208
209 if let Some(status) = obj.get("status").and_then(|v| v.as_str()) {
210 parts.push(format!("status: {status}"));
211 }
212 if let Some(message) = obj.get("message").and_then(|v| v.as_str()) {
213 parts.push(truncate_text(message, TOOL_TEXT_LIMIT));
214 }
215 if let Some(task_id) = obj.get("task_id").and_then(|v| v.as_str()) {
216 parts.push(format!("task_id: {task_id}"));
217 }
218 if let Some(file_id) = obj.get("file_id").and_then(|v| v.as_str()) {
219 parts.push(format!("file_id: {file_id}"));
220 }
221 if let Some(url) = obj
222 .get("file_url")
223 .or_else(|| obj.get("url"))
224 .and_then(|v| v.as_str())
225 {
226 parts.push(format!("url: {}", truncate_text(url, 120)));
227 }
228 if let Some(data) = obj.get("data") {
229 parts.push(format!("data: {}", summarize_inline_value(data, 80, true)));
230 }
231
232 if !parts.is_empty() {
233 return parts.join(" | ");
234 }
235
236 if let Some(content) = obj
237 .get("content")
238 .or(obj.get("result"))
239 .or(obj.get("output"))
240 {
241 return summarize_inline_value(content, TOOL_TEXT_LIMIT, false);
242 }
243 }
244
245 return summarize_inline_value(&json, TOOL_TEXT_LIMIT, true);
246 }
247
248 truncate_text(output, TOOL_TEXT_LIMIT)
249 }
250
251 /// Summary information extracted from an MCP tool output payload.
252 pub struct McpOutputSummary {
253 pub content: Option<String>,
254 pub is_image: bool,
255 pub is_error: Option<bool>,
256 }
257
258 /// Summarize raw MCP output into UI-friendly content.
259 #[must_use]
260 pub fn summarize_mcp_output(output: &str) -> McpOutputSummary {
261 if let Ok(json) = serde_json::from_str::<Value>(output) {
262 let is_error = json
263 .get("isError")
264 .and_then(serde_json::Value::as_bool)
265 .or_else(|| json.get("is_error").and_then(serde_json::Value::as_bool));
266
267 if let Some(blocks) = json.get("content").and_then(|v| v.as_array()) {
268 let mut lines = Vec::new();
269 let mut is_image = false;
270
271 for block in blocks {
272 let block_type = block
273 .get("type")
274 .and_then(|v| v.as_str())
275 .unwrap_or("unknown");
276 match block_type {
277 "text" => {
278 let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
279 if !text.is_empty() {
280 lines.push(format!("- text: {}", truncate_text(text, 200)));
281 }
282 }
283 "image" | "image_url" => {
284 is_image = true;
285 let url = block
286 .get("url")
287 .or_else(|| block.get("image_url"))
288 .and_then(|v| v.as_str());
289 if let Some(url) = url {
290 lines.push(format!("- image: {}", truncate_text(url, 200)));
291 } else {
292 lines.push("- image".to_string());
293 }
294 }
295 "resource" | "resource_link" => {
296 let uri = block
297 .get("uri")
298 .or_else(|| block.get("url"))
299 .and_then(|v| v.as_str())
300 .unwrap_or("<resource>");
301 lines.push(format!("- resource: {}", truncate_text(uri, 200)));
302 }
303 other => {
304 lines.push(format!("- {other} content"));
305 }
306 }
307 }
308
309 return McpOutputSummary {
310 content: if lines.is_empty() {
311 None
312 } else {
313 Some(lines.join("\n"))
314 },
315 is_image,
316 is_error,
317 };
318 }
319 }
320
321 McpOutputSummary {
322 content: Some(summarize_tool_output(output)),
323 is_image: output_is_image(output),
324 is_error: None,
325 }
326 }
327
328 #[must_use]
329 pub fn output_is_image(output: &str) -> bool {
330 let lower = output.to_lowercase();
331
332 [
333 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ppm",
334 ]
335 .iter()
336 .any(|ext| lower.contains(ext))
337 }
338
339 fn render_preserved_output_mode(
340 output: &str,
341 width: u16,
342 line_limit: usize,
343 mode: RenderMode,
344 first_label: &str,
345 ) -> Vec<Line<'static>> {
346 let mut lines = Vec::new();
347 if output.trim().is_empty() {
348 // #3031: In compact/Live mode, suppress "(no output)" — the tool
349 // header already carries the success/failure status. Transcript
350 // mode still records it for exports/clipboard/pager.
351 if mode == RenderMode::Transcript {
352 lines.push(Line::from(Span::styled(
353 " (no output)",
354 Style::default().fg(palette::TEXT_MUTED).italic(),
355 )));
356 }
357 return lines;
358 }
359
360 // Hash once; reuse for both the rows cache and the indices cache below.
361 let content_hash = crate::tui::output_rows_cache::hash_str(output);
362 let all_lines =
363 crate::tui::output_rows_cache::get_or_compute_rows_with_hash(content_hash, width, || {
364 output_rows(output, width)
365 });
366
367 if matches!(mode, RenderMode::Transcript) {
368 // Full-content path: emit every wrapped line with no head/tail split,
369 // no "+N more" affordance.
370 for (idx, row) in all_lines.iter().enumerate() {
371 render_output_row(
372 &mut lines,
373 if idx == 0 { Some(first_label) } else { None },
374 row,
375 width,
376 );
377 }
378 return lines;
379 }
380
381 let selected = crate::tui::output_rows_cache::get_or_compute_indices(
382 content_hash,
383 width,
384 line_limit,
385 || selected_output_indices(&all_lines, line_limit),
386 );
387 let mut previous: Option<usize> = None;
388 for (rendered_idx, idx) in selected.iter().copied().enumerate() {
389 if let Some(prev) = previous {
390 let omitted = idx.saturating_sub(prev + 1);
391 if omitted > 0 {
392 lines.push(details_affordance_line(
393 &format!(
394 "{omitted} lines omitted; {}",
395 crate::tui::key_shortcuts::tool_details_shortcut_action_hint("output")
396 ),
397 Style::default().fg(palette::TEXT_MUTED),
398 ));
399 }
400 }
401
402 let row = &all_lines[idx];
403 render_output_row(
404 &mut lines,
405 if rendered_idx == 0 {
406 Some(first_label)
407 } else {
408 None
409 },
410 row,
411 width,
412 );
413 previous = Some(idx);
414 }
415
416 lines
417 }
418
419 fn output_rows(output: &str, width: u16) -> Vec<OutputRow> {
420 let wrap_width = width.saturating_sub(4).max(1) as usize;
421 let mut rows = Vec::new();
422 let mut sanitized = String::with_capacity(output.len());
423 for line in output.lines() {
424 sanitized.clear();
425 crate::tui::osc8::strip_ansi_into(line, &mut sanitized);
426 let intact = is_path_or_url_like(&sanitized);
427 if intact {
428 rows.push(OutputRow {
429 text: sanitized.clone(),
430 intact: true,
431 });
432 } else {
433 for wrapped in wrap_text(&sanitized, wrap_width) {
434 rows.push(OutputRow {
435 text: wrapped,
436 intact: false,
437 });
438 }
439 }
440 }
441 if rows.is_empty() {
442 rows.push(OutputRow {
443 text: String::new(),
444 intact: false,
445 });
446 }
447 rows
448 }
449
450 fn selected_output_indices(rows: &[OutputRow], line_limit: usize) -> Vec<usize> {
451 let total = rows.len();
452 if total <= line_limit || line_limit == 0 {
453 return (0..total).collect();
454 }
455
456 let head = TOOL_OUTPUT_HEAD_LINES.min(line_limit).min(total);
457 let tail = TOOL_OUTPUT_TAIL_LINES
458 .min(line_limit.saturating_sub(head))
459 .min(total.saturating_sub(head));
460 let mut selected = std::collections::BTreeSet::new();
461 selected.extend(0..head);
462 selected.extend(total.saturating_sub(tail)..total);
463
464 let budget = line_limit.saturating_sub(selected.len());
465 if budget > 0 {
466 let mut important: Vec<(usize, usize)> = rows
467 .iter()
468 .enumerate()
469 .skip(head)
470 .take(total.saturating_sub(head + tail))
471 .filter_map(|(idx, row)| output_importance_rank(&row.text).map(|rank| (idx, rank)))
472 .collect();
473 important.sort_by_key(|(idx, rank)| (*rank, *idx));
474 for (idx, _) in important.into_iter().take(budget) {
475 selected.insert(idx);
476 }
477 }
478
479 // The importance pass only fires on lines that look like errors, warnings
480 // or paths. Plain output — a list of names, a table, a build log with
481 // nothing alarming in it — matches none of them, so the card used to show
482 // `head + tail` rows and silently forfeit the rest of its budget. A
483 // 20-line command then rendered 16 rows and claimed the other four were
484 // "omitted". Spend whatever is left by growing the head downward, which
485 // keeps the shown region contiguous and readable top-down.
486 let mut next = head;
487 while selected.len() < line_limit.min(total) && next < total {
488 selected.insert(next);
489 next += 1;
490 }
491
492 selected.into_iter().collect()
493 }
494
495 fn output_importance_rank(line: &str) -> Option<usize> {
496 let lower = line.to_ascii_lowercase();
497 if [
498 "error",
499 "failed",
500 "failure",
501 "fatal",
502 "panic",
503 "exception",
504 "traceback",
505 "denied",
506 "not found",
507 "no such file",
508 "cannot",
509 "can't",
510 ]
511 .iter()
512 .any(|needle| lower.contains(needle))
513 {
514 return Some(0);
515 }
516 if lower.contains("warning") || lower.contains("warn") {
517 return Some(1);
518 }
519 if is_path_or_url_like(line) {
520 return Some(2);
521 }
522 None
523 }
524
525 fn is_path_or_url_like(line: &str) -> bool {
526 let trimmed = line.trim();
527 if trimmed.contains("://") || trimmed.starts_with("file:") {
528 return true;
529 }
530 let has_separator = trimmed.contains('/') || trimmed.contains('\\');
531 let has_extension = trimmed
532 .split_whitespace()
533 .any(|part| part.rsplit_once('.').is_some_and(|(_, ext)| ext.len() <= 8));
534 has_separator && has_extension
535 }
536
537 /// Detect whether a line contains a `path:line` pattern that could be
538 /// opened by `try_open_file_at_line`. Returns a distinctive style
539 /// (underline + blue) when the pattern matches, or `None` otherwise.
540 /// The style is applied over the existing value style so the line
541 /// remains readable.
542 fn file_line_style(text: &str) -> Option<Style> {
543 let trimmed = text.trim();
544 if let Some((before, after)) = trimmed.rsplit_once(':')
545 && !before.is_empty()
546 && after.chars().all(|c| c.is_ascii_digit())
547 && looks_like_file_path(before)
548 {
549 Some(
550 Style::default()
551 .fg(palette::WHALE_INFO)
552 .add_modifier(ratatui::style::Modifier::UNDERLINED),
553 )
554 } else {
555 None
556 }
557 }
558
559 /// Apply inline diff highlighting to a single text line.
560 ///
561 /// Returns the appropriate style for the line based on its prefix:
562 /// - Lines starting with `+` (after trimming) => `palette::DIFF_ADDED` (green)
563 /// - Lines starting with `-` (after trimming) => `palette::STATUS_ERROR` (red)
564 /// - Lines starting with `@@` => `palette::WHALE_INFO` (cyan/blue)
565 /// - All other lines => None (use default style)
566 fn diff_line_style(text: &str) -> Option<Style> {
567 let trimmed = text.trim_start();
568 if trimmed.starts_with("@@") {
569 Some(Style::default().fg(palette::WHALE_ACTION))
570 } else if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
571 Some(Style::default().fg(palette::DIFF_ADDED))
572 } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
573 Some(Style::default().fg(palette::STATUS_ERROR))
574 } else {
575 None
576 }
577 }
578
579 fn render_output_row(
580 lines: &mut Vec<Line<'static>>,
581 label: Option<&str>,
582 row: &OutputRow,
583 width: u16,
584 ) {
585 // #374: apply file:line highlighting when the row text contains
586 // a `path:line` pattern. Diff style takes precedence (colored
587 // prefix lines should stay colored), but if no diff style matched,
588 // check for a file:line pattern and highlight it distinctively.
589 let diff_style = diff_line_style(&row.text);
590 let file_style = file_line_style(&row.text);
591 let value_style = diff_style.or(file_style).unwrap_or_else(tool_value_style);
592 if row.intact {
593 lines.push(render_card_detail_line_single(
594 label,
595 &row.text,
596 value_style,
597 ));
598 } else {
599 lines.extend(render_card_detail_line(
600 label,
601 &row.text,
602 value_style,
603 width,
604 ));
605 }
606 }
607
608 pub(super) fn wrap_plain_line(line: &str, style: Style, width: u16) -> Vec<Line<'static>> {
609 let mut lines = Vec::new();
610 for part in wrap_text(line, width.max(1) as usize) {
611 lines.push(Line::from(Span::styled(part, style)));
612 }
613 lines
614 }
615
616 pub(super) fn wrap_text(text: &str, width: usize) -> Vec<String> {
617 if width == 0 {
618 return vec![text.to_string()];
619 }
620 if text.is_empty() {
621 return vec![String::new()];
622 }
623
624 let mut lines = Vec::new();
625 let mut current = String::new();
626
627 for ch in text.chars() {
628 let tentative = if current.is_empty() {
629 ch.to_string()
630 } else {
631 let mut t = current.clone();
632 t.push(ch);
633 t
634 };
635
636 if UnicodeWidthStr::width(tentative.as_str()) > width && !current.is_empty() {
637 lines.push(std::mem::take(&mut current));
638 }
639
640 current.push(ch);
641 }
642
643 lines.push(current);
644
645 if lines.is_empty() {
646 vec![String::new()]
647 } else {
648 lines
649 }
650 }
651
651 lines RUST