返回 CodeWhale
history.rs
根目录 / crates / tui / src / tui / history.rs
1 //! TUI rendering helpers for chat history and tool output.
2
3 use std::path::{Path, PathBuf};
4 use std::time::Instant;
5
6 use ratatui::style::{Color, Modifier, Style};
7 use ratatui::text::{Line, Span};
8 use unicode_width::UnicodeWidthStr;
9
10 use crate::deepseek_theme::active_theme;
11 use crate::models::{ContentBlock, Message};
12 use crate::palette;
13 use crate::tools::plan::PlanSnapshot;
14 use crate::tools::review::ReviewOutput;
15 use crate::tui::app::TranscriptSpacing;
16 use crate::tui::diff_render;
17 use crate::tui::motion::MotionMode;
18 use crate::tui::ui_text::CopyLineSeparator;
19
20 mod agent_activity;
21 mod archived_context;
22 mod checklist;
23 mod constants;
24 mod file_mutation;
25 mod latex_render;
26 mod message;
27 mod plan;
28 mod thinking;
29 mod tool_output;
30 mod tool_run;
31
32 use archived_context::{parse_archived_context, render_archived_context};
33 use checklist::{
34 is_checklist_tool_name, parse_checklist_snapshot, parse_update_prefix, render_checklist_card,
35 render_checklist_change_card,
36 };
37
38 #[cfg(test)]
39 use checklist::{ChecklistChange, ChecklistItemSnapshot, ChecklistSnapshot};
40 use constants::{
41 ASSISTANT_GLYPH, FOREGROUND_SHELL_WAIT_HINT, TOOL_CARD_SUMMARY_LINES, TOOL_COMMAND_LINE_LIMIT,
42 TOOL_DONE_SYMBOL, TOOL_FAILED_SYMBOL, TOOL_HEADER_SUMMARY_LIMIT, TOOL_OUTPUT_LINE_LIMIT,
43 TOOL_SUCCESS_OUTPUT_PREVIEW_LINES, TOOL_SUMMARY_CARD_LINES, TRANSCRIPT_RAIL, USER_GLYPH,
44 };
45 #[cfg(test)]
46 use constants::{TOOL_RUNNING_SYMBOLS, TOOL_STATUS_SYMBOL_MS};
47 use message::{
48 RenderedTranscriptLine, assistant_label_style_for, hard_break_copy_lines, message_body_style,
49 render_message, render_message_with_copy_metadata_for_palette, render_plain_message,
50 render_user_message, system_body_style, system_label_style, update_streaming_message_render,
51 user_body_style, user_label_style,
52 };
53 #[cfg(test)]
54 pub(super) use thinking::render_thinking_with_highlight;
55 use thinking::{render_hidden_thinking_activity, render_thinking};
56 use tool_output::{render_exec_output_mode, render_tool_output_mode, wrap_plain_line, wrap_text};
57
58 #[cfg(test)]
59 use agent_activity::extract_agent_id;
60 pub use file_mutation::FileMutationReceipt;
61 pub use plan::PlanUpdateCell;
62 #[cfg(test)]
63 use thinking::extract_reasoning_summary;
64 #[cfg(test)]
65 use tool_run::ToolRunActivitySummary;
66 #[cfg(test)]
67 pub use tool_run::detect_tool_runs;
68 pub use tool_run::{ToolRun, detect_tool_runs_from_slices, tool_run_summary};
69
70 #[cfg(test)]
71 use thinking::{REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL};
72 pub(crate) use tool_output::output_looks_like_diff;
73 pub use tool_output::{
74 OutputRow, summarize_mcp_output, summarize_tool_args, summarize_tool_output,
75 };
76
77 use std::process::Command;
78
79 /// Render mode controlling whether tool/thinking cells render their compact
80 /// "live" form (with caps and collapsed reasoning) or their full transcript
81 /// form (uncapped, suitable for the pager / clipboard / message export).
82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 pub enum RenderMode {
84 /// Live in-stream view: thinking is collapsed to a summary, tool output is
85 /// truncated with a visible details-pager affordance.
86 Live,
87 /// Full transcript view: every line of reasoning and tool output is
88 /// emitted, no caps, no affordance.
89 Transcript,
90 }
91
92 // === History Cells ===
93
94 /// Renderable history cell for user/assistant/system entries.
95 #[derive(Debug, Clone)]
96 pub enum HistoryCell {
97 User {
98 content: String,
99 },
100 Assistant {
101 content: String,
102 streaming: bool,
103 },
104 System {
105 content: String,
106 },
107 /// Categorized engine-error cell. Severity drives the label glyph + color
108 /// (red for `Error`/`Critical`, amber for `Warning`, dim for `Info`) so
109 /// the user can prioritize at a glance.
110 Error {
111 message: String,
112 severity: crate::error_taxonomy::ErrorSeverity,
113 },
114 Thinking {
115 content: String,
116 streaming: bool,
117 duration_secs: Option<f32>,
118 },
119 /// An `<archived_context>` seam block produced by the Flash seam manager
120 /// (issue #159). Rendered dimmed/italic with a level + range label so
121 /// the user can see at a glance where context seams exist.
122 ArchivedContext {
123 /// Seam level (1, 2, 3, or 0 for cycle-level).
124 level: u8,
125 /// Message range covered (e.g. "msg 0-128").
126 range: String,
127 /// Token estimate string (e.g. "~2500").
128 tokens: String,
129 /// Density label (e.g. "~2,500 tokens").
130 density: String,
131 /// Model that produced the summary.
132 model: String,
133 /// RFC 3339 timestamp.
134 timestamp: String,
135 /// The summary text content.
136 summary: String,
137 },
138 Tool(ToolCell),
139 /// Live in-transcript card for sub-agent activity (issue #128). Owns
140 /// either a single `DelegateCard` or a multi-worker `FanoutCard`; the
141 /// UI re-binds it from the mailbox stream as envelopes arrive.
142 SubAgent(SubAgentCell),
143 }
144
145 /// In-transcript sub-agent cell — either a single delegate or a fanout.
146 /// State mutates over the turn as mailbox envelopes are drained.
147 /// `Shelf` is a synthetic collapsed projector for concurrent live agents.
148 #[derive(Debug, Clone)]
149 pub enum SubAgentCell {
150 Delegate(crate::tui::widgets::agent_card::DelegateCard),
151 Fanout(crate::tui::widgets::agent_card::FanoutCard),
152 }
153
154 impl SubAgentCell {
155 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
156 match self {
157 SubAgentCell::Delegate(card) => card.render_lines(width),
158 SubAgentCell::Fanout(card) => card.render_lines(width),
159 }
160 }
161 }
162
163 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
164 pub struct TranscriptRenderOptions {
165 pub show_thinking: bool,
166 pub thinking_default_expanded: bool,
167 pub thinking_highlight: bool,
168 pub verbose: bool,
169 pub show_tool_details: bool,
170 pub inline_diff_mode: crate::settings::InlineDiffMode,
171 pub calm_mode: bool,
172 pub low_motion: bool,
173 pub motion_mode: MotionMode,
174 pub spacing: TranscriptSpacing,
175 /// Resolved application theme mode. This keeps cached markdown syntax
176 /// colors aligned with an explicit theme selection.
177 pub palette_mode: palette::PaletteMode,
178 }
179
180 impl Default for TranscriptRenderOptions {
181 fn default() -> Self {
182 Self {
183 show_thinking: true,
184 thinking_highlight: true,
185 thinking_default_expanded: false,
186 verbose: false,
187 show_tool_details: true,
188 inline_diff_mode: crate::settings::InlineDiffMode::Full,
189 calm_mode: false,
190 low_motion: false,
191 motion_mode: MotionMode::Full,
192 spacing: TranscriptSpacing::Comfortable,
193 palette_mode: palette::PaletteMode::detect(),
194 }
195 }
196 }
197
198 /// Max wrap measure (in columns) for prose cells - user messages, assistant
199 /// answers, and reasoning/thinking blocks - in the live transcript. At
200 /// ultrawide terminal widths this stops prose from stretching edge-to-edge
201 /// while tool and status cells keep the full content width. Applied at the
202 /// live-transcript render entry points so the main cache and the
203 /// full-screen overlay agree on the same effective width.
204 pub(crate) const PROSE_MAX_MEASURE: u16 = 105;
205
206 impl HistoryCell {
207 #[must_use]
208 pub(crate) fn has_live_motion(&self) -> bool {
209 match self {
210 HistoryCell::Assistant { streaming, .. } => *streaming,
211 HistoryCell::Tool(ToolCell::Generic(tool))
212 if tool.name == "agent" && !agent_activity::is_agent_inspection(tool) =>
213 {
214 false
215 }
216 HistoryCell::Tool(tool) => tool.is_running(),
217 HistoryCell::User { .. }
218 | HistoryCell::System { .. }
219 | HistoryCell::Error { .. }
220 | HistoryCell::Thinking { .. }
221 | HistoryCell::ArchivedContext { .. }
222 | HistoryCell::SubAgent(_) => false,
223 }
224 }
225
226 #[allow(clippy::too_many_arguments)]
227 pub(crate) fn update_incremental_streaming_render(
228 &self,
229 width: u16,
230 options: TranscriptRenderOptions,
231 verified_append: bool,
232 cache: &mut crate::tui::markdown_render::IncrementalMarkdownRenderCache,
233 lines: &mut Vec<Line<'static>>,
234 links: &mut Vec<Vec<crate::tui::osc8::LineLink>>,
235 copy_separators: &mut Vec<CopyLineSeparator>,
236 copy_prefix_widths: &mut Vec<usize>,
237 ) -> Option<usize> {
238 let HistoryCell::Assistant {
239 content,
240 streaming: true,
241 } = self
242 else {
243 return None;
244 };
245 if content.trim().is_empty() {
246 lines.clear();
247 links.clear();
248 copy_separators.clear();
249 copy_prefix_widths.clear();
250 *cache = crate::tui::markdown_render::IncrementalMarkdownRenderCache::default();
251 return Some(0);
252 }
253 let width = width.clamp(1, PROSE_MAX_MEASURE);
254 Some(update_streaming_message_render(
255 cache,
256 content,
257 width,
258 assistant_label_style_for(true, options.low_motion),
259 message_body_style(),
260 options.palette_mode,
261 verified_append,
262 lines,
263 links,
264 copy_separators,
265 copy_prefix_widths,
266 ))
267 }
268
269 /// Render the cell into a set of terminal lines.
270 ///
271 /// This is the live-display path used by widgets that don't already pass
272 /// `TranscriptRenderOptions`. Tool output is capped, but thinking is shown
273 /// in full because callers using bare `lines()` historically expected the
274 /// uncollapsed body. For the in-stream transcript view prefer
275 /// `lines_with_options`; for the pager / clipboard prefer
276 /// `transcript_lines`.
277 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
278 match self {
279 HistoryCell::User { content } => render_user_message(content, width),
280 HistoryCell::Assistant { content, streaming } => render_message(
281 ASSISTANT_GLYPH,
282 assistant_label_style_for(*streaming, /*low_motion*/ false),
283 message_body_style(),
284 content,
285 width,
286 ),
287 HistoryCell::System { content } => {
288 if is_cycle_boundary(content) {
289 render_cycle_boundary(content, width)
290 } else {
291 render_message(
292 "Note",
293 system_label_style(),
294 system_body_style(),
295 content,
296 width,
297 )
298 }
299 }
300 HistoryCell::Error { message, severity } => {
301 // Error messages are machine-generated and should not be run
302 // through markdown rendering, which would mangle env-var names
303 // containing underscores (e.g. DEEPSEEK_ALLOW_INSECURE_HTTP
304 // would lose its underscores as italic markers).
305 let label = error_label_text(*severity);
306 let label_style = error_label_style(*severity);
307 let body_style = error_body_style(*severity);
308 let prefix_width = UnicodeWidthStr::width(label);
309 let content_width = width.saturating_sub(2 + prefix_width as u16).max(1);
310 let mut lines = wrap_plain_line(message, body_style, content_width);
311 // Add the label prefix to the first line
312 if let Some(first) = lines.get_mut(0) {
313 first.spans.insert(0, Span::raw(" "));
314 first.spans.insert(0, Span::styled(label, label_style));
315 }
316 // Continuation rail for subsequent lines
317 let rail = format!("{}{}", '\u{258F}', " ".repeat(prefix_width));
318 let rail_style = Style::default().fg(palette::TEXT_DIM);
319 for line in lines.iter_mut().skip(1) {
320 line.spans.insert(0, Span::styled(rail.clone(), rail_style));
321 }
322 lines
323 }
324 HistoryCell::Thinking {
325 content,
326 streaming,
327 duration_secs,
328 } => render_thinking(content, width, *streaming, *duration_secs, false, false),
329 HistoryCell::Tool(cell) => cell.lines_with_motion(width, false),
330 HistoryCell::SubAgent(cell) => cell.lines(width),
331 HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, false),
332 }
333 }
334
335 #[allow(dead_code)] // retained for focused/detail renderers and direct rendering tests
336 pub fn lines_with_options(
337 &self,
338 width: u16,
339 options: TranscriptRenderOptions,
340 ) -> Vec<Line<'static>> {
341 self.lines_with_options_folded(width, options, false)
342 }
343
344 /// Render with an explicit per-cell fold override for thinking cells.
345 ///
346 /// Uses XOR with the `verbose` flag so that pressing Space toggles
347 /// the collapsed state *relative* to the global setting:
348 /// - verbose off (default): thinking is collapsed; Space unfolds it
349 /// - verbose on: thinking is expanded; Space folds it
350 pub fn lines_with_options_folded(
351 &self,
352 width: u16,
353 options: TranscriptRenderOptions,
354 folded: bool,
355 ) -> Vec<Line<'static>> {
356 let mut lines = match self {
357 HistoryCell::Thinking {
358 streaming,
359 duration_secs,
360 ..
361 } if !options.show_thinking => {
362 if *streaming {
363 render_hidden_thinking_activity(width, *duration_secs, options.low_motion)
364 } else {
365 Vec::new()
366 }
367 }
368 HistoryCell::Thinking {
369 content,
370 streaming,
371 duration_secs,
372 } => thinking::render_thinking_with_highlight(
373 content,
374 width,
375 *streaming,
376 *duration_secs,
377 folded ^ !options.verbose ^ options.thinking_default_expanded,
378 options.low_motion,
379 options.thinking_highlight,
380 ),
381 HistoryCell::Tool(ToolCell::PatchSummary(cell)) => cell.render(
382 width,
383 options.low_motion,
384 RenderMode::Live,
385 options.inline_diff_mode,
386 ),
387 HistoryCell::Tool(cell) if !options.show_tool_details && !cell.is_failed() => {
388 let mut lines = cell.lines_with_motion(width, options.low_motion);
389 if lines.len() > TOOL_SUMMARY_CARD_LINES {
390 lines.truncate(TOOL_SUMMARY_CARD_LINES);
391 lines.push(details_affordance_line(
392 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("details"),
393 Style::default().fg(palette::TEXT_MUTED).italic(),
394 ));
395 }
396 lines
397 }
398 HistoryCell::Tool(cell) if options.calm_mode && !cell.is_failed() => {
399 let mut lines = cell.lines_with_motion(width, options.low_motion);
400 if lines.len() > TOOL_CARD_SUMMARY_LINES {
401 lines.truncate(TOOL_CARD_SUMMARY_LINES);
402 lines.push(details_affordance_line(
403 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("details"),
404 Style::default().fg(palette::TEXT_MUTED).italic(),
405 ));
406 }
407 lines
408 }
409 HistoryCell::Tool(cell) => cell.lines_with_motion(width, options.low_motion),
410 HistoryCell::User { content } => render_user_message(content, width),
411 HistoryCell::Assistant { content, streaming } => {
412 let mut lines: Vec<Line<'static>> = render_message_with_copy_metadata_for_palette(
413 ASSISTANT_GLYPH,
414 assistant_label_style_for(*streaming, options.low_motion),
415 message_body_style(),
416 content,
417 width,
418 options.palette_mode,
419 )
420 .into_iter()
421 .map(|rendered| rendered.line)
422 .collect();
423 if *streaming {
424 apply_hot_tail_to_last_line(&mut lines, options.low_motion);
425 }
426 lines
427 }
428 HistoryCell::System { .. } | HistoryCell::Error { .. } => self.lines(width),
429 HistoryCell::SubAgent(cell) => cell.lines(width),
430 HistoryCell::ArchivedContext { .. } => {
431 render_archived_context(self, width, options.low_motion)
432 }
433 };
434 if matches!(self, HistoryCell::Tool(_)) {
435 match options.motion_mode {
436 MotionMode::Reduced => apply_static_tool_markers(
437 &mut lines,
438 crate::tui::spinner::BRAILLE_SPINNER_STILL_FRAME,
439 ),
440 MotionMode::Still => {
441 apply_static_tool_markers(&mut lines, crate::tui::spinner::LIVE_STATIC_MARKER)
442 }
443 MotionMode::Full => {}
444 }
445 }
446 lines
447 }
448
449 #[allow(dead_code)]
450 pub(crate) fn lines_with_copy_metadata(
451 &self,
452 width: u16,
453 options: TranscriptRenderOptions,
454 ) -> Vec<RenderedTranscriptLine> {
455 self.lines_with_copy_metadata_folded(width, options, false)
456 }
457
458 pub(crate) fn lines_with_copy_metadata_folded(
459 &self,
460 width: u16,
461 options: TranscriptRenderOptions,
462 folded: bool,
463 ) -> Vec<RenderedTranscriptLine> {
464 match self {
465 // Prose cells wrap at the bounded measure; tool/status cells keep
466 // the caller's full width (see PROSE_MAX_MEASURE).
467 HistoryCell::User { content } => hard_break_copy_lines(render_user_message(
468 content,
469 width.clamp(1, PROSE_MAX_MEASURE),
470 )),
471 HistoryCell::Assistant { content, streaming } => {
472 let width = width.clamp(1, PROSE_MAX_MEASURE);
473 let mut rendered = render_message_with_copy_metadata_for_palette(
474 ASSISTANT_GLYPH,
475 assistant_label_style_for(*streaming, options.low_motion),
476 message_body_style(),
477 content,
478 width,
479 options.palette_mode,
480 );
481 if *streaming && let Some(last) = rendered.last_mut() {
482 apply_hot_tail_to_line(&mut last.line, options.low_motion);
483 }
484 rendered
485 }
486 HistoryCell::System { content } if !is_cycle_boundary(content) => {
487 render_message_with_copy_metadata_for_palette(
488 "Note",
489 system_label_style(),
490 system_body_style(),
491 content,
492 width,
493 options.palette_mode,
494 )
495 }
496 HistoryCell::Tool(_) => self
497 .lines_with_options_folded(width, options, folded)
498 .into_iter()
499 .map(|line| {
500 let copy_prefix_width = tool_copy_prefix_width(&line);
501 RenderedTranscriptLine {
502 line,
503 links: Vec::new(),
504 copy_prefix_width,
505 copy_separator_after: CopyLineSeparator::Newline,
506 }
507 })
508 .collect(),
509 // Reasoning blocks follow the prose measure: they are the longest
510 // single text surface at ultrawide sizes.
511 HistoryCell::Thinking { .. } => hard_break_copy_lines(self.lines_with_options_folded(
512 width.clamp(1, PROSE_MAX_MEASURE),
513 options,
514 folded,
515 )),
516 _ => hard_break_copy_lines(self.lines_with_options_folded(width, options, folded)),
517 }
518 }
519
520 /// Render the cell in transcript mode: full content, no caps, no
521 /// visible details-pager affordances.
522 ///
523 /// Use this for full-detail pagers, clipboard exports, and any
524 /// surface that wants the complete body rather than the live summary.
525 /// For most variants (User / Assistant / System) this matches `lines()`;
526 /// `Thinking` and `Tool` are where the live and transcript surfaces
527 /// diverge.
528 pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
529 match self {
530 HistoryCell::User { content } => render_plain_message(
531 USER_GLYPH,
532 user_label_style(),
533 user_body_style(),
534 content,
535 width,
536 ),
537 HistoryCell::Assistant { content, streaming } => render_message(
538 ASSISTANT_GLYPH,
539 // Pager / clipboard surface — pin the glyph at full
540 // brightness so a screenshot reads the same as a live frame.
541 assistant_label_style_for(*streaming, /*low_motion*/ true),
542 message_body_style(),
543 content,
544 width,
545 ),
546 HistoryCell::System { .. } | HistoryCell::Error { .. } => self.lines(width),
547 HistoryCell::Thinking {
548 content,
549 streaming,
550 duration_secs,
551 } => render_thinking(
552 content,
553 width,
554 *streaming,
555 *duration_secs,
556 /*collapsed*/ false,
557 /*low_motion*/ false,
558 ),
559 HistoryCell::Tool(cell) => cell.transcript_lines(width),
560 HistoryCell::SubAgent(cell) => cell.lines(width),
561 HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, true),
562 }
563 }
564 }
565
566 /// Convert a message into history cells for rendering.
567 #[must_use]
568 pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
569 if let Some(display) = crate::runtime_handoff::restored_subagent_checkpoint_display(msg) {
570 return vec![HistoryCell::System {
571 content: display.to_string(),
572 }];
573 }
574
575 let mut cells = Vec::new();
576
577 for (block_index, block) in msg.content.iter().enumerate() {
578 match block {
579 ContentBlock::Text { text, .. } => {
580 if is_turn_metadata_block(msg, block_index, text) {
581 continue;
582 }
583 if text.starts_with("[tool_history_repair]") {
584 cells.push(HistoryCell::System {
585 content: text.clone(),
586 });
587 continue;
588 }
589 // Check if this is an `<archived_context>` block.
590 if (msg.role == "assistant"
591 || msg.role == crate::models::INTERRUPTED_ASSISTANT_ROLE)
592 && let Some(archived) = parse_archived_context(text)
593 {
594 cells.push(archived);
595 continue;
596 }
597 match msg.role.as_str() {
598 "user" => {
599 if let Some(HistoryCell::User { content }) = cells.last_mut() {
600 if !content.is_empty() {
601 content.push('\n');
602 }
603 content.push_str(text);
604 } else {
605 cells.push(HistoryCell::User {
606 content: text.clone(),
607 });
608 }
609 }
610 "assistant" => {
611 if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() {
612 if !content.is_empty() {
613 content.push('\n');
614 }
615 content.push_str(text);
616 } else {
617 cells.push(HistoryCell::Assistant {
618 content: text.clone(),
619 streaming: false,
620 });
621 }
622 }
623 "system" => {
624 if let Some(HistoryCell::System { content }) = cells.last_mut() {
625 if !content.is_empty() {
626 content.push('\n');
627 }
628 content.push_str(text);
629 } else {
630 cells.push(HistoryCell::System {
631 content: text.clone(),
632 });
633 }
634 }
635 _ => {}
636 }
637 }
638 ContentBlock::Thinking { thinking, .. } => {
639 if let Some(HistoryCell::Thinking { content, .. }) = cells.last_mut() {
640 if !content.is_empty() {
641 content.push('\n');
642 }
643 content.push_str(thinking);
644 } else {
645 cells.push(HistoryCell::Thinking {
646 content: thinking.clone(),
647 streaming: false,
648 duration_secs: None,
649 });
650 }
651 }
652 ContentBlock::ToolUse { name, input, .. } if name == "update_plan" => {
653 cells.push(HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
654 snapshot: PlanSnapshot::from_tool_input(input),
655 status: ToolStatus::Success,
656 })));
657 }
658 _ => {}
659 }
660 }
661
662 cells
663 }
664
665 /// Whether this text block is a runtime-owned `<turn_meta>` envelope that
666 /// must stay out of the visible transcript.
667 ///
668 /// Current sessions persist the envelope as the trailing block of a
669 /// multi-block user message; sessions saved before the tail move
670 /// (pre-v0.8.54) carry it as the *leading* block instead, so a complete
671 /// envelope is hidden at any index. A single-block user message is never
672 /// hidden: its text is user-authored by construction, and a literal
673 /// `<turn_meta>` example the user typed must stay visible.
674 fn is_turn_metadata_block(msg: &Message, block_index: usize, text: &str) -> bool {
675 if msg.role != "user" || msg.content.len() < 2 || !is_complete_turn_meta_envelope(text) {
676 return false;
677 }
678 if block_index > 0 {
679 return true;
680 }
681 // Leading envelope: hide it only when the trailing block is ordinary
682 // text (the legacy `[turn_meta, prompt]` persisted shape). If the tail
683 // block is itself an envelope, this message is the current shape and the
684 // leading block is user-authored literal text that must stay visible.
685 matches!(
686 msg.content.last(),
687 Some(ContentBlock::Text { text: tail, .. }) if !is_complete_turn_meta_envelope(tail)
688 )
689 }
690
691 fn is_complete_turn_meta_envelope(text: &str) -> bool {
692 let trimmed = text.trim();
693 trimmed
694 .strip_prefix("<turn_meta>")
695 .and_then(|body| body.strip_suffix("</turn_meta>"))
696 .is_some()
697 }
698
699 // === Tool Cells ===
700
701 /// Variants describing a tool result cell.
702 #[derive(Debug, Clone)]
703 pub enum ToolCell {
704 Exec(ExecCell),
705 Exploring(ExploringCell),
706 PlanUpdate(PlanUpdateCell),
707 PatchSummary(PatchSummaryCell),
708 Review(ReviewCell),
709 /// Standalone preview compatibility cell. Approval previews now live in
710 /// the modal and successful mutations use `PatchSummary`, but keeping this
711 /// renderer avoids discarding the older transcript shape outright.
712 #[allow(dead_code)]
713 DiffPreview(DiffPreviewCell),
714 Mcp(McpToolCell),
715 ViewImage(ViewImageCell),
716 WebSearch(WebSearchCell),
717 Generic(GenericToolCell),
718 }
719
720 impl ToolCell {
721 /// Whether this tool cell projects durable Work state rather than a
722 /// transient action receipt. Transcript rhythm uses this semantic split
723 /// to keep plans, checklists, and workflows legible without teaching the
724 /// renderer about individual tool payloads.
725 #[must_use]
726 pub(crate) fn is_durable_work_receipt(&self) -> bool {
727 matches!(self, ToolCell::PlanUpdate(_))
728 || matches!(
729 self,
730 ToolCell::Generic(cell)
731 if cell.name == "workflow" || is_checklist_tool_name(&cell.name)
732 )
733 }
734
735 /// Status for cells that have a concrete lifecycle state.
736 pub fn status(&self) -> Option<ToolStatus> {
737 match self {
738 ToolCell::Exec(cell) => Some(cell.status),
739 ToolCell::Exploring(cell) => {
740 let has_running = cell
741 .entries
742 .iter()
743 .any(|entry| entry.status == ToolStatus::Running);
744 let has_failed = cell
745 .entries
746 .iter()
747 .any(|entry| entry.status == ToolStatus::Failed);
748 Some(if has_running {
749 ToolStatus::Running
750 } else if has_failed {
751 ToolStatus::Failed
752 } else {
753 ToolStatus::Success
754 })
755 }
756 ToolCell::PlanUpdate(cell) => Some(cell.status),
757 ToolCell::PatchSummary(cell) => Some(cell.status),
758 ToolCell::Review(cell) => Some(cell.status),
759 ToolCell::Mcp(cell) => Some(cell.status),
760 ToolCell::WebSearch(cell) => Some(cell.status),
761 ToolCell::Generic(cell) => Some(cell.status),
762 ToolCell::DiffPreview(_) | ToolCell::ViewImage(_) => Some(ToolStatus::Success),
763 }
764 }
765
766 #[must_use]
767 pub fn is_success(&self) -> bool {
768 self.status() == Some(ToolStatus::Success)
769 }
770
771 #[must_use]
772 pub fn is_running(&self) -> bool {
773 self.status() == Some(ToolStatus::Running)
774 }
775
776 #[must_use]
777 pub fn is_failed(&self) -> bool {
778 self.status() == Some(ToolStatus::Failed)
779 }
780
781 /// Whether this cell should stay visible even inside a dense tool run.
782 #[must_use]
783 pub fn is_collapsible_guard(&self) -> bool {
784 self.is_running()
785 || self.is_failed()
786 || matches!(
787 self,
788 ToolCell::Exec(_)
789 | ToolCell::PatchSummary(_)
790 | ToolCell::Review(_)
791 | ToolCell::DiffPreview(_)
792 | ToolCell::PlanUpdate(_)
793 )
794 || matches!(self, ToolCell::Generic(cell) if tool_run::generic_tool_name_is_collapse_guard(&cell.name) || cell.is_diff)
795 }
796
797 /// Render the tool cell into lines.
798 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
799 self.lines_with_motion(width, false)
800 }
801
802 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
803 self.render(width, low_motion, RenderMode::Live)
804 }
805
806 /// Full-content rendering for the pager / clipboard. Tool output that
807 /// would be capped + suffixed with a details-pager hint in the live view
808 /// is emitted in full here.
809 pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
810 self.render(width, /*low_motion*/ false, RenderMode::Transcript)
811 }
812
813 fn render(&self, width: u16, low_motion: bool, mode: RenderMode) -> Vec<Line<'static>> {
814 match self {
815 ToolCell::Exec(cell) => cell.render(width, low_motion, mode),
816 ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion),
817 ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion),
818 ToolCell::PatchSummary(cell) => cell.render(
819 width,
820 low_motion,
821 mode,
822 crate::settings::InlineDiffMode::Full,
823 ),
824 ToolCell::Review(cell) => cell.render(width, low_motion, mode),
825 ToolCell::DiffPreview(cell) => cell.lines_with_motion(width, low_motion),
826 ToolCell::Mcp(cell) => cell.render(width, low_motion, mode),
827 ToolCell::ViewImage(cell) => cell.lines_with_motion(width, low_motion),
828 ToolCell::WebSearch(cell) => cell.lines_with_motion(width, low_motion),
829 ToolCell::Generic(cell) => cell.lines_with_mode(width, low_motion, mode),
830 }
831 }
832 }
833
834 /// Overall status for a tool execution.
835 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
836 pub enum ToolStatus {
837 Running,
838 Success,
839 Hydrated,
840 Failed,
841 }
842
843 /// Shell command execution rendering data.
844 #[derive(Debug, Clone)]
845 pub struct ExecCell {
846 pub command: String,
847 pub status: ToolStatus,
848 pub output: Option<String>,
849 pub live_output: Option<String>,
850 pub shell_task_id: Option<String>,
851 pub owner_agent_id: Option<String>,
852 pub owner_agent_name: Option<String>,
853 pub started_at: Option<Instant>,
854 pub duration_ms: Option<u64>,
855 pub stale_elapsed_since_output_ms: Option<u64>,
856 pub source: ExecSource,
857 pub interaction: Option<String>,
858 /// Cached output summary — avoids re-parsing JSON every frame.
859 pub output_summary: Option<String>,
860 }
861
862 impl ExecCell {
863 /// Render the execution cell into lines (live view, capped output).
864 #[cfg(test)]
865 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
866 self.render(width, low_motion, RenderMode::Live)
867 }
868
869 /// Foreground `exec_shell` blocking the turn — eligible for Ctrl+B detach.
870 fn is_foreground_shell_wait(&self) -> bool {
871 self.status == ToolStatus::Running
872 && self.source == ExecSource::Assistant
873 && self.interaction.is_none()
874 }
875
876 pub(super) fn render(
877 &self,
878 width: u16,
879 low_motion: bool,
880 mode: RenderMode,
881 ) -> Vec<Line<'static>> {
882 let mut lines = Vec::new();
883 let command_summary = command_header_summary(&self.command);
884 let compact_foreground_wait = self.is_foreground_shell_wait();
885 let header_summary = if compact_foreground_wait {
886 Some(FOREGROUND_SHELL_WAIT_HINT)
887 } else {
888 self.interaction
889 .as_deref()
890 .or(Some(command_summary.as_str()))
891 };
892 let stale_status = self
893 .stale_elapsed_since_output_ms
894 .map(stale_shell_status_label);
895 lines.push(render_tool_header_with_summary(
896 "Shell",
897 header_summary,
898 stale_status
899 .as_deref()
900 .unwrap_or_else(|| tool_status_label(self.status)),
901 self.status,
902 self.started_at,
903 low_motion || stale_status.is_some(),
904 ));
905
906 // Foreground shell waits block the turn but do not need a verbose
907 // transcript card — spinner + running badge + Ctrl+B hint only.
908 // Command, live output, and artifact paths belong in the Activity sidebar
909 // and `/jobs` detail surfaces.
910 if compact_foreground_wait {
911 return wrap_card_rail(lines);
912 }
913
914 // A successful shell call does not earn its full body in live mode —
915 // failures stay fully verbose so errors remain visible, and Transcript
916 // mode keeps everything for the pager/clipboard. But it does earn a
917 // glimpse: collapsing success to the bare header meant a `run` card
918 // showed literally nothing of what the command produced, and you had
919 // to expand every single one to find out whether anything happened.
920 // `TOOL_SUCCESS_OUTPUT_PREVIEW_LINES` rows show roughly half of real
921 // successful runs in full and the opening of the rest.
922 if mode == RenderMode::Live
923 && self
924 .output
925 .as_deref()
926 .is_some_and(is_truncated_output_preview)
927 {
928 lines.push(render_spillover_annotation(width));
929 return wrap_card_rail(lines);
930 }
931 if mode == RenderMode::Live && self.status == ToolStatus::Success {
932 if self.interaction.is_none()
933 && let Some(output) = self.output.as_ref().or(self.live_output.as_ref())
934 {
935 lines.extend(render_exec_output_mode(
936 output,
937 width,
938 TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
939 mode,
940 ));
941 }
942 if let Some(duration_ms) = self.duration_ms
943 && duration_ms >= 1000
944 {
945 lines.extend(render_compact_kv(
946 "time",
947 &crate::elapsed::format_elapsed_ms(duration_ms),
948 Style::default().fg(palette::TEXT_DIM),
949 width,
950 ));
951 }
952 return wrap_card_rail(lines);
953 }
954
955 if self.status == ToolStatus::Success && self.source == ExecSource::User {
956 lines.extend(render_compact_kv(
957 "source",
958 "started by you",
959 Style::default().fg(palette::TEXT_MUTED),
960 width,
961 ));
962 }
963
964 if let Some(owner) = self
965 .owner_agent_name
966 .as_deref()
967 .or(self.owner_agent_id.as_deref())
968 {
969 lines.extend(render_compact_kv(
970 "owner",
971 owner,
972 Style::default().fg(palette::TEXT_MUTED),
973 width,
974 ));
975 }
976
977 if let Some(interaction) = self.interaction.as_ref() {
978 lines.extend(wrap_plain_line(
979 &format!(" {interaction}"),
980 Style::default().fg(palette::TEXT_MUTED),
981 width,
982 ));
983 } else {
984 lines.extend(render_command_mode(&self.command, width, mode));
985 }
986
987 if self.interaction.is_none() {
988 if let Some(output) = self.output.as_ref().or(self.live_output.as_ref()) {
989 lines.extend(render_exec_output_mode(
990 output,
991 width,
992 TOOL_OUTPUT_LINE_LIMIT,
993 mode,
994 ));
995 } else if self.status == ToolStatus::Running && self.source == ExecSource::Assistant {
996 lines.extend(wrap_plain_line(
997 " Ctrl+B moves this shell wait to /jobs.",
998 Style::default().fg(palette::TEXT_MUTED),
999 width,
1000 ));
1001 } else if self.status != ToolStatus::Running && mode == RenderMode::Transcript {
1002 // #3031: Suppress "(no output)" in compact/Live mode;
1003 // the success header is enough signal. Transcript still
1004 // records it for exports/clipboard/pager.
1005 lines.push(Line::from(Span::styled(
1006 " (no output)",
1007 Style::default().fg(palette::TEXT_MUTED).italic(),
1008 )));
1009 }
1010 }
1011
1012 if let Some(duration_ms) = self.duration_ms {
1013 // #3031: Suppress sub-second timing in compact mode.
1014 // Transcript mode always shows timing.
1015 if mode == RenderMode::Transcript || duration_ms >= 1000 {
1016 lines.extend(render_compact_kv(
1017 "time",
1018 &crate::elapsed::format_elapsed_ms(duration_ms),
1019 Style::default().fg(palette::TEXT_DIM),
1020 width,
1021 ));
1022 }
1023 }
1024
1025 wrap_card_rail(lines)
1026 }
1027 }
1028
1029 /// Source of a shell command execution.
1030 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1031 pub enum ExecSource {
1032 User,
1033 Assistant,
1034 }
1035
1036 /// Aggregate cell for tool exploration runs.
1037 #[derive(Debug, Clone)]
1038 pub struct ExploringCell {
1039 pub entries: Vec<ExploringEntry>,
1040 }
1041
1042 impl ExploringCell {
1043 /// Render the exploring cell into lines.
1044 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1045 let mut lines = Vec::new();
1046 let all_done = self
1047 .entries
1048 .iter()
1049 .all(|entry| entry.status != ToolStatus::Running);
1050 let any_hydrated = self
1051 .entries
1052 .iter()
1053 .any(|entry| entry.status == ToolStatus::Hydrated);
1054 let status = if all_done {
1055 if any_hydrated {
1056 ToolStatus::Hydrated
1057 } else {
1058 ToolStatus::Success
1059 }
1060 } else {
1061 ToolStatus::Running
1062 };
1063 let header_summary = exploring_header_summary(&self.entries);
1064 let multi_entry = self.entries.len() > 1;
1065 let header_state = if multi_entry {
1066 ""
1067 } else if all_done {
1068 tool_status_label(status)
1069 } else {
1070 "running"
1071 };
1072 // Search-only exploration cards read with the `find` verb so a
1073 // completed grep renders `find done · Searching for …` instead of the
1074 // incoherent `read done · Searching …` (#4145). Read/list or mixed
1075 // cards keep the neutral `read` verb the Workspace card has always used.
1076 let family = exploring_card_family(&self.entries);
1077 lines.push(render_tool_header_with_family_and_summary(
1078 family,
1079 header_summary.as_deref(),
1080 header_state,
1081 status,
1082 None,
1083 low_motion,
1084 ));
1085
1086 // Dot-grid status strip — one glyph per entry, showing parallel
1087 // fanout at a glance: ●=done ◐=running ✕=failed.
1088 if self.entries.len() > 1 {
1089 let (done, running, failed) =
1090 self.entries
1091 .iter()
1092 .fold((0usize, 0usize, 0usize), |(d, r, f), e| match e.status {
1093 ToolStatus::Success | ToolStatus::Hydrated => (d + 1, r, f),
1094 ToolStatus::Running => (d, r + 1, f),
1095 ToolStatus::Failed => (d, r, f + 1),
1096 });
1097 let dots: String = self
1098 .entries
1099 .iter()
1100 .map(|e| match e.status {
1101 ToolStatus::Success | ToolStatus::Hydrated => "\u{25CF}",
1102 ToolStatus::Running => "\u{25D0}",
1103 ToolStatus::Failed => "\u{2715}",
1104 })
1105 .collect();
1106 let counts = format!(
1107 "{done} done, {running} running{}",
1108 if failed > 0 {
1109 format!(", {failed} failed")
1110 } else {
1111 String::new()
1112 },
1113 );
1114 lines.push(Line::styled(
1115 format!(" {dots} {counts}"),
1116 Style::default().fg(palette::WHALE_INFO),
1117 ));
1118 }
1119
1120 for entry in &self.entries {
1121 if multi_entry {
1122 lines.extend(render_card_detail_line(
1123 None,
1124 &entry.label,
1125 tool_value_style(),
1126 width,
1127 ));
1128 } else {
1129 let prefix = match entry.status {
1130 ToolStatus::Running => "live",
1131 ToolStatus::Success => "done",
1132 ToolStatus::Hydrated => "loaded",
1133 ToolStatus::Failed => "issue",
1134 };
1135 lines.extend(render_compact_kv(
1136 prefix,
1137 &entry.label,
1138 tool_value_style(),
1139 width,
1140 ));
1141 }
1142 }
1143 lines
1144 }
1145
1146 /// Insert a new entry and return its index.
1147 #[must_use]
1148 pub fn insert_entry(&mut self, entry: ExploringEntry) -> usize {
1149 self.entries.push(entry);
1150 self.entries.len().saturating_sub(1)
1151 }
1152 }
1153
1154 /// Single entry for exploring tool output.
1155 #[derive(Debug, Clone)]
1156 pub struct ExploringEntry {
1157 pub label: String,
1158 pub status: ToolStatus,
1159 }
1160
1161 /// Calm outcome and exact evidence for a structured File mutation.
1162 #[derive(Debug, Clone)]
1163 pub struct PatchSummaryCell {
1164 pub path: String,
1165 pub summary: String,
1166 pub status: ToolStatus,
1167 pub error: Option<String>,
1168 pub receipt: Option<FileMutationReceipt>,
1169 }
1170
1171 impl PatchSummaryCell {
1172 pub(super) fn render(
1173 &self,
1174 width: u16,
1175 low_motion: bool,
1176 mode: RenderMode,
1177 inline_diff_mode: crate::settings::InlineDiffMode,
1178 ) -> Vec<Line<'static>> {
1179 let mut lines = Vec::new();
1180 let header_summary = self
1181 .receipt
1182 .as_ref()
1183 .map(FileMutationReceipt::outcome_label)
1184 .unwrap_or_else(|| self.path.clone());
1185 lines.push(render_tool_header_with_summary(
1186 "File",
1187 Some(&header_summary),
1188 tool_status_label(self.status),
1189 self.status,
1190 None,
1191 low_motion,
1192 ));
1193 if self.status == ToolStatus::Success
1194 && let Some(receipt) = self.receipt.as_ref()
1195 {
1196 lines.extend(receipt.render_inline(width, inline_diff_mode));
1197 } else {
1198 lines.extend(render_compact_kv(
1199 "file",
1200 &self.path,
1201 tool_value_style(),
1202 width,
1203 ));
1204 lines.extend(render_tool_output_mode(
1205 &self.summary,
1206 width,
1207 TOOL_COMMAND_LINE_LIMIT,
1208 mode,
1209 ));
1210 }
1211 if let Some(error) = self.error.as_ref() {
1212 lines.extend(render_tool_output_mode(
1213 error,
1214 width,
1215 TOOL_COMMAND_LINE_LIMIT,
1216 mode,
1217 ));
1218 }
1219 lines
1220 }
1221 }
1222
1223 /// Cell for structured review output.
1224 #[derive(Debug, Clone)]
1225 pub struct ReviewCell {
1226 pub target: String,
1227 pub status: ToolStatus,
1228 pub output: Option<ReviewOutput>,
1229 pub error: Option<String>,
1230 }
1231
1232 impl ReviewCell {
1233 pub(super) fn render(
1234 &self,
1235 width: u16,
1236 low_motion: bool,
1237 mode: RenderMode,
1238 ) -> Vec<Line<'static>> {
1239 let mut lines = Vec::new();
1240 lines.push(render_tool_header(
1241 "Review",
1242 tool_status_label(self.status),
1243 self.status,
1244 None,
1245 low_motion,
1246 ));
1247
1248 if !self.target.trim().is_empty() {
1249 lines.extend(render_compact_kv(
1250 "target",
1251 self.target.trim(),
1252 tool_value_style(),
1253 width,
1254 ));
1255 }
1256
1257 if self.status == ToolStatus::Running {
1258 return lines;
1259 }
1260
1261 if let Some(error) = self.error.as_ref() {
1262 lines.extend(render_tool_output_mode(
1263 error,
1264 width,
1265 TOOL_COMMAND_LINE_LIMIT,
1266 mode,
1267 ));
1268 return lines;
1269 }
1270
1271 let Some(output) = self.output.as_ref() else {
1272 return lines;
1273 };
1274
1275 if !output.summary.trim().is_empty() {
1276 lines.extend(wrap_plain_line(
1277 &format!("Summary: {}", output.summary.trim()),
1278 Style::default().fg(palette::TEXT_PRIMARY),
1279 width,
1280 ));
1281 }
1282
1283 lines.push(Line::from(""));
1284 lines.push(Line::from(Span::styled(
1285 "Issues",
1286 Style::default()
1287 .fg(palette::WHALE_ACTION)
1288 .add_modifier(Modifier::BOLD),
1289 )));
1290 if output.issues.is_empty() {
1291 lines.extend(wrap_plain_line(
1292 " (none)",
1293 Style::default().fg(palette::TEXT_MUTED),
1294 width,
1295 ));
1296 } else {
1297 for issue in &output.issues {
1298 let severity = issue.severity.trim().to_ascii_lowercase();
1299 let color = review_severity_color(&severity);
1300 let location = format_review_location(issue.path.as_ref(), issue.line);
1301 let label = if location.is_empty() {
1302 format!(" - [{}] {}", severity, issue.title.trim())
1303 } else {
1304 format!(" - [{}] {} ({})", severity, issue.title.trim(), location)
1305 };
1306 lines.extend(wrap_plain_line(&label, Style::default().fg(color), width));
1307 if !issue.description.trim().is_empty() {
1308 lines.extend(wrap_plain_line(
1309 &format!(" {}", issue.description.trim()),
1310 Style::default().fg(palette::TEXT_MUTED),
1311 width,
1312 ));
1313 }
1314 }
1315 }
1316
1317 lines.push(Line::from(""));
1318 lines.push(Line::from(Span::styled(
1319 "Suggestions",
1320 Style::default()
1321 .fg(palette::WHALE_ACTION)
1322 .add_modifier(Modifier::BOLD),
1323 )));
1324 if output.suggestions.is_empty() {
1325 lines.extend(wrap_plain_line(
1326 " (none)",
1327 Style::default().fg(palette::TEXT_MUTED),
1328 width,
1329 ));
1330 } else {
1331 for suggestion in &output.suggestions {
1332 let location = format_review_location(suggestion.path.as_ref(), suggestion.line);
1333 let label = if location.is_empty() {
1334 format!(" - {}", suggestion.suggestion.trim())
1335 } else {
1336 format!(" - {} ({})", suggestion.suggestion.trim(), location)
1337 };
1338 lines.extend(wrap_plain_line(
1339 &label,
1340 Style::default().fg(palette::TEXT_PRIMARY),
1341 width,
1342 ));
1343 }
1344 }
1345
1346 if !output.overall_assessment.trim().is_empty() {
1347 lines.push(Line::from(""));
1348 lines.extend(wrap_plain_line(
1349 &format!("Overall: {}", output.overall_assessment.trim()),
1350 Style::default().fg(palette::TEXT_PRIMARY),
1351 width,
1352 ));
1353 }
1354
1355 lines
1356 }
1357 }
1358
1359 /// Cell for showing a diff preview before applying changes.
1360 #[derive(Debug, Clone)]
1361 pub struct DiffPreviewCell {
1362 pub title: String,
1363 pub diff: String,
1364 }
1365
1366 impl DiffPreviewCell {
1367 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1368 let mut lines = Vec::new();
1369 let diff_summary = diff_render::diff_summary_label(&self.diff);
1370 lines.push(render_tool_header_with_summary(
1371 "Diff",
1372 diff_summary.as_deref(),
1373 "done",
1374 ToolStatus::Success,
1375 None,
1376 low_motion,
1377 ));
1378 lines.extend(render_compact_kv(
1379 "title",
1380 &self.title,
1381 tool_value_style(),
1382 width,
1383 ));
1384 lines.extend(diff_render::render_diff(&self.diff, width));
1385 lines
1386 }
1387 }
1388
1389 /// Cell representing an MCP tool execution.
1390 #[derive(Debug, Clone)]
1391 pub struct McpToolCell {
1392 pub tool: String,
1393 pub status: ToolStatus,
1394 pub content: Option<String>,
1395 pub is_image: bool,
1396 }
1397
1398 impl McpToolCell {
1399 pub(super) fn render(
1400 &self,
1401 width: u16,
1402 low_motion: bool,
1403 mode: RenderMode,
1404 ) -> Vec<Line<'static>> {
1405 let mut lines = Vec::new();
1406 lines.push(render_tool_header_with_summary(
1407 "Tool",
1408 Some(&self.tool),
1409 tool_status_label(self.status),
1410 self.status,
1411 None,
1412 low_motion,
1413 ));
1414 lines.extend(render_compact_kv(
1415 "name",
1416 &self.tool,
1417 tool_value_style(),
1418 width,
1419 ));
1420
1421 if self.is_image {
1422 lines.extend(render_compact_kv(
1423 "result",
1424 "image",
1425 tool_value_style(),
1426 width,
1427 ));
1428 }
1429
1430 if let Some(content) = self.content.as_ref() {
1431 if mode == RenderMode::Live && is_truncated_output_preview(content) {
1432 lines.push(render_spillover_annotation(width));
1433 return lines;
1434 }
1435 lines.extend(render_tool_output_mode(
1436 content,
1437 width,
1438 TOOL_COMMAND_LINE_LIMIT,
1439 mode,
1440 ));
1441 }
1442 lines
1443 }
1444 }
1445
1446 /// Cell for image view actions.
1447 #[derive(Debug, Clone)]
1448 pub struct ViewImageCell {
1449 pub path: PathBuf,
1450 }
1451
1452 impl ViewImageCell {
1453 /// Render the image view cell into lines.
1454 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1455 let path = self.path.display().to_string();
1456 let mut lines = vec![render_tool_header_with_summary(
1457 "Image",
1458 Some(&path),
1459 "done",
1460 ToolStatus::Success,
1461 None,
1462 low_motion,
1463 )];
1464 lines.extend(render_compact_kv("path", &path, tool_value_style(), width));
1465 lines
1466 }
1467 }
1468
1469 /// Cell for web search tool output.
1470 #[derive(Debug, Clone)]
1471 pub struct WebSearchCell {
1472 pub query: String,
1473 pub status: ToolStatus,
1474 pub summary: Option<String>,
1475 pub source: Option<String>,
1476 pub degraded: Option<String>,
1477 pub ref_count: usize,
1478 }
1479
1480 impl WebSearchCell {
1481 /// Render the web search cell into lines.
1482 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1483 let mut lines = Vec::new();
1484 lines.push(render_tool_header_with_summary(
1485 "Search",
1486 Some(&self.query),
1487 tool_status_label(self.status),
1488 self.status,
1489 None,
1490 low_motion,
1491 ));
1492 lines.extend(render_compact_kv(
1493 "query",
1494 &self.query,
1495 tool_value_style(),
1496 width,
1497 ));
1498 if let Some(source) = self.source.as_ref() {
1499 lines.extend(render_compact_kv(
1500 "source",
1501 source,
1502 tool_value_style(),
1503 width,
1504 ));
1505 }
1506 if let Some(degraded) = self.degraded.as_ref() {
1507 lines.extend(render_compact_kv(
1508 "degraded",
1509 degraded,
1510 tool_value_style(),
1511 width,
1512 ));
1513 }
1514 if self.ref_count > 0 {
1515 lines.extend(render_compact_kv(
1516 "citations",
1517 &self.ref_count.to_string(),
1518 tool_value_style(),
1519 width,
1520 ));
1521 }
1522 if let Some(summary) = self.summary.as_ref() {
1523 lines.extend(render_compact_kv(
1524 "result",
1525 summary,
1526 tool_value_style(),
1527 width,
1528 ));
1529 }
1530 lines
1531 }
1532 }
1533
1534 /// Generic cell for tool output when no specialized rendering exists.
1535 #[derive(Debug, Clone)]
1536 pub struct GenericToolCell {
1537 pub name: String,
1538 pub status: ToolStatus,
1539 pub input_summary: Option<String>,
1540 pub output: Option<String>,
1541 /// Optional list of per-child prompts. When populated (by any future
1542 /// fan-out tool), each prompt is shown on its own indented row instead
1543 /// of the inline `args:` summary. `None` for ordinary tools.
1544 pub prompts: Option<Vec<String>>,
1545 /// Filesystem path to the full output's spillover file (#422/#423).
1546 /// Set by the tool-routing layer when `ToolResult.metadata` carried a
1547 /// `spillover_path` field. The truncation affordance includes the
1548 /// path so the user can `read_file` it (or Cmd+click in
1549 /// OSC 8-aware terminals — the path renders as a hyperlink when
1550 /// `tui.osc8_links` is enabled).
1551 pub spillover_path: Option<std::path::PathBuf>,
1552 // --- Pre-computed render cache (populated once at cell creation) ---
1553 /// Cached output summary — avoids re-parsing JSON every frame.
1554 pub output_summary: Option<String>,
1555 /// Whether the output looks like a unified diff (cached after first check).
1556 pub is_diff: bool,
1557 }
1558
1559 fn should_show_raw_tool_name(
1560 name: &str,
1561 family: crate::tui::widgets::tool_card::ToolFamily,
1562 mode: RenderMode,
1563 ) -> bool {
1564 matches!(mode, RenderMode::Transcript)
1565 || matches!(family, crate::tui::widgets::tool_card::ToolFamily::Generic)
1566 || name.starts_with("mcp_")
1567 }
1568
1569 impl GenericToolCell {
1570 /// Render the generic tool cell into lines.
1571 ///
1572 /// `mode` controls multi-line output handling: `Live` caps at
1573 /// `TOOL_OUTPUT_LINE_LIMIT` rows with a "+N more" affordance;
1574 /// `Transcript` emits the full output.
1575 pub fn lines_with_mode(
1576 &self,
1577 width: u16,
1578 low_motion: bool,
1579 mode: RenderMode,
1580 ) -> Vec<Line<'static>> {
1581 if self.name == "activity_group" {
1582 return agent_activity::render_activity_group(self, width);
1583 }
1584
1585 // Issue #241: when the underlying tool is a checklist/todo update and
1586 // the output is parseable, render a purpose-built progress card
1587 // instead of dumping the JSON into the generic tool block.
1588 if let Some(lines) = self.try_render_as_checklist(width, low_motion, mode) {
1589 return lines;
1590 }
1591
1592 // #4038 / #4122: purpose-built workflow run card (compact in live,
1593 // expanded in transcript) shared with the WorkflowPanel state machine.
1594 if let Some(lines) = self.try_render_as_workflow(width, low_motion, mode) {
1595 return lines;
1596 }
1597
1598 // Sub-agent launch already gets a dedicated `DelegateCard`
1599 // that owns the live action tree, status, and final summary (#4133).
1600 // Spawns therefore render nothing here in either mode — one visible
1601 // artifact per delegated unit. Inspection/join calls (peek/status/
1602 // wait) stay as a single compact line (#4112 dogfood A5).
1603 if self.name == "agent" {
1604 if agent_activity::is_agent_inspection(self) {
1605 return agent_activity::render_agent_compact(self, low_motion);
1606 }
1607 // Spawn / start / run: suppress the generic tool card entirely.
1608 return Vec::new();
1609 }
1610
1611 // A call to a tool that doesn't exist carries exactly one useful
1612 // fact: the catalog error. The full name:/args:/result: block turns
1613 // each model slip into a four-line card (dogfood A5) — collapse it
1614 // to a single header line in both render modes.
1615 if self.status == ToolStatus::Failed
1616 && let Some(output) = self.output.as_deref()
1617 && output.contains("is not available in the current tool catalog")
1618 {
1619 let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
1620 let summary = truncate_text(output.trim(), 200);
1621 return wrap_card_rail(vec![render_tool_header_with_family_and_summary(
1622 family,
1623 Some(summary.as_str()),
1624 tool_status_label(self.status),
1625 self.status,
1626 None,
1627 low_motion,
1628 )]);
1629 }
1630
1631 // Live mode stays calm: successful tool calls collapse to one header
1632 // line, and non-read in-flight tools do the same. Failures keep their
1633 // body visible because error output is the useful part.
1634 if matches!(mode, RenderMode::Live) {
1635 let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
1636 let is_read_family = matches!(
1637 family,
1638 crate::tui::widgets::tool_card::ToolFamily::Read
1639 | crate::tui::widgets::tool_card::ToolFamily::Find
1640 );
1641 let should_collapse = self.status == ToolStatus::Success
1642 || (self.status != ToolStatus::Failed && !is_read_family);
1643 if should_collapse || self.spillover_path.is_some() {
1644 let header_summary = crate::tui::widgets::tool_card::tool_header_summary_for_name(
1645 &self.name,
1646 self.input_summary.as_deref(),
1647 );
1648 let mut collapsed = vec![render_tool_header_with_family_and_summary(
1649 family,
1650 header_summary.as_deref(),
1651 tool_status_label(self.status),
1652 self.status,
1653 None,
1654 low_motion,
1655 )];
1656 if self.spillover_path.is_some() {
1657 collapsed.push(render_spillover_annotation(width));
1658 }
1659 return wrap_card_rail(collapsed);
1660 }
1661 }
1662
1663 let mut lines = Vec::new();
1664 // Map the actual tool name (e.g. `agent`, `apply_patch`) to a
1665 // family rather than the catch-all `"Tool"` title — this is what
1666 // gives a `GenericToolCell` the right verb glyph (◐ delegate, ⋮⋮
1667 // fanout, etc.) instead of falling back to the neutral bullet.
1668 let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
1669 let header_summary = crate::tui::widgets::tool_card::tool_header_summary_for_name(
1670 &self.name,
1671 self.input_summary.as_deref(),
1672 );
1673 lines.push(render_tool_header_with_family_and_summary(
1674 family,
1675 header_summary.as_deref(),
1676 tool_status_label(self.status),
1677 self.status,
1678 None,
1679 low_motion,
1680 ));
1681 if should_show_raw_tool_name(&self.name, family, mode) {
1682 lines.extend(render_compact_kv(
1683 "name",
1684 &self.name,
1685 tool_value_style(),
1686 width,
1687 ));
1688 }
1689
1690 // Prefer per-prompt rows over the generic args summary when the tool
1691 // exposes a list of child prompts. One row per child with a `[i]`
1692 // index makes the fan-out legible without expanding JSON.
1693 let show_prompts = matches!(self.status, ToolStatus::Running) || self.output.is_none();
1694 if show_prompts
1695 && let Some(prompts) = self.prompts.as_ref()
1696 && !prompts.is_empty()
1697 {
1698 for (idx, prompt) in prompts.iter().enumerate() {
1699 let label = if idx == 0 { "prompts" } else { "" };
1700 let value = format!("[{idx}] {}", truncate_text(prompt.trim(), 200));
1701 lines.extend(render_card_detail_line(
1702 if label.is_empty() { None } else { Some(label) },
1703 &value,
1704 tool_value_style(),
1705 width,
1706 ));
1707 }
1708 } else {
1709 let show_args = matches!(self.status, ToolStatus::Running | ToolStatus::Failed)
1710 || self.output.is_none();
1711 if show_args && let Some(summary) = self.input_summary.as_ref() {
1712 lines.extend(render_compact_kv(
1713 "args",
1714 summary,
1715 tool_value_style(),
1716 width,
1717 ));
1718 }
1719 }
1720
1721 if let Some(output) = self.output.as_ref() {
1722 if self.is_diff {
1723 let diff_summary = diff_render::diff_summary_label(output);
1724 lines.push(render_tool_header_with_summary(
1725 "Diff",
1726 diff_summary.as_deref(),
1727 tool_status_label(self.status),
1728 self.status,
1729 None,
1730 low_motion,
1731 ));
1732 lines.extend(diff_render::render_diff(output, width));
1733 } else {
1734 let output_mode =
1735 if matches!(mode, RenderMode::Live) && self.status == ToolStatus::Failed {
1736 RenderMode::Transcript
1737 } else {
1738 mode
1739 };
1740 lines.extend(render_tool_output_mode(
1741 output,
1742 width,
1743 TOOL_OUTPUT_LINE_LIMIT,
1744 output_mode,
1745 ));
1746 }
1747
1748 if matches!(mode, RenderMode::Live) && self.spillover_path.is_some() {
1749 lines.push(render_spillover_annotation(width));
1750 }
1751 }
1752 wrap_card_rail(lines)
1753 }
1754
1755 /// If this cell is a checklist/todo write/add/update and the output is
1756 /// parseable as a checklist snapshot, render a purpose-built checklist
1757 /// card instead of the generic `name: ... { json }` block (issue #241).
1758 fn try_render_as_checklist(
1759 &self,
1760 width: u16,
1761 low_motion: bool,
1762 mode: RenderMode,
1763 ) -> Option<Vec<Line<'static>>> {
1764 if !is_checklist_tool_name(&self.name) {
1765 return None;
1766 }
1767 let output = self.output.as_ref()?;
1768 let snapshot = parse_checklist_snapshot(output)?;
1769
1770 // Concise update rendering (#403). When the tool emits an
1771 // "Updated todo #N to STATUS" prefix line — which `todo_update` /
1772 // `checklist_update` always do on a successful match — render
1773 // only the changed item plus a `M/N · pct%` summary instead of
1774 // dumping the full list every time. The full list is still
1775 // reachable via `v` on the tool detail record. This keeps the
1776 // transcript scannable in long sessions.
1777 if matches!(mode, RenderMode::Live)
1778 && let Some(change) = parse_update_prefix(output)
1779 {
1780 return Some(render_checklist_change_card(
1781 &self.name,
1782 self.status,
1783 &snapshot,
1784 &change,
1785 width,
1786 low_motion,
1787 ));
1788 }
1789
1790 Some(render_checklist_card(
1791 &self.name,
1792 self.status,
1793 &snapshot,
1794 width,
1795 low_motion,
1796 mode,
1797 ))
1798 }
1799
1800 /// Render the `workflow` tool via the shared WorkflowPanel history-card
1801 /// renderer (#4122). Live mode stays compact (lifecycle, children, phases,
1802 /// failures, elapsed); transcript mode expands phase/child summaries,
1803 /// artifact/transcript links, final result, and failure details.
1804 /// Status-list payloads keep a multi-run summary card.
1805 fn try_render_as_workflow(
1806 &self,
1807 width: u16,
1808 low_motion: bool,
1809 mode: RenderMode,
1810 ) -> Option<Vec<Line<'static>>> {
1811 if self.name != "workflow" {
1812 return None;
1813 }
1814 let output = self.output.as_ref()?;
1815 let value: serde_json::Value = serde_json::from_str(output).ok()?;
1816 let is_status_list =
1817 value.get("action").and_then(serde_json::Value::as_str) == Some("status");
1818 if value.get("run_id").is_none() && !is_status_list {
1819 return None;
1820 }
1821 let family = crate::tui::widgets::tool_card::tool_family_for_name("workflow");
1822 let mut lines = Vec::new();
1823
1824 if is_status_list {
1825 let runs = value.get("runs").and_then(serde_json::Value::as_array);
1826 let count = value
1827 .get("count")
1828 .and_then(serde_json::Value::as_u64)
1829 .unwrap_or_else(|| runs.map(|r| r.len() as u64).unwrap_or(0));
1830 let header = format!("{count} run(s)");
1831 lines.push(render_tool_header_with_family_and_summary(
1832 family,
1833 Some(header.as_str()),
1834 tool_status_label(self.status),
1835 self.status,
1836 None,
1837 low_motion,
1838 ));
1839 if let Some(runs) = runs {
1840 for run in runs {
1841 let run_id = run
1842 .get("run_id")
1843 .and_then(serde_json::Value::as_str)
1844 .unwrap_or("?");
1845 let status = run
1846 .get("status")
1847 .and_then(serde_json::Value::as_str)
1848 .unwrap_or("?");
1849 let children = run
1850 .get("child_count")
1851 .and_then(serde_json::Value::as_u64)
1852 .or_else(|| {
1853 run.get("child_ids")
1854 .and_then(serde_json::Value::as_array)
1855 .map(|a| a.len() as u64)
1856 })
1857 .unwrap_or(0);
1858 lines.extend(render_card_detail_line(
1859 None,
1860 &format!("{run_id} · {status} · {children} child(ren)"),
1861 tool_value_style(),
1862 width,
1863 ));
1864 }
1865 }
1866 return Some(wrap_card_rail(lines));
1867 }
1868
1869 use crate::tui::widgets::workflow_panel::{WorkflowHistoryExtras, WorkflowPanel};
1870 let panel = WorkflowPanel::from_run_json(&value)?;
1871 // Prefer the panel's lifecycle-aware status label when the tool cell
1872 // is still marked running but the snapshot already terminal (or vice
1873 // versa during live streaming).
1874 let header_status = match panel.lifecycle {
1875 crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Failed
1876 | crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Cancelled => {
1877 ToolStatus::Failed
1878 }
1879 crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Succeeded => {
1880 ToolStatus::Success
1881 }
1882 crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Pending
1883 | crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Running => {
1884 if self.status == ToolStatus::Failed {
1885 ToolStatus::Failed
1886 } else if self.status == ToolStatus::Success {
1887 ToolStatus::Success
1888 } else {
1889 ToolStatus::Running
1890 }
1891 }
1892 };
1893 let summary = panel.history_header_summary(usize::from(width).saturating_sub(18));
1894 lines.push(render_tool_header_with_family_and_summary(
1895 family,
1896 Some(summary.as_str()),
1897 tool_status_label(header_status),
1898 header_status,
1899 None,
1900 low_motion,
1901 ));
1902 let expanded = matches!(mode, RenderMode::Transcript);
1903 if expanded {
1904 let extras = WorkflowHistoryExtras {
1905 result_summary: panel.result_summary.clone(),
1906 source_path: panel.source_path.clone().or_else(|| {
1907 value
1908 .get("source_path")
1909 .and_then(serde_json::Value::as_str)
1910 .map(std::path::PathBuf::from)
1911 }),
1912 spillover_path: self.spillover_path.clone(),
1913 verification_summary: value
1914 .get("verification")
1915 .and_then(|v| v.get("summary"))
1916 .and_then(serde_json::Value::as_str)
1917 .map(str::to_string),
1918 };
1919 for detail in panel.history_expanded_lines(width, &extras) {
1920 // history_expanded_lines omit the leading indent; re-use the
1921 // card detail path so rails and spacing stay consistent.
1922 let text: String = detail.spans.iter().map(|s| s.content.as_ref()).collect();
1923 lines.extend(render_card_detail_line(
1924 None,
1925 &text,
1926 tool_value_style(),
1927 width,
1928 ));
1929 }
1930 }
1931 Some(wrap_card_rail(lines))
1932 }
1933 }
1934
1935 /// Render the inline annotation for a tool cell whose full output was
1936 /// retained internally and replaced by a bounded preview. The annotation
1937 /// stays calm and path-free: it only says the output was shortened and that
1938 /// the details shortcut opens the full retained output.
1939 fn render_spillover_annotation(width: u16) -> Line<'static> {
1940 // Matches the model-facing preview footer (truncate.rs) and the existing
1941 // "Alt+V opens …" hint style (#3256): one quiet line, no handles or paths.
1942 let affordance = format!(
1943 "Output shortened — {}",
1944 crate::tui::key_shortcuts::tool_details_shortcut_action_hint("output")
1945 );
1946 Line::from(Span::styled(
1947 truncate_text(&affordance, usize::from(width).max(8)),
1948 Style::default().fg(palette::TEXT_MUTED).italic(),
1949 ))
1950 }
1951
1952 /// Detect a truncated-output preview: the current model-facing footer (which
1953 /// names the artifact path and recovery instruction), the previous plain
1954 /// footer, or the legacy receipt header still present in older saved
1955 /// sessions. Live cards collapse to the expand affordance for all of them.
1956 fn is_truncated_output_preview(content: &str) -> bool {
1957 content.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT)
1958 || content.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT)
1959 || content.trim_start().starts_with("[Exact evidence retained")
1960 }
1961
1962 fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec<Line<'static>> {
1963 let mut lines = Vec::new();
1964 let cap = match mode {
1965 RenderMode::Live => TOOL_COMMAND_LINE_LIMIT,
1966 RenderMode::Transcript => usize::MAX,
1967 };
1968 for (count, chunk) in wrap_text(command, width.saturating_sub(4).max(1) as usize)
1969 .into_iter()
1970 .enumerate()
1971 {
1972 if count >= cap {
1973 lines.push(details_affordance_line(
1974 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("command"),
1975 Style::default().fg(palette::TEXT_MUTED),
1976 ));
1977 break;
1978 }
1979 lines.extend(render_card_detail_line(
1980 if count == 0 { Some("command") } else { None },
1981 chunk.as_str(),
1982 tool_value_style(),
1983 width,
1984 ));
1985 }
1986 lines
1987 }
1988
1989 fn command_header_summary(command: &str) -> String {
1990 command
1991 .lines()
1992 .next()
1993 .unwrap_or(command)
1994 .trim_start_matches("$ ")
1995 .trim()
1996 .to_string()
1997 }
1998
1999 fn exploring_header_summary(entries: &[ExploringEntry]) -> Option<String> {
2000 match entries {
2001 [] => None,
2002 [entry] => Some(entry.label.clone()),
2003 entries => Some(format!("{} items", entries.len())),
2004 }
2005 }
2006
2007 /// Choose the verb family for an exploring card's header. A card whose entries
2008 /// are all searches reads with the `find` verb so the completed action agrees
2009 /// with its `Searching for …` labels (#4145); every other exploration mix keeps
2010 /// the neutral `read` verb the Workspace card uses. The search signal is the
2011 /// English label prefix produced by `exploring_label` in `tool_routing`.
2012 fn exploring_card_family(entries: &[ExploringEntry]) -> crate::tui::widgets::tool_card::ToolFamily {
2013 use crate::tui::widgets::tool_card::ToolFamily;
2014 let all_search = !entries.is_empty()
2015 && entries
2016 .iter()
2017 .all(|entry| entry.label.starts_with("Searching"));
2018 if all_search {
2019 ToolFamily::Find
2020 } else {
2021 ToolFamily::Read
2022 }
2023 }
2024
2025 fn render_compact_kv(label: &str, value: &str, style: Style, width: u16) -> Vec<Line<'static>> {
2026 render_card_detail_line(Some(label.trim_end_matches(':')), value, style, width)
2027 }
2028
2029 /// Wrap rendered tool-card lines with card-rail glyphs (╭ │ ╰).
2030 /// First non-empty line gets `╭`, middle lines get `│`, last line gets `╰`.
2031 /// Single-line cards get a single `─` prefix.
2032 fn wrap_card_rail(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
2033 let n = lines.len();
2034 if n == 0 {
2035 return lines;
2036 }
2037 if n == 1 {
2038 lines[0].spans.insert(0, Span::raw("─ "));
2039 return lines;
2040 }
2041 for (i, line) in lines.iter_mut().enumerate() {
2042 let rail = if i == 0 {
2043 "\u{256D} " // ╭
2044 } else if i == n - 1 {
2045 "\u{2570} " // ╰
2046 } else {
2047 "\u{2502} " // │
2048 };
2049 line.spans.insert(0, Span::raw(rail));
2050 }
2051 lines
2052 }
2053
2054 /// The legacy tool renderers accept only a low-motion boolean, which gives
2055 /// both Reduced and Still a mix of legacy static frames. Preserve that stable
2056 /// rendering path, then apply the central mode's exact fallback at the typed
2057 /// header span (never by rewriting user/tool output text).
2058 fn apply_static_tool_markers(lines: &mut [Line<'static>], marker: &'static str) {
2059 for line in lines {
2060 let mut index = 0;
2061 if line
2062 .spans
2063 .first()
2064 .is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ "))
2065 {
2066 index = 1;
2067 }
2068 let Some(status) = line.spans.get(index).map(|span| span.content.as_ref()) else {
2069 continue;
2070 };
2071 let Some(family) = line.spans.get(index + 1).map(|span| span.content.as_ref()) else {
2072 continue;
2073 };
2074 if !status.ends_with(' ')
2075 || !is_tool_status_glyph(status.trim_end())
2076 || !family.ends_with(' ')
2077 || !is_tool_family_glyph(family.trim_end())
2078 {
2079 continue;
2080 }
2081 let mut chars = status.trim_end().chars();
2082 if matches!(chars.next(), Some('\u{2800}'..='\u{28FF}')) && chars.next().is_none() {
2083 line.spans[index].content = format!("{marker} ").into();
2084 }
2085 }
2086 }
2087
2088 /// Return the width of tool-cell chrome that remains after the transcript
2089 /// cache removes the cell-local card rail. Tool headers have two additional
2090 /// visual tokens (`✓`/spinner and the family glyph); detail rows have the
2091 /// thin transcript rail. Keeping this width with the rendered line avoids
2092 /// making selection copy infer chrome from glyph ranges, which can consume
2093 /// real code or CJK text that happens to begin with a box-drawing character.
2094 fn tool_copy_prefix_width(line: &Line<'static>) -> usize {
2095 let spans = line.spans.as_slice();
2096 let mut index = 0;
2097
2098 // The cache removes these exact local card rails before flattening.
2099 if spans
2100 .first()
2101 .is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ "))
2102 {
2103 index = 1;
2104 }
2105
2106 // Detail rows and pager affordances use the transcript rail as their
2107 // first span. The live transcript's general rail accounting removes it;
2108 // do not report it again as cell-local copy chrome.
2109 if spans
2110 .get(index)
2111 .is_some_and(|span| span.content.as_ref() == TRANSCRIPT_RAIL)
2112 {
2113 return 0;
2114 }
2115
2116 // A tool header starts with `<status> <family> `. Only consume this
2117 // pair when both tokens are present, so output beginning with `✓` or a
2118 // braille character remains copyable content.
2119 let Some(status) = spans.get(index).map(|span| span.content.as_ref()) else {
2120 return 0;
2121 };
2122 let Some(family) = spans.get(index + 1).map(|span| span.content.as_ref()) else {
2123 return 0;
2124 };
2125 if !status.ends_with(' ')
2126 || !is_tool_status_glyph(status.trim_end())
2127 || !family.ends_with(' ')
2128 || !is_tool_family_glyph(family.trim_end())
2129 {
2130 return 0;
2131 }
2132
2133 UnicodeWidthStr::width(status) + UnicodeWidthStr::width(family)
2134 }
2135
2136 fn is_tool_status_glyph(text: &str) -> bool {
2137 let mut chars = text.chars();
2138 let Some(ch) = chars.next() else {
2139 return false;
2140 };
2141 chars.next().is_none()
2142 && matches!(
2143 ch,
2144 '\u{2713}' // ✓
2145 | '\u{2715}' // ✕
2146 | '\u{00B7}' // ·
2147 | '\u{203A}' // › static still-mode marker
2148 | '\u{2800}'..='\u{28FF}' // braille spinner frames
2149 )
2150 }
2151
2152 fn is_tool_family_glyph(text: &str) -> bool {
2153 use crate::tui::widgets::tool_card::{ToolFamily, family_glyph};
2154
2155 [
2156 ToolFamily::Read,
2157 ToolFamily::Patch,
2158 ToolFamily::Run,
2159 ToolFamily::Find,
2160 ToolFamily::Delegate,
2161 ToolFamily::Fanout,
2162 ToolFamily::Rlm,
2163 ToolFamily::Verify,
2164 ToolFamily::Think,
2165 ToolFamily::Generic,
2166 ]
2167 .into_iter()
2168 .any(|family| family_glyph(family) == text)
2169 }
2170
2171 fn review_severity_color(severity: &str) -> Color {
2172 match severity {
2173 "error" => palette::STATUS_ERROR,
2174 "warning" => palette::STATUS_WARNING,
2175 _ => palette::STATUS_INFO,
2176 }
2177 }
2178
2179 fn format_review_location(path: Option<&String>, line: Option<u32>) -> String {
2180 let path = path.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
2181 match (path, line) {
2182 (Some(path), Some(line)) => format!("{path}:{line}"),
2183 (Some(path), None) => path,
2184 (None, Some(line)) => format!("line {line}"),
2185 (None, None) => String::new(),
2186 }
2187 }
2188
2189 /// Detect whether a system message is a cycle-boundary announcement
2190 /// (e.g. `─── cycle 0 → 1 (briefing: 2500 tokens) ───`).
2191 fn is_cycle_boundary(content: &str) -> bool {
2192 content.contains("cycle")
2193 }
2194
2195 /// Render a cycle-boundary system message with distinct visual styling (#395):
2196 /// full-width line with primary accent text and bold weight, plus a thin
2197 /// horizontal rule above for visual separation.
2198 fn render_cycle_boundary(content: &str, width: u16) -> Vec<Line<'static>> {
2199 let style = Style::default()
2200 .fg(palette::WHALE_ACTION)
2201 .add_modifier(Modifier::BOLD);
2202 let rule_style = Style::default().fg(palette::TEXT_DIM);
2203 let content_width = usize::from(width.saturating_sub(2).max(1));
2204 let mut lines = Vec::new();
2205 // Thin horizontal rule above for visual separation
2206 if width >= 4 {
2207 let rule = "\u{2500}".repeat(content_width);
2208 lines.push(Line::from(Span::styled(format!(" {rule}"), rule_style)));
2209 }
2210 // Cycle boundary text — just the content, full-width
2211 let rendered =
2212 crate::tui::markdown_render::render_markdown(content, content_width as u16, style);
2213 for line in rendered {
2214 let mut spans = vec![Span::raw(" ")];
2215 spans.extend(line.spans);
2216 lines.push(Line::from(spans));
2217 }
2218 if lines.len() == 1 && width >= 4 {
2219 // Only the rule was added (unlikely), but add at least a spacer
2220 lines.push(Line::from(""));
2221 }
2222 lines
2223 }
2224
2225 fn status_symbol(
2226 started_at: Option<Instant>,
2227 status: ToolStatus,
2228 low_motion: bool,
2229 family: crate::tui::widgets::tool_card::ToolFamily,
2230 ) -> String {
2231 match status {
2232 ToolStatus::Running if family == crate::tui::widgets::tool_card::ToolFamily::Verify => {
2233 crate::tui::spinner::verification_tick_frame(started_at, low_motion).to_string()
2234 }
2235 ToolStatus::Running => {
2236 crate::tui::spinner::braille_spinner_frame(started_at, low_motion).to_string()
2237 }
2238 ToolStatus::Success | ToolStatus::Hydrated => TOOL_DONE_SYMBOL.to_string(),
2239 ToolStatus::Failed => TOOL_FAILED_SYMBOL.to_string(),
2240 }
2241 }
2242
2243 fn details_affordance_line(text: &str, style: Style) -> Line<'static> {
2244 Line::from(vec![
2245 Span::styled(
2246 TRANSCRIPT_RAIL.to_string(),
2247 Style::default().fg(palette::TEXT_DIM),
2248 ),
2249 Span::styled(text.to_string(), style),
2250 ])
2251 }
2252
2253 fn truncate_text(text: &str, max_len: usize) -> String {
2254 if text.chars().count() <= max_len {
2255 return text.to_string();
2256 }
2257 let mut out = String::new();
2258 for ch in text.chars().take(max_len.saturating_sub(3)) {
2259 out.push(ch);
2260 }
2261 out.push_str("...");
2262 out
2263 }
2264
2265 /// Label glyph for an error cell. `Critical`/`Error` get the loudest marker;
2266 /// `Warning` is softer; `Info` is neutral. Kept as ASCII so it survives any
2267 /// terminal font fallback.
2268 fn error_label_text(severity: crate::error_taxonomy::ErrorSeverity) -> &'static str {
2269 match severity {
2270 crate::error_taxonomy::ErrorSeverity::Critical
2271 | crate::error_taxonomy::ErrorSeverity::Error => "Error",
2272 crate::error_taxonomy::ErrorSeverity::Warning => "Warn",
2273 crate::error_taxonomy::ErrorSeverity::Info => "Info",
2274 }
2275 }
2276
2277 /// Label color for an error cell — drives the leading rail glyph.
2278 fn error_label_style(severity: crate::error_taxonomy::ErrorSeverity) -> Style {
2279 let color = match severity {
2280 crate::error_taxonomy::ErrorSeverity::Critical
2281 | crate::error_taxonomy::ErrorSeverity::Error => palette::STATUS_ERROR,
2282 crate::error_taxonomy::ErrorSeverity::Warning => palette::STATUS_WARNING,
2283 crate::error_taxonomy::ErrorSeverity::Info => palette::TEXT_DIM,
2284 };
2285 Style::default().fg(color).add_modifier(Modifier::BOLD)
2286 }
2287
2288 /// Body color for an error cell — softer than the label so the rail draws
2289 /// the eye but the prose stays readable.
2290 fn error_body_style(severity: crate::error_taxonomy::ErrorSeverity) -> Style {
2291 let color = match severity {
2292 crate::error_taxonomy::ErrorSeverity::Critical
2293 | crate::error_taxonomy::ErrorSeverity::Error => palette::STATUS_ERROR,
2294 crate::error_taxonomy::ErrorSeverity::Warning => palette::STATUS_WARNING,
2295 crate::error_taxonomy::ErrorSeverity::Info => palette::TEXT_MUTED,
2296 };
2297 Style::default().fg(color)
2298 }
2299
2300 fn render_tool_header(
2301 title: &str,
2302 state: &str,
2303 status: ToolStatus,
2304 started_at: Option<Instant>,
2305 low_motion: bool,
2306 ) -> Line<'static> {
2307 let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
2308 render_tool_header_with_family(family, state, status, started_at, low_motion)
2309 }
2310
2311 fn render_tool_header_with_summary(
2312 title: &str,
2313 summary: Option<&str>,
2314 state: &str,
2315 status: ToolStatus,
2316 started_at: Option<Instant>,
2317 low_motion: bool,
2318 ) -> Line<'static> {
2319 let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
2320 render_tool_header_with_family_and_summary(
2321 family, summary, state, status, started_at, low_motion,
2322 )
2323 }
2324
2325 /// Render a tool-card header with an explicit verb family. Lets callers
2326 /// (e.g. `GenericToolCell`) bypass the legacy title→family mapping when
2327 /// they already know the actual tool name.
2328 fn render_tool_header_with_family(
2329 family: crate::tui::widgets::tool_card::ToolFamily,
2330 state: &str,
2331 status: ToolStatus,
2332 started_at: Option<Instant>,
2333 low_motion: bool,
2334 ) -> Line<'static> {
2335 render_tool_header_with_family_and_summary(family, None, state, status, started_at, low_motion)
2336 }
2337
2338 fn render_tool_header_with_family_and_summary(
2339 family: crate::tui::widgets::tool_card::ToolFamily,
2340 summary: Option<&str>,
2341 state: &str,
2342 status: ToolStatus,
2343 started_at: Option<Instant>,
2344 low_motion: bool,
2345 ) -> Line<'static> {
2346 // For long-running tools, append elapsed seconds so the user can see the
2347 // call isn't stuck. Threshold matches the eye's "did this hang?" reflex
2348 // — under 3s we stay quiet so quick reads/greps don't visually churn.
2349 let state_owned: String = if state == "running"
2350 && status == ToolStatus::Running
2351 && let Some(started) = started_at
2352 {
2353 running_status_label_with_elapsed(started.elapsed().as_secs())
2354 } else {
2355 state.to_string()
2356 };
2357
2358 let glyph = crate::tui::widgets::tool_card::family_glyph(family);
2359 let verb = crate::tui::widgets::tool_card::family_label(family);
2360
2361 let mut spans = vec![
2362 Span::styled(
2363 format!("{} ", status_symbol(started_at, status, low_motion, family)),
2364 Style::default().fg(tool_state_color(status)),
2365 ),
2366 Span::styled(
2367 format!("{glyph} "),
2368 Style::default().fg(tool_state_color(status)),
2369 ),
2370 Span::styled(verb.to_string(), tool_title_style()),
2371 Span::styled(" ", Style::default()),
2372 Span::styled(state_owned, tool_status_style(status)),
2373 ];
2374
2375 // #4148: don't let the summary echo the verb it sits next to — an
2376 // identity/summary that resolves to the family word itself would render a
2377 // duplicate like "delegate · delegate". When the summary collapses to the
2378 // verb, the verb already carries the signal, so drop the redundant tail.
2379 if let Some(summary) = summary
2380 .and_then(normalize_header_summary)
2381 .filter(|summary| !summary.eq_ignore_ascii_case(verb))
2382 {
2383 spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
2384 spans.push(Span::styled(
2385 truncate_text(&summary, TOOL_HEADER_SUMMARY_LIMIT),
2386 Style::default().fg(palette::TEXT_MUTED),
2387 ));
2388 }
2389
2390 Line::from(spans)
2391 }
2392
2393 fn normalize_header_summary(summary: &str) -> Option<String> {
2394 let normalized = summary
2395 .split_whitespace()
2396 .collect::<Vec<_>>()
2397 .join(" ")
2398 .trim()
2399 .to_string();
2400 if normalized.is_empty() {
2401 None
2402 } else {
2403 Some(normalized)
2404 }
2405 }
2406
2407 /// Build the "running" label with an elapsed-seconds badge for long-running
2408 /// tools. Below 3s the badge is suppressed to avoid visual churn for tools
2409 /// that resolve in milliseconds; at 3s and beyond the badge appears and ticks
2410 /// every second the tool stays in flight.
2411 pub(crate) fn running_status_label_with_elapsed(elapsed_secs: u64) -> String {
2412 if elapsed_secs < 3 {
2413 "running".to_string()
2414 } else {
2415 format!("running ({elapsed_secs}s)")
2416 }
2417 }
2418
2419 pub(crate) fn stale_shell_status_label(elapsed_since_output_ms: u64) -> String {
2420 format!(
2421 "running · stale · no output {}",
2422 crate::elapsed::format_elapsed_ms(elapsed_since_output_ms)
2423 )
2424 }
2425
2426 fn render_card_detail_line(
2427 label: Option<&str>,
2428 value: &str,
2429 value_style: Style,
2430 width: u16,
2431 ) -> Vec<Line<'static>> {
2432 let label_text = label.map(|text| format!("{text}:"));
2433 let prefix_width = UnicodeWidthStr::width(TRANSCRIPT_RAIL)
2434 + label_text.as_deref().map_or(0, UnicodeWidthStr::width)
2435 + usize::from(label.is_some());
2436 let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
2437
2438 let mut lines = Vec::new();
2439 for (idx, part) in wrap_text(value, content_width).into_iter().enumerate() {
2440 let mut spans = vec![Span::styled(
2441 TRANSCRIPT_RAIL.to_string(),
2442 Style::default().fg(palette::TEXT_DIM),
2443 )];
2444 if idx == 0 {
2445 if let Some(label_text) = label_text.as_deref() {
2446 spans.push(Span::styled(
2447 label_text.to_string(),
2448 tool_detail_label_style(),
2449 ));
2450 spans.push(Span::raw(" "));
2451 }
2452 } else if let Some(label_text) = label_text.as_deref() {
2453 spans.push(Span::raw(
2454 " ".repeat(UnicodeWidthStr::width(label_text) + 1),
2455 ));
2456 }
2457 spans.push(Span::styled(part, value_style));
2458 lines.push(Line::from(spans));
2459 }
2460 lines
2461 }
2462
2463 fn render_card_detail_line_single(
2464 label: Option<&str>,
2465 value: &str,
2466 value_style: Style,
2467 ) -> Line<'static> {
2468 let label_text = label.map(|text| format!("{text}:"));
2469 let mut spans = vec![Span::styled(
2470 TRANSCRIPT_RAIL.to_string(),
2471 Style::default().fg(palette::TEXT_DIM),
2472 )];
2473 if let Some(label_text) = label_text {
2474 spans.push(Span::styled(label_text, tool_detail_label_style()));
2475 spans.push(Span::raw(" "));
2476 }
2477 spans.push(Span::styled(value.to_string(), value_style));
2478 Line::from(spans)
2479 }
2480
2481 fn tool_title_style() -> Style {
2482 active_theme().tool_title_style()
2483 }
2484
2485 fn tool_status_style(status: ToolStatus) -> Style {
2486 active_theme().tool_status_style(status)
2487 }
2488
2489 fn tool_detail_label_style() -> Style {
2490 active_theme().tool_label_style()
2491 }
2492
2493 fn tool_state_color(status: ToolStatus) -> Color {
2494 active_theme().tool_status_color(status)
2495 }
2496
2497 fn tool_status_label(status: ToolStatus) -> &'static str {
2498 match status {
2499 ToolStatus::Running => "running",
2500 ToolStatus::Success => "done",
2501 ToolStatus::Hydrated => "tool loaded - retry required",
2502 ToolStatus::Failed => "issue",
2503 }
2504 }
2505
2506 fn tool_value_style() -> Style {
2507 active_theme().tool_value_style()
2508 }
2509
2510 /// Parse `path:line` patterns from `text` and open the file at the given line
2511 /// in the user's preferred editor (`$VISUAL` / `$EDITOR` / `vim`).
2512 ///
2513 /// Scans lines of `text` for patterns like `src/main.rs:42`. Resolves the path
2514 /// relative to `workspace` (if not absolute) and opens the editor. Returns
2515 /// `true` if at least one file was opened successfully.
2516 pub fn try_open_file_at_line(text: &str, workspace: &Path) -> bool {
2517 let editor = std::env::var("VISUAL")
2518 .ok()
2519 .filter(|s| !s.trim().is_empty())
2520 .or_else(|| {
2521 std::env::var("EDITOR")
2522 .ok()
2523 .filter(|s| !s.trim().is_empty())
2524 })
2525 .unwrap_or_else(|| "vim".to_string());
2526
2527 let mut any_opened = false;
2528 for line in text.lines() {
2529 let trimmed = line.trim();
2530 if let Some((before, after)) = trimmed.rsplit_once(':')
2531 && after.chars().all(|c| c.is_ascii_digit())
2532 {
2533 let line_num: u32 = after.parse().unwrap_or(1);
2534 let path_str = before.trim();
2535 if !path_str.is_empty() && looks_like_file_path(path_str) {
2536 let abs_path = if Path::new(path_str).is_absolute() {
2537 PathBuf::from(path_str)
2538 } else {
2539 workspace.join(path_str)
2540 };
2541 if abs_path.is_file()
2542 && Command::new(&editor)
2543 .arg(format!("+{line_num}"))
2544 .arg(&abs_path)
2545 .spawn()
2546 .is_ok()
2547 {
2548 any_opened = true;
2549 }
2550 }
2551 }
2552 }
2553 any_opened
2554 }
2555
2556 /// Heuristic check whether a string looks like a file path (contains a
2557 /// directory separator or a known source file extension).
2558 fn looks_like_file_path(s: &str) -> bool {
2559 if s.contains('/') || s.contains('\\') {
2560 return true;
2561 }
2562 // Check for a known file extension
2563 if let Some((_, ext)) = s.rsplit_once('.') {
2564 let ext = ext.trim();
2565 matches!(
2566 ext,
2567 "rs" | "toml"
2568 | "md"
2569 | "sh"
2570 | "py"
2571 | "js"
2572 | "ts"
2573 | "json"
2574 | "yaml"
2575 | "yml"
2576 | "css"
2577 | "html"
2578 | "go"
2579 | "c"
2580 | "h"
2581 | "cpp"
2582 | "hpp"
2583 | "java"
2584 | "kt"
2585 | "swift"
2586 | "rb"
2587 | "php"
2588 | "lua"
2589 | "zig"
2590 | "mod"
2591 | "sum"
2592 | "lock"
2593 | "txt"
2594 | "ini"
2595 | "cfg"
2596 | "conf"
2597 | "env"
2598 | "gitignore"
2599 | "dockerfile"
2600 | "sql"
2601 | "r"
2602 | "ex"
2603 | "exs"
2604 | "vue"
2605 | "svelte"
2606 | "tsx"
2607 | "jsx"
2608 | "scss"
2609 | "sass"
2610 | "less"
2611 | "gradle"
2612 | "properties"
2613 | "xml"
2614 | "proto"
2615 | "nix"
2616 )
2617 } else {
2618 false
2619 }
2620 }
2621
2622 /// Aggregated file activity for compact Work panel display (#4636).
2623 #[derive(Debug, Clone, Default, PartialEq, Eq)]
2624 pub struct FileActivitySummary {
2625 pub files_read: u32,
2626 pub dirs_listed: u32,
2627 pub patterns_searched: u32,
2628 pub files_written: u32,
2629 }
2630
2631 impl FileActivitySummary {
2632 pub fn is_empty(&self) -> bool {
2633 self.files_read == 0
2634 && self.dirs_listed == 0
2635 && self.patterns_searched == 0
2636 && self.files_written == 0
2637 }
2638
2639 pub fn compact_display(&self) -> Vec<String> {
2640 let mut parts = Vec::new();
2641 if self.files_read > 0 {
2642 parts.push(format!("Read {} files", self.files_read));
2643 }
2644 if self.dirs_listed > 0 {
2645 parts.push(format!("Listed {} directories", self.dirs_listed));
2646 }
2647 if self.patterns_searched > 0 {
2648 parts.push(format!("Searched {} patterns", self.patterns_searched));
2649 }
2650 if self.files_written > 0 {
2651 parts.push(format!("Wrote {} files", self.files_written));
2652 }
2653 parts
2654 }
2655
2656 pub fn from_tool_name(name: &str) -> Option<FileActivityKind> {
2657 match name {
2658 "read_file" | "Read" | "read" => Some(FileActivityKind::Read),
2659 "list_dir" | "list_directory" | "Glob" | "glob" => Some(FileActivityKind::List),
2660 "search" | "grep" | "Grep" | "grep_files" | "file_search" | "codebase_search" => {
2661 Some(FileActivityKind::Search)
2662 }
2663 "write_file" | "Write" | "apply_patch" | "Edit" | "edit_file" | "fim_edit" => {
2664 Some(FileActivityKind::Write)
2665 }
2666 _ => None,
2667 }
2668 }
2669 }
2670
2671 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2672 pub enum FileActivityKind {
2673 Read,
2674 List,
2675 Search,
2676 Write,
2677 }
2678
2679 impl FileActivitySummary {
2680 pub fn record(&mut self, kind: FileActivityKind) {
2681 match kind {
2682 FileActivityKind::Read => self.files_read += 1,
2683 FileActivityKind::List => self.dirs_listed += 1,
2684 FileActivityKind::Search => self.patterns_searched += 1,
2685 FileActivityKind::Write => self.files_written += 1,
2686 }
2687 }
2688 }
2689
2690 /// Illuminate the newest graphemes of an actively streaming assistant line.
2691 fn apply_hot_tail_to_last_line(lines: &mut [Line<'static>], low_motion: bool) {
2692 if let Some(last) = lines.last_mut() {
2693 apply_hot_tail_to_line(last, low_motion);
2694 }
2695 }
2696
2697 pub(crate) fn apply_hot_tail_to_line(line: &mut Line<'static>, low_motion: bool) {
2698 if line.spans.is_empty() {
2699 return;
2700 }
2701 // Reconstruct plain text from spans, split hot tail, re-style.
2702 let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
2703 if plain.trim().is_empty() {
2704 return;
2705 }
2706 let (_settled, hot) = crate::tui::hot_tail::split_hot_tail(
2707 &plain,
2708 true,
2709 crate::tui::hot_tail::HOT_TAIL_GRAPHEMES,
2710 );
2711 if hot.is_empty() {
2712 return;
2713 }
2714 let hot_start = plain.len().saturating_sub(hot.len());
2715 let elapsed = std::time::SystemTime::now()
2716 .duration_since(std::time::UNIX_EPOCH)
2717 .map(|d| d.as_millis())
2718 .unwrap_or(0);
2719 let base_fg = palette::TEXT_PRIMARY;
2720 let hot_style = crate::tui::hot_tail::hot_tail_style(base_fg, elapsed, low_motion);
2721
2722 // Walk spans and re-style the trailing hot graphemes.
2723 let mut cursor = 0usize;
2724 let mut new_spans = Vec::with_capacity(line.spans.len() + 2);
2725 for span in line.spans.drain(..) {
2726 let content = span.content.to_string();
2727 let len = content.len();
2728 let span_end = cursor + len;
2729 if span_end <= hot_start {
2730 new_spans.push(span);
2731 } else if cursor >= hot_start {
2732 new_spans.push(Span::styled(content, hot_style));
2733 } else {
2734 // Split this span across the boundary.
2735 let local = hot_start - cursor;
2736 let (left, right) = content.split_at(local.min(content.len()));
2737 if !left.is_empty() {
2738 new_spans.push(Span::styled(left.to_string(), span.style));
2739 }
2740 if !right.is_empty() {
2741 new_spans.push(Span::styled(right.to_string(), hot_style));
2742 }
2743 }
2744 cursor = span_end;
2745 }
2746 line.spans = new_spans;
2747 }
2748
2749 #[cfg(test)]
2750 mod tests;
2751
2751 lines RUST