返回 CodeWhale
history.rs
根目录 / crates / tui / src / tui / history.rs
1 //! TUI rendering helpers for chat history and tool output.
2
3 use std::borrow::Cow;
4 use std::path::{Path, PathBuf};
5 use std::time::Instant;
6
7 use ratatui::style::{Color, Modifier, Style};
8 use ratatui::text::{Line, Span};
9 use unicode_width::UnicodeWidthStr;
10
11 use crate::tools::plan::PlanSnapshot;
12 use crate::tools::review::ReviewOutput;
13 use crate::tui::app::TranscriptSpacing;
14 use crate::tui::diff_render;
15 use crate::tui::motion::MotionMode;
16 use crate::tui::ui_text::CopyLineSeparator;
17 use codewhale_localization::Locale;
18 use codewhale_models::{ContentBlock, Message};
19 use codewhale_palette as palette;
20
21 mod agent_activity;
22 mod archived_context;
23 mod automation;
24 mod checklist;
25 mod constants;
26 mod file_mutation;
27 mod latex_render;
28 mod message;
29 mod plan;
30 mod thinking;
31 mod tool_output;
32 mod tool_run;
33
34 use archived_context::{parse_archived_context, render_archived_context};
35 pub use automation::{AutomationCell, AutomationCellKind};
36 use checklist::{
37 is_checklist_tool_name, parse_checklist_snapshot, parse_update_prefix, render_checklist_card,
38 render_checklist_change_card,
39 };
40
41 #[cfg(test)]
42 use checklist::{ChecklistChange, ChecklistItemSnapshot, ChecklistSnapshot};
43 use constants::{
44 ASSISTANT_GLYPH, FOREGROUND_SHELL_WAIT_HINT, TOOL_COMMAND_LINE_LIMIT, TOOL_DONE_SYMBOL,
45 TOOL_FAILED_SYMBOL, TOOL_FAILURE_PREVIEW_LINES, TOOL_HEADER_SUMMARY_LIMIT,
46 TOOL_OUTPUT_LINE_LIMIT, TOOL_SUCCESS_OUTPUT_PREVIEW_LINES, TOOL_SUMMARY_CARD_LINES,
47 TRANSCRIPT_RAIL, USER_GLYPH,
48 };
49 #[cfg(test)]
50 use constants::{TOOL_RUNNING_SYMBOLS, TOOL_STATUS_SYMBOL_MS};
51 use message::{
52 RenderedTranscriptLine, assistant_label_style_for, hard_break_copy_lines, message_body_style,
53 render_message, render_message_with_copy_metadata_for_palette, render_plain_message,
54 render_user_message, system_body_style, system_label_style, update_streaming_message_render,
55 user_body_style, user_label_style,
56 };
57 #[cfg(test)]
58 pub(super) use thinking::render_thinking_with_analysis;
59 use thinking::{render_hidden_thinking_activity, render_thinking};
60 use tool_output::{render_exec_output_mode, render_tool_output_mode, wrap_plain_line, wrap_text};
61
62 #[cfg(test)]
63 use agent_activity::extract_agent_id;
64 pub use file_mutation::FileMutationReceipt;
65 pub use plan::PlanUpdateCell;
66 #[cfg(test)]
67 use thinking::extract_reasoning_summary;
68 #[cfg(test)]
69 use tool_run::ToolRunActivitySummary;
70 #[cfg(test)]
71 pub use tool_run::detect_tool_runs;
72 pub use tool_run::{ToolRun, detect_tool_runs_from_slices, tool_run_summary};
73
74 #[cfg(test)]
75 use thinking::{REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL};
76 pub(crate) use tool_output::output_looks_like_diff;
77 pub use tool_output::{
78 OutputRow, summarize_mcp_output, summarize_tool_args, summarize_tool_output,
79 };
80
81 /// Render mode controlling whether tool/thinking cells render their compact
82 /// "live" form (with caps and collapsed reasoning) or their full transcript
83 /// form (uncapped, suitable for the pager / clipboard / message export).
84 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
85 pub enum RenderMode {
86 /// Live in-stream view: thinking is collapsed to a summary, tool output is
87 /// truncated with a visible details-pager affordance.
88 Live,
89 /// Full transcript view: every line of reasoning and tool output is
90 /// emitted, no caps, no affordance.
91 Transcript,
92 }
93
94 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
95 pub(crate) enum ReasoningAction {
96 Expand,
97 Collapse,
98 }
99
100 /// A user's explicit decision about one thinking cell.
101 ///
102 /// The absence of a `ThinkingFold` — `None` at a call site, no entry in
103 /// `App::thinking_folds` — means the user has not touched that cell, so the
104 /// display preferences (`verbose` or `thinking_default_expanded`) decide its
105 /// default. An explicit intent is *absolute*: it says expanded or collapsed
106 /// outright, never "the opposite of whatever the preference currently says".
107 /// That is what lets a choice outlive a later preference change (#5847).
108 ///
109 /// Known limitation: the intent is per session and per virtual cell index.
110 /// It is not persisted across restarts, and destructive transcript edits drop
111 /// it along with the other per-index state (`prune_transcript_index_state`).
112 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
113 pub enum ThinkingFold {
114 /// The user expanded this cell; show the whole body whatever the
115 /// preference says.
116 Expanded,
117 /// The user collapsed this cell; show the preview whatever the
118 /// preference says.
119 Collapsed,
120 }
121
122 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
123 pub(crate) struct TranscriptActionOwner {
124 pub cell_index: usize,
125 /// Rejects same-index replacements after destructive transcript changes.
126 pub identity_epoch: u64,
127 }
128
129 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
130 pub(crate) struct ReasoningActionTarget {
131 pub owner: TranscriptActionOwner,
132 pub action: ReasoningAction,
133 }
134
135 // === History Cells ===
136
137 /// Renderable history cell for user/assistant/system entries.
138 #[derive(Debug, Clone)]
139 pub enum HistoryCell {
140 User {
141 content: String,
142 },
143 Assistant {
144 content: String,
145 streaming: bool,
146 },
147 System {
148 content: String,
149 },
150 /// Categorized engine-error cell. Severity drives the label glyph + color
151 /// (red for `Error`/`Critical`, amber for `Warning`, dim for `Info`) so
152 /// the user can prioritize at a glance.
153 Error {
154 message: String,
155 severity: crate::error_taxonomy::ErrorSeverity,
156 },
157 Thinking {
158 content: String,
159 streaming: bool,
160 duration_secs: Option<f32>,
161 },
162 /// An `<archived_context>` seam block produced by the Flash seam manager
163 /// (issue #159). Rendered dimmed/italic with a level + range label so
164 /// the user can see at a glance where context seams exist.
165 ArchivedContext {
166 /// Seam level (1, 2, 3, or 0 for cycle-level).
167 level: u8,
168 /// Message range covered (e.g. "msg 0-128").
169 range: String,
170 /// Token estimate string (e.g. "~2500").
171 tokens: String,
172 /// Density label (e.g. "~2,500 tokens").
173 density: String,
174 /// Model that produced the summary.
175 model: String,
176 /// RFC 3339 timestamp.
177 timestamp: String,
178 /// The summary text content.
179 summary: String,
180 },
181 Tool(ToolCell),
182 /// Typed receipt for durable scheduled automations (fired / started /
183 /// completed / failed / coalesced / missed / expired / mutated) — the
184 /// one-line bulleted card that replaced bare-String `System` receipts
185 /// (AUTOMATION-VISIBILITY-SPEC §2.2).
186 Automation(AutomationCell),
187 /// Live in-transcript card for sub-agent activity (issue #128). Owns
188 /// either a single `DelegateCard` or a multi-worker `FanoutCard`; the
189 /// UI re-binds it from the mailbox stream as envelopes arrive.
190 SubAgent(SubAgentCell),
191 }
192
193 /// In-transcript sub-agent cell — either a single delegate or a fanout.
194 /// State mutates over the turn as mailbox envelopes are drained.
195 /// `Shelf` is a synthetic collapsed projector for concurrent live agents.
196 #[derive(Debug, Clone)]
197 pub enum SubAgentCell {
198 Delegate(crate::tui::widgets::agent_card::DelegateCard),
199 Fanout(crate::tui::widgets::agent_card::FanoutCard),
200 }
201
202 impl SubAgentCell {
203 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
204 match self {
205 SubAgentCell::Delegate(card) => card.render_lines(width, &codewhale_palette::UI_THEME),
206 SubAgentCell::Fanout(card) => card.render_lines(width, &codewhale_palette::UI_THEME),
207 }
208 }
209 }
210
211 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
212 pub struct TranscriptRenderOptions {
213 pub locale: Locale,
214 pub show_thinking: bool,
215 pub thinking_default_expanded: bool,
216 /// Collapsed completed-thought preview rows (settings.toml).
217 pub thinking_preview_lines: usize,
218 pub thinking_highlight: bool,
219 pub verbose: bool,
220 pub show_tool_details: bool,
221 pub inline_diff_mode: crate::settings::InlineDiffMode,
222 pub calm_mode: bool,
223 pub low_motion: bool,
224 pub motion_mode: MotionMode,
225 pub spacing: TranscriptSpacing,
226 /// Resolved application theme mode. This keeps cached markdown syntax
227 /// colors aligned with an explicit theme selection.
228 pub palette_mode: palette::PaletteMode,
229 /// Wrap cap (columns) for prose cells — user messages, assistant answers,
230 /// and reasoning/thinking blocks. `None` spends the full content width,
231 /// matching tool/status cells (#5436); `Some(n)` caps prose at `n` columns
232 /// for a bounded reading measure. Resolved once per render pass from
233 /// `[transcript] prose_measure` so the main cache and the full-screen
234 /// overlay agree on the same effective width.
235 pub(crate) prose_measure: Option<u16>,
236 /// This cell is a durable Work receipt that a later one has replaced.
237 ///
238 /// `todo_write` writes the whole list every time, so a long session grew a
239 /// stack of full checklist cards that could only be cleared by `/clear` or
240 /// `/new` — both of which also drop `api_messages` and the compaction
241 /// summary, so tidying the view cost the conversation (#5871). A
242 /// superseded snapshot collapses to its summary line in the live
243 /// transcript; the full card stays in the transcript overlay and in the
244 /// tool detail record, because the receipt that the tool ran is evidence.
245 pub(crate) superseded_work_receipt: bool,
246 /// This cell is the newest user turn in the transcript. Only it carries
247 /// the elevated-surface background; every older prompt renders on the
248 /// bare ground, so the eye lands on the turn in play.
249 pub(crate) newest_user_turn: bool,
250 }
251
252 impl Default for TranscriptRenderOptions {
253 fn default() -> Self {
254 Self {
255 superseded_work_receipt: false,
256 newest_user_turn: false,
257 locale: Locale::En,
258 show_thinking: true,
259 thinking_highlight: true,
260 thinking_default_expanded: false,
261 thinking_preview_lines: 2,
262 verbose: false,
263 show_tool_details: true,
264 inline_diff_mode: crate::settings::InlineDiffMode::Full,
265 calm_mode: false,
266 low_motion: false,
267 motion_mode: MotionMode::Full,
268 spacing: TranscriptSpacing::Comfortable,
269 palette_mode: palette::PaletteMode::detect(),
270 prose_measure: None,
271 }
272 }
273 }
274
275 impl TranscriptRenderOptions {
276 /// Effective wrap width for a prose cell at the given content width.
277 ///
278 /// `prose_measure` caps the measure; without a cap the prose cell uses
279 /// the full content width, like tool/status cells. Applied only at the
280 /// live-transcript render entry points so the main cache and the
281 /// full-screen overlay agree on the same effective width.
282 #[must_use]
283 pub(crate) fn prose_width(self, width: u16) -> u16 {
284 match self.prose_measure {
285 Some(cap) => width.clamp(1, cap),
286 None => width.max(1),
287 }
288 }
289 }
290
291 impl HistoryCell {
292 #[must_use]
293 pub(crate) fn has_live_motion(&self) -> bool {
294 match self {
295 HistoryCell::Assistant { streaming, .. } => *streaming,
296 HistoryCell::Tool(ToolCell::Generic(tool))
297 if tool.name == "agent" && !agent_activity::is_agent_inspection(tool) =>
298 {
299 false
300 }
301 HistoryCell::Tool(tool) => tool.is_running(),
302 HistoryCell::User { .. }
303 | HistoryCell::System { .. }
304 | HistoryCell::Error { .. }
305 | HistoryCell::Thinking { .. }
306 | HistoryCell::ArchivedContext { .. }
307 | HistoryCell::Automation(_)
308 | HistoryCell::SubAgent(_) => false,
309 }
310 }
311
312 /// Whether this cell is a completed (non-streaming) assistant answer —
313 /// the only variant a "copy the answer" affordance may serialize.
314 ///
315 /// This is the typed "final answer only" projection for copy surfaces:
316 /// everything else — user prompts, reasoning/thinking blocks, tool calls
317 /// and results, sub-agent transcripts, runtime status/system notes, and
318 /// archived-context seams — is excluded by construction, and a
319 /// still-streaming partial answer never qualifies. Pair it with
320 /// `history_cell_to_clipboard_text` for the canonical clean payload.
321 #[must_use]
322 pub fn is_completed_assistant_answer(&self) -> bool {
323 matches!(
324 self,
325 HistoryCell::Assistant {
326 streaming: false,
327 ..
328 }
329 )
330 }
331
332 #[allow(clippy::too_many_arguments)]
333 pub(crate) fn update_incremental_streaming_render(
334 &self,
335 width: u16,
336 options: TranscriptRenderOptions,
337 verified_append: bool,
338 cache: &mut crate::tui::markdown_render::IncrementalMarkdownRenderCache,
339 lines: &mut Vec<Line<'static>>,
340 links: &mut Vec<Vec<crate::tui::osc8::LineLink>>,
341 copy_separators: &mut Vec<CopyLineSeparator>,
342 copy_prefix_widths: &mut Vec<usize>,
343 ) -> Option<usize> {
344 let HistoryCell::Assistant {
345 content,
346 streaming: true,
347 } = self
348 else {
349 return None;
350 };
351 if content.trim().is_empty() {
352 lines.clear();
353 links.clear();
354 copy_separators.clear();
355 copy_prefix_widths.clear();
356 *cache = crate::tui::markdown_render::IncrementalMarkdownRenderCache::default();
357 return Some(0);
358 }
359 let width = options.prose_width(width);
360 Some(update_streaming_message_render(
361 cache,
362 content,
363 width,
364 assistant_label_style_for(true, options.low_motion),
365 message_body_style(),
366 options.palette_mode,
367 verified_append,
368 lines,
369 links,
370 copy_separators,
371 copy_prefix_widths,
372 ))
373 }
374
375 /// Render the cell into a set of terminal lines.
376 ///
377 /// This is the live-display path used by widgets that don't already pass
378 /// `TranscriptRenderOptions`. Tool output is capped, but thinking is shown
379 /// in full because callers using bare `lines()` historically expected the
380 /// uncollapsed body. For the in-stream transcript view prefer
381 /// `lines_with_options`; for the pager / clipboard prefer
382 /// `transcript_lines`.
383 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
384 match self {
385 HistoryCell::User { content } => render_user_message(content, width, false),
386 HistoryCell::Assistant { content, streaming } => render_message(
387 ASSISTANT_GLYPH,
388 assistant_label_style_for(*streaming, /*low_motion*/ false),
389 message_body_style(),
390 content,
391 width,
392 ),
393 HistoryCell::System { content } => {
394 if is_cycle_boundary(content) {
395 render_cycle_boundary(content, width)
396 } else {
397 render_message(
398 "Note",
399 system_label_style(),
400 system_body_style(),
401 content,
402 width,
403 )
404 }
405 }
406 HistoryCell::Error { message, severity } => {
407 render_error_message(message, *severity, width, true)
408 }
409 HistoryCell::Thinking {
410 content,
411 streaming,
412 duration_secs,
413 } => render_thinking(content, width, *streaming, *duration_secs, false, false),
414 HistoryCell::Tool(cell) => cell.lines_with_motion(width, false),
415 HistoryCell::SubAgent(cell) => cell.lines(width),
416 HistoryCell::Automation(cell) => cell.render(width),
417 HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, false),
418 }
419 }
420
421 #[cfg(test)]
422 pub fn lines_with_options(
423 &self,
424 width: u16,
425 options: TranscriptRenderOptions,
426 ) -> Vec<Line<'static>> {
427 self.lines_with_options_folded(width, options, None).0
428 }
429
430 /// Render with the user's explicit per-cell fold intent for thinking
431 /// cells.
432 ///
433 /// `None` means the user has not touched this cell, so the expanded
434 /// baseline decides: on when the session is verbose or the thinking
435 /// default is expanded, off otherwise. `Some(..)` is the user's own
436 /// decision and outranks the baseline in both directions, so changing a
437 /// preference later never rewrites what they already chose (#5847).
438 pub fn lines_with_options_folded(
439 &self,
440 width: u16,
441 options: TranscriptRenderOptions,
442 fold: Option<ThinkingFold>,
443 ) -> (Vec<Line<'static>>, Option<ReasoningAction>) {
444 let mut reasoning_action = None;
445 let mut lines = match self {
446 HistoryCell::Thinking {
447 streaming,
448 duration_secs,
449 ..
450 } if !options.show_thinking => {
451 if *streaming {
452 render_hidden_thinking_activity(width, *duration_secs, options.low_motion)
453 } else {
454 Vec::new()
455 }
456 }
457 HistoryCell::Thinking {
458 content,
459 streaming,
460 duration_secs,
461 } => {
462 let collapsed = match fold {
463 Some(ThinkingFold::Expanded) => false,
464 Some(ThinkingFold::Collapsed) => true,
465 None => !(options.verbose || options.thinking_default_expanded),
466 };
467 let (lines, expandable) = thinking::render_thinking_with_preview_limit(
468 content,
469 width,
470 *streaming,
471 *duration_secs,
472 collapsed,
473 options.low_motion,
474 options.thinking_highlight && !options.calm_mode,
475 options.locale,
476 if options.calm_mode {
477 0
478 } else {
479 options.thinking_preview_lines
480 },
481 );
482 reasoning_action = expandable.then_some(if collapsed {
483 ReasoningAction::Expand
484 } else {
485 ReasoningAction::Collapse
486 });
487 lines
488 }
489 HistoryCell::Tool(ToolCell::PatchSummary(cell)) => cell.render(
490 width,
491 options.low_motion,
492 RenderMode::Live,
493 options.inline_diff_mode,
494 ),
495 HistoryCell::Tool(cell)
496 if (!options.show_tool_details || options.calm_mode) && !cell.is_failed() =>
497 {
498 let mut lines =
499 cell.lines_with_motion_and_locale(width, options.low_motion, options.locale);
500 if lines.len() > TOOL_SUMMARY_CARD_LINES {
501 lines.truncate(TOOL_SUMMARY_CARD_LINES.saturating_sub(1));
502 lines.push(details_affordance_line(
503 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("details"),
504 Style::default().fg(palette::TEXT_MUTED).italic(),
505 ));
506 }
507 lines
508 }
509 HistoryCell::Tool(cell) if options.superseded_work_receipt => {
510 // A durable Work receipt a later one replaced keeps its header
511 // — the progress reading — and drops the body (#5871). The full
512 // card stays in the transcript overlay and the detail record,
513 // so the evidence that the tool ran is not rewritten away.
514 let mut lines =
515 cell.lines_with_motion_and_locale(width, options.low_motion, options.locale);
516 lines.truncate(1);
517 lines.push(details_affordance_line(
518 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("details"),
519 Style::default().fg(palette::TEXT_MUTED).italic(),
520 ));
521 lines
522 }
523 HistoryCell::Tool(cell) => {
524 cell.lines_with_motion_and_locale(width, options.low_motion, options.locale)
525 }
526 HistoryCell::User { content } => {
527 render_user_message(content, width, options.newest_user_turn)
528 }
529 HistoryCell::Assistant { content, streaming } => {
530 let mut lines: Vec<Line<'static>> = render_message_with_copy_metadata_for_palette(
531 ASSISTANT_GLYPH,
532 assistant_label_style_for(*streaming, options.low_motion),
533 message_body_style(),
534 content,
535 width,
536 options.palette_mode,
537 )
538 .into_iter()
539 .map(|rendered| rendered.line)
540 .collect();
541 if *streaming {
542 apply_hot_tail_to_last_line(&mut lines, options.low_motion);
543 }
544 lines
545 }
546 HistoryCell::System { .. } => self.lines(width),
547 HistoryCell::Error { message, severity } => {
548 render_error_message(message, *severity, width, true)
549 }
550 HistoryCell::SubAgent(cell) => cell.lines(width),
551 HistoryCell::Automation(cell) => cell.render(width),
552 HistoryCell::ArchivedContext { .. } => {
553 render_archived_context(self, width, options.low_motion)
554 }
555 };
556 if matches!(self, HistoryCell::Tool(_)) {
557 match options.motion_mode {
558 MotionMode::Reduced => apply_static_tool_markers(
559 &mut lines,
560 crate::tui::spinner::BRAILLE_SPINNER_STILL_FRAME,
561 ),
562 MotionMode::Still => {
563 apply_static_tool_markers(&mut lines, crate::tui::spinner::LIVE_STATIC_MARKER)
564 }
565 MotionMode::Full => {}
566 }
567 }
568 (lines, reasoning_action)
569 }
570
571 pub(crate) fn lines_with_copy_metadata(
572 &self,
573 width: u16,
574 options: TranscriptRenderOptions,
575 ) -> Vec<RenderedTranscriptLine> {
576 self.lines_with_copy_metadata_folded(width, options, None).0
577 }
578
579 pub(crate) fn lines_with_copy_metadata_folded(
580 &self,
581 width: u16,
582 options: TranscriptRenderOptions,
583 fold: Option<ThinkingFold>,
584 ) -> (Vec<RenderedTranscriptLine>, Option<ReasoningAction>) {
585 if matches!(self, HistoryCell::Thinking { .. }) {
586 let (lines, action) =
587 self.lines_with_options_folded(options.prose_width(width), options, fold);
588 return (hard_break_copy_lines(lines), action);
589 }
590 let lines = match self {
591 HistoryCell::User { content } => hard_break_copy_lines(render_user_message(
592 content,
593 options.prose_width(width),
594 options.newest_user_turn,
595 )),
596 HistoryCell::Assistant { content, streaming } => {
597 let width = options.prose_width(width);
598 let mut rendered = render_message_with_copy_metadata_for_palette(
599 ASSISTANT_GLYPH,
600 assistant_label_style_for(*streaming, options.low_motion),
601 message_body_style(),
602 content,
603 width,
604 options.palette_mode,
605 );
606 if *streaming && let Some(last) = rendered.last_mut() {
607 apply_hot_tail_to_line(&mut last.line, options.low_motion);
608 }
609 rendered
610 }
611 HistoryCell::System { content } if !is_cycle_boundary(content) => {
612 render_message_with_copy_metadata_for_palette(
613 "Note",
614 system_label_style(),
615 system_body_style(),
616 content,
617 width,
618 options.palette_mode,
619 )
620 }
621 HistoryCell::Tool(_) => self
622 .lines_with_options_folded(width, options, fold)
623 .0
624 .into_iter()
625 .map(|line| {
626 let copy_prefix_width = tool_copy_prefix_width(&line);
627 RenderedTranscriptLine {
628 line,
629 links: Vec::new(),
630 copy_prefix_width,
631 copy_separator_after: CopyLineSeparator::Newline,
632 }
633 })
634 .collect(),
635 HistoryCell::Thinking { .. } => unreachable!("reasoning handled above"),
636 _ => hard_break_copy_lines(self.lines_with_options_folded(width, options, fold).0),
637 };
638 (lines, None)
639 }
640
641 /// Render the cell in transcript mode: full content, no caps, no
642 /// visible details-pager affordances.
643 ///
644 /// Use this for full-detail pagers, clipboard exports, and any
645 /// surface that wants the complete body rather than the live summary.
646 /// For most variants (User / Assistant / System) this matches `lines()`;
647 /// `Thinking` and `Tool` are where the live and transcript surfaces
648 /// diverge.
649 pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
650 match self {
651 HistoryCell::User { content } => render_plain_message(
652 USER_GLYPH,
653 user_label_style(),
654 user_body_style(),
655 content,
656 width,
657 ),
658 HistoryCell::Assistant { content, streaming } => render_message(
659 ASSISTANT_GLYPH,
660 // Pager / clipboard surface — pin the glyph at full
661 // brightness so a screenshot reads the same as a live frame.
662 assistant_label_style_for(*streaming, /*low_motion*/ true),
663 message_body_style(),
664 content,
665 width,
666 ),
667 HistoryCell::System { .. } => self.lines(width),
668 HistoryCell::Error { message, severity } => {
669 render_error_message(message, *severity, width, false)
670 }
671 HistoryCell::Thinking {
672 content,
673 streaming,
674 duration_secs,
675 } => render_thinking(
676 content,
677 width,
678 *streaming,
679 *duration_secs,
680 /*collapsed*/ false,
681 /*low_motion*/ false,
682 ),
683 HistoryCell::Tool(cell) => cell.transcript_lines(width),
684 HistoryCell::SubAgent(cell) => cell.lines(width),
685 HistoryCell::Automation(cell) => cell.render(width),
686 HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, true),
687 }
688 }
689 }
690
691 /// Convert a message into history cells for rendering.
692 #[must_use]
693 pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
694 // Model-facing Operate contract; the live receipt is the goal line.
695 if crate::runtime_handoff::is_operate_contract_message(msg) {
696 return Vec::new();
697 }
698 if let Some(display) = crate::runtime_handoff::restored_subagent_checkpoint_display(msg) {
699 return vec![HistoryCell::System {
700 content: display.to_string(),
701 }];
702 }
703 // MCP server guidance is third-party text the model was shown; keep it
704 // auditable in the transcript rather than hiding it with other handoffs.
705 if let Some(guidance) = crate::runtime_handoff::mcp_server_instructions_display(msg) {
706 return vec![HistoryCell::System {
707 content: format!("MCP server guidance shown to the model:\n{guidance}"),
708 }];
709 }
710 if let Some(instructions) = crate::runtime_handoff::extension_prompt_contributions_display(msg)
711 {
712 return vec![HistoryCell::System {
713 content: instructions.to_string(),
714 }];
715 }
716 // Raw runtime handoffs have live tool/status receipts, not user cells.
717 // Keep their model-facing payload intact and filter only the display.
718 if crate::runtime_handoff::is_internal_runtime_handoff(msg) {
719 return Vec::new();
720 }
721
722 let mut cells = Vec::new();
723
724 for (block_index, block) in msg.content.iter().enumerate() {
725 match block {
726 ContentBlock::Text { text, .. } => {
727 if is_turn_metadata_block(msg, block_index, text) {
728 continue;
729 }
730 if text.starts_with("[tool_history_repair]") {
731 cells.push(HistoryCell::System {
732 content: text.clone(),
733 });
734 continue;
735 }
736 // Check if this is an `<archived_context>` block.
737 if (msg.role == "assistant"
738 || msg.role == codewhale_models::INTERRUPTED_ASSISTANT_ROLE)
739 && let Some(archived) = parse_archived_context(text)
740 {
741 cells.push(archived);
742 continue;
743 }
744 match msg.role.as_str() {
745 "user" => {
746 if let Some(HistoryCell::User { content }) = cells.last_mut() {
747 if !content.is_empty() {
748 content.push('\n');
749 }
750 content.push_str(text);
751 } else {
752 cells.push(HistoryCell::User {
753 content: text.clone(),
754 });
755 }
756 }
757 "assistant" => {
758 if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() {
759 if !content.is_empty() {
760 content.push('\n');
761 }
762 content.push_str(text);
763 } else {
764 cells.push(HistoryCell::Assistant {
765 content: text.clone(),
766 streaming: false,
767 });
768 }
769 }
770 "system" => {
771 if let Some(HistoryCell::System { content }) = cells.last_mut() {
772 if !content.is_empty() {
773 content.push('\n');
774 }
775 content.push_str(text);
776 } else {
777 cells.push(HistoryCell::System {
778 content: text.clone(),
779 });
780 }
781 }
782 _ => {}
783 }
784 }
785 ContentBlock::Thinking { thinking, .. } => {
786 // Older sessions may contain this transport-only fallback from
787 // thinking-mode tool-call replay. It was never model output,
788 // so do not surface it when restoring their transcripts.
789 if thinking == "(reasoning omitted)" {
790 continue;
791 }
792 if let Some(HistoryCell::Thinking { content, .. }) = cells.last_mut() {
793 if !content.is_empty() {
794 content.push('\n');
795 }
796 content.push_str(thinking);
797 } else {
798 cells.push(HistoryCell::Thinking {
799 content: thinking.clone(),
800 streaming: false,
801 duration_secs: None,
802 });
803 }
804 }
805 ContentBlock::ToolUse { name, input, .. } if name == "update_plan" => {
806 cells.push(HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
807 snapshot: PlanSnapshot::from_tool_input(input),
808 status: ToolStatus::Success,
809 })));
810 }
811 _ => {}
812 }
813 }
814
815 cells
816 }
817
818 /// Whether this text block is a runtime-owned `<turn_meta>` envelope that
819 /// must stay out of the visible transcript.
820 ///
821 /// Current sessions persist the envelope as the trailing block of a
822 /// multi-block user message; sessions saved before the tail move
823 /// (pre-v0.8.54) carry it as the *leading* block instead, so a complete
824 /// envelope is hidden at any index. A single-block user message is never
825 /// hidden: its text is user-authored by construction, and a literal
826 /// `<turn_meta>` example the user typed must stay visible.
827 fn is_turn_metadata_block(msg: &Message, block_index: usize, text: &str) -> bool {
828 if msg.role != "user" || msg.content.len() < 2 || !is_complete_turn_meta_envelope(text) {
829 return false;
830 }
831 if block_index > 0 {
832 return true;
833 }
834 // Leading envelope: hide it only when the trailing block is ordinary
835 // text (the legacy `[turn_meta, prompt]` persisted shape). If the tail
836 // block is itself an envelope, this message is the current shape and the
837 // leading block is user-authored literal text that must stay visible.
838 matches!(
839 msg.content.last(),
840 Some(ContentBlock::Text { text: tail, .. }) if !is_complete_turn_meta_envelope(tail)
841 )
842 }
843
844 fn is_complete_turn_meta_envelope(text: &str) -> bool {
845 let trimmed = text.trim();
846 trimmed
847 .strip_prefix("<turn_meta>")
848 .and_then(|body| body.strip_suffix("</turn_meta>"))
849 .is_some()
850 }
851
852 // === Tool Cells ===
853
854 /// Variants describing a tool result cell.
855 #[derive(Debug, Clone)]
856 pub enum ToolCell {
857 Exec(ExecCell),
858 Exploring(ExploringCell),
859 PlanUpdate(PlanUpdateCell),
860 PatchSummary(PatchSummaryCell),
861 Review(ReviewCell),
862 Mcp(McpToolCell),
863 ViewImage(ViewImageCell),
864 WebSearch(WebSearchCell),
865 Generic(GenericToolCell),
866 }
867
868 impl ToolCell {
869 /// Whether this tool cell projects durable Work state rather than a
870 /// transient action receipt. Transcript rhythm uses this semantic split
871 /// to keep plans, checklists, and workflows legible without teaching the
872 /// renderer about individual tool payloads.
873 #[must_use]
874 pub(crate) fn is_durable_work_receipt(&self) -> bool {
875 matches!(self, ToolCell::PlanUpdate(_))
876 || matches!(
877 self,
878 ToolCell::Generic(cell)
879 if cell.name == "workflow" || is_checklist_tool_name(&cell.name)
880 )
881 }
882
883 /// Status for cells that have a concrete lifecycle state.
884 pub fn status(&self) -> Option<ToolStatus> {
885 match self {
886 ToolCell::Exec(cell) => Some(cell.status),
887 ToolCell::Exploring(cell) => Some(cell.status()),
888 ToolCell::PlanUpdate(cell) => Some(cell.status),
889 ToolCell::PatchSummary(cell) => Some(cell.status),
890 ToolCell::Review(cell) => Some(cell.status),
891 ToolCell::Mcp(cell) => Some(cell.status),
892 ToolCell::WebSearch(cell) => Some(cell.status),
893 ToolCell::Generic(cell) => Some(cell.status),
894 ToolCell::ViewImage(_) => Some(ToolStatus::Success),
895 }
896 }
897
898 #[must_use]
899 pub fn is_success(&self) -> bool {
900 self.status() == Some(ToolStatus::Success)
901 }
902
903 #[must_use]
904 pub fn is_running(&self) -> bool {
905 self.status() == Some(ToolStatus::Running)
906 }
907
908 #[must_use]
909 pub fn is_failed(&self) -> bool {
910 self.status() == Some(ToolStatus::Failed)
911 }
912
913 /// Whether this cell should stay visible even inside a dense tool run.
914 #[must_use]
915 pub fn is_collapsible_guard(&self) -> bool {
916 self.is_running()
917 || self.is_failed()
918 || matches!(
919 self,
920 ToolCell::Exec(_)
921 | ToolCell::PatchSummary(_)
922 | ToolCell::Review(_)
923 | ToolCell::PlanUpdate(_)
924 )
925 || matches!(self, ToolCell::Generic(cell) if tool_run::generic_tool_name_is_collapse_guard(&cell.name) || cell.is_diff)
926 }
927
928 /// Render the tool cell into lines.
929 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
930 self.lines_with_motion(width, false)
931 }
932
933 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
934 self.lines_with_motion_and_locale(width, low_motion, Locale::En)
935 }
936
937 pub fn lines_with_motion_and_locale(
938 &self,
939 width: u16,
940 low_motion: bool,
941 locale: Locale,
942 ) -> Vec<Line<'static>> {
943 self.render_with_locale(width, low_motion, RenderMode::Live, locale)
944 }
945
946 /// Full-content rendering for the pager / clipboard. Tool output that
947 /// would be capped + suffixed with a details-pager hint in the live view
948 /// is emitted in full here.
949 pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
950 self.transcript_lines_with_locale(width, Locale::En)
951 }
952
953 pub fn transcript_lines_with_locale(&self, width: u16, locale: Locale) -> Vec<Line<'static>> {
954 self.render_with_locale(
955 width,
956 /*low_motion*/ false,
957 RenderMode::Transcript,
958 locale,
959 )
960 }
961
962 fn render_with_locale(
963 &self,
964 width: u16,
965 low_motion: bool,
966 mode: RenderMode,
967 locale: Locale,
968 ) -> Vec<Line<'static>> {
969 match self {
970 ToolCell::Exec(cell) => cell.render_with_locale(width, low_motion, mode, locale),
971 ToolCell::Exploring(cell) => {
972 cell.lines_with_motion_and_locale(width, low_motion, locale)
973 }
974 ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion),
975 ToolCell::PatchSummary(cell) => cell.render(
976 width,
977 low_motion,
978 mode,
979 crate::settings::InlineDiffMode::Full,
980 ),
981 ToolCell::Review(cell) => cell.render(width, low_motion, mode),
982 ToolCell::Mcp(cell) => cell.render(width, low_motion, mode),
983 ToolCell::ViewImage(cell) => cell.lines_with_motion(width, low_motion),
984 ToolCell::WebSearch(cell) => cell.lines_with_motion(width, low_motion),
985 ToolCell::Generic(cell) => {
986 cell.lines_with_mode_and_locale(width, low_motion, mode, locale)
987 }
988 }
989 }
990 }
991
992 /// Overall status for a tool execution.
993 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
994 pub enum ToolStatus {
995 Running,
996 Success,
997 Hydrated,
998 /// Terminal result with usable output that still needs attention.
999 Warning,
1000 Failed,
1001 }
1002
1003 /// Shell command execution rendering data.
1004 #[derive(Debug, Clone)]
1005 pub struct ExecCell {
1006 pub command: String,
1007 pub status: ToolStatus,
1008 pub output: Option<String>,
1009 pub live_output: Option<String>,
1010 pub shell_task_id: Option<String>,
1011 pub owner_agent_id: Option<String>,
1012 pub owner_agent_name: Option<String>,
1013 pub started_at: Option<Instant>,
1014 pub duration_ms: Option<u64>,
1015 pub stale_elapsed_since_output_ms: Option<u64>,
1016 pub source: ExecSource,
1017 pub interaction: Option<String>,
1018 /// Cached output summary — avoids re-parsing JSON every frame.
1019 pub output_summary: Option<String>,
1020 }
1021
1022 impl ExecCell {
1023 /// Render the execution cell into lines (live view, capped output).
1024 #[cfg(test)]
1025 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1026 self.render(width, low_motion, RenderMode::Live)
1027 }
1028
1029 /// Foreground `exec_shell` blocking the turn — eligible for Ctrl+B detach.
1030 fn is_foreground_shell_wait(&self) -> bool {
1031 self.status == ToolStatus::Running
1032 && self.source == ExecSource::Assistant
1033 && self.interaction.is_none()
1034 }
1035
1036 #[cfg(test)]
1037 pub(super) fn render(
1038 &self,
1039 width: u16,
1040 low_motion: bool,
1041 mode: RenderMode,
1042 ) -> Vec<Line<'static>> {
1043 self.render_with_locale(width, low_motion, mode, Locale::En)
1044 }
1045
1046 pub(super) fn render_with_locale(
1047 &self,
1048 width: u16,
1049 low_motion: bool,
1050 mode: RenderMode,
1051 locale: Locale,
1052 ) -> Vec<Line<'static>> {
1053 let mut lines = Vec::new();
1054 let command_summary = command_header_summary(&self.command);
1055 let compact_foreground_wait = self.is_foreground_shell_wait();
1056 let header_summary = if compact_foreground_wait {
1057 Some(FOREGROUND_SHELL_WAIT_HINT)
1058 } else {
1059 self.interaction
1060 .as_deref()
1061 .or(Some(command_summary.as_str()))
1062 };
1063 let stale_status = self
1064 .stale_elapsed_since_output_ms
1065 .map(stale_shell_status_label);
1066 let receipt = tool_receipt_label(
1067 crate::tui::widgets::tool_card::ToolFamily::Run,
1068 self.status,
1069 self.output.as_deref(),
1070 locale,
1071 );
1072 let status_text = stale_status
1073 .as_deref()
1074 .map(Cow::Borrowed)
1075 .unwrap_or(receipt);
1076 lines.push(render_tool_header_with_summary(
1077 "Shell",
1078 header_summary,
1079 status_text.as_ref(),
1080 self.status,
1081 self.started_at,
1082 low_motion || stale_status.is_some(),
1083 ));
1084
1085 // Foreground shell waits block the turn but do not need a verbose
1086 // transcript card — spinner + running badge + Ctrl+B hint only.
1087 // Command, live output, and artifact paths belong in the Activity sidebar
1088 // and `/jobs` detail surfaces.
1089 if compact_foreground_wait {
1090 return wrap_card_rail(lines, self.status);
1091 }
1092
1093 // Routine successes get a three-row glimpse (two opening rows plus
1094 // the tail) so they take less space in the live transcript. Failures
1095 // keep their larger preview, and Transcript mode retains the full
1096 // result for the pager and clipboard.
1097 if mode == RenderMode::Live
1098 && self
1099 .output
1100 .as_deref()
1101 .is_some_and(is_truncated_output_preview)
1102 {
1103 lines.push(render_spillover_annotation(width));
1104 return wrap_card_rail(lines, self.status);
1105 }
1106 if mode == RenderMode::Live && self.status == ToolStatus::Success {
1107 if self.interaction.is_none()
1108 && let Some(output) = self.output.as_ref().or(self.live_output.as_ref())
1109 {
1110 lines.extend(render_exec_output_mode(
1111 output,
1112 width,
1113 TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
1114 mode,
1115 ));
1116 }
1117 if let Some(duration_ms) = self.duration_ms
1118 && duration_ms >= 1000
1119 {
1120 lines.extend(render_compact_kv(
1121 "time",
1122 &crate::elapsed::format_elapsed_ms(duration_ms),
1123 Style::default().fg(palette::TEXT_DIM),
1124 width,
1125 ));
1126 }
1127 return wrap_card_rail(lines, self.status);
1128 }
1129
1130 if self.status == ToolStatus::Success && self.source == ExecSource::User {
1131 lines.extend(render_compact_kv(
1132 "source",
1133 "started by you",
1134 Style::default().fg(palette::TEXT_MUTED),
1135 width,
1136 ));
1137 }
1138
1139 if let Some(owner) = self
1140 .owner_agent_name
1141 .as_deref()
1142 .or(self.owner_agent_id.as_deref())
1143 {
1144 lines.extend(render_compact_kv(
1145 "owner",
1146 owner,
1147 Style::default().fg(palette::TEXT_MUTED),
1148 width,
1149 ));
1150 }
1151
1152 if let Some(interaction) = self.interaction.as_ref() {
1153 lines.extend(wrap_plain_line(
1154 &format!(" {interaction}"),
1155 Style::default().fg(palette::TEXT_MUTED),
1156 width,
1157 ));
1158 } else {
1159 lines.extend(render_command_mode(&self.command, width, mode));
1160 }
1161
1162 if self.interaction.is_none() {
1163 if let Some(output) = self.output.as_ref().or(self.live_output.as_ref()) {
1164 lines.extend(render_exec_output_mode(
1165 output,
1166 width,
1167 TOOL_OUTPUT_LINE_LIMIT,
1168 mode,
1169 ));
1170 } else if self.status == ToolStatus::Running && self.source == ExecSource::Assistant {
1171 lines.extend(wrap_plain_line(
1172 " Ctrl+B moves this shell wait to /jobs.",
1173 Style::default().fg(palette::TEXT_MUTED),
1174 width,
1175 ));
1176 } else if self.status != ToolStatus::Running && mode == RenderMode::Transcript {
1177 // #3031: Suppress "(no output)" in compact/Live mode;
1178 // the success header is enough signal. Transcript still
1179 // records it for exports/clipboard/pager.
1180 lines.push(Line::from(Span::styled(
1181 " (no output)",
1182 Style::default().fg(palette::TEXT_MUTED).italic(),
1183 )));
1184 }
1185 }
1186
1187 if let Some(duration_ms) = self.duration_ms {
1188 // #3031: Suppress sub-second timing in compact mode.
1189 // Transcript mode always shows timing.
1190 if mode == RenderMode::Transcript || duration_ms >= 1000 {
1191 lines.extend(render_compact_kv(
1192 "time",
1193 &crate::elapsed::format_elapsed_ms(duration_ms),
1194 Style::default().fg(palette::TEXT_DIM),
1195 width,
1196 ));
1197 }
1198 }
1199
1200 wrap_card_rail(lines, self.status)
1201 }
1202 }
1203
1204 /// Source of a shell command execution.
1205 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1206 pub enum ExecSource {
1207 User,
1208 Assistant,
1209 }
1210
1211 /// Aggregate cell for tool exploration runs.
1212 #[derive(Debug, Clone)]
1213 pub struct ExploringCell {
1214 pub entries: Vec<ExploringEntry>,
1215 }
1216
1217 impl ExploringCell {
1218 /// The cell's own lifecycle state, rolled up from its parallel entries.
1219 ///
1220 /// One derivation, used by the card header, by [`ToolCell::status`], and by
1221 /// the Activity surfaces — three copies of this fold had drifted apart, and
1222 /// the header's copy could not produce `Failed` at all, so a fan-out with a
1223 /// failed read painted itself `done` in the success colour.
1224 ///
1225 /// Precedence: any entry still running keeps the whole cell running; after
1226 /// that the loudest terminal state wins, so a single failure is never
1227 /// averaged away by its successful siblings.
1228 #[must_use]
1229 pub fn status(&self) -> ToolStatus {
1230 let any = |wanted: ToolStatus| self.entries.iter().any(|entry| entry.status == wanted);
1231 if any(ToolStatus::Running) {
1232 ToolStatus::Running
1233 } else if any(ToolStatus::Failed) {
1234 ToolStatus::Failed
1235 } else if any(ToolStatus::Warning) {
1236 ToolStatus::Warning
1237 } else if any(ToolStatus::Hydrated) {
1238 ToolStatus::Hydrated
1239 } else {
1240 ToolStatus::Success
1241 }
1242 }
1243
1244 /// Render the exploring cell into lines.
1245 #[cfg(test)]
1246 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1247 self.lines_with_motion_and_locale(width, low_motion, Locale::En)
1248 }
1249
1250 pub fn lines_with_motion_and_locale(
1251 &self,
1252 width: u16,
1253 low_motion: bool,
1254 locale: Locale,
1255 ) -> Vec<Line<'static>> {
1256 let mut lines = Vec::new();
1257 let status = self.status();
1258 let all_done = status != ToolStatus::Running;
1259 let header_summary = exploring_header_summary(&self.entries);
1260 let multi_entry = self.entries.len() > 1;
1261 let header_state: Cow<'static, str> = if multi_entry {
1262 Cow::Borrowed("")
1263 } else if all_done {
1264 if status == ToolStatus::Success {
1265 codewhale_localization::tr(
1266 locale,
1267 codewhale_localization::MessageId::ToolReceiptDone,
1268 )
1269 } else {
1270 Cow::Borrowed(tool_status_label(status))
1271 }
1272 } else {
1273 Cow::Borrowed("running")
1274 };
1275 // Search-only exploration cards read with the `find` verb so a
1276 // completed grep renders `find done · Searching for …` instead of the
1277 // incoherent `read done · Searching …` (#4145). Read/list or mixed
1278 // cards keep the neutral `read` verb the Workspace card has always used.
1279 let family = exploring_card_family(&self.entries);
1280 lines.push(render_tool_header_with_family_and_summary(
1281 family,
1282 header_summary.as_deref(),
1283 header_state.as_ref(),
1284 status,
1285 None,
1286 low_motion,
1287 ));
1288
1289 // Dot-grid status strip — one glyph per entry, showing parallel
1290 // fanout at a glance: ●=done ◐=running ✕=failed.
1291 if self.entries.len() > 1 {
1292 let (done, running, failed) =
1293 self.entries
1294 .iter()
1295 .fold((0usize, 0usize, 0usize), |(d, r, f), e| match e.status {
1296 ToolStatus::Success | ToolStatus::Hydrated => (d + 1, r, f),
1297 ToolStatus::Warning => (d, r, f + 1),
1298 ToolStatus::Running => (d, r + 1, f),
1299 ToolStatus::Failed => (d, r, f + 1),
1300 });
1301 // Each dot carries its own entry's state. Painting the strip one
1302 // flat colour made a failed parallel read indistinguishable from a
1303 // finished one, which is exactly the narration the cell is
1304 // supposed to make unnecessary.
1305 let mut dot_spans: Vec<Span<'static>> = vec![Span::raw(" ")];
1306 dot_spans.extend(self.entries.iter().map(|e| {
1307 let glyph = match e.status {
1308 ToolStatus::Success | ToolStatus::Hydrated => "\u{25CF}",
1309 ToolStatus::Warning => "!",
1310 ToolStatus::Running => "\u{25D0}",
1311 ToolStatus::Failed => "\u{2715}",
1312 };
1313 Span::styled(
1314 glyph,
1315 Style::default().fg(tool_glyph_color(e.status, family)),
1316 )
1317 }));
1318 let counts = format!(
1319 " {done} done, {running} running{}",
1320 if failed > 0 {
1321 format!(", {failed} failed")
1322 } else {
1323 String::new()
1324 },
1325 );
1326 dot_spans.push(Span::styled(
1327 counts,
1328 Style::default().fg(tool_glyph_color(status, family)),
1329 ));
1330 lines.push(Line::from(dot_spans));
1331 }
1332
1333 for entry in &self.entries {
1334 if multi_entry {
1335 lines.extend(render_card_detail_line(
1336 None,
1337 &entry.label,
1338 tool_value_style(),
1339 width,
1340 ));
1341 } else {
1342 let prefix = match entry.status {
1343 ToolStatus::Running => "live",
1344 ToolStatus::Success => "done",
1345 ToolStatus::Hydrated => "loaded",
1346 ToolStatus::Warning => "issue",
1347 ToolStatus::Failed => "issue",
1348 };
1349 lines.extend(render_compact_kv(
1350 prefix,
1351 &entry.label,
1352 tool_value_style(),
1353 width,
1354 ));
1355 }
1356 }
1357 lines
1358 }
1359
1360 /// Insert a new entry and return its index.
1361 #[must_use]
1362 pub fn insert_entry(&mut self, entry: ExploringEntry) -> usize {
1363 self.entries.push(entry);
1364 self.entries.len().saturating_sub(1)
1365 }
1366 }
1367
1368 /// Single entry for exploring tool output.
1369 #[derive(Debug, Clone)]
1370 pub struct ExploringEntry {
1371 pub label: String,
1372 pub status: ToolStatus,
1373 }
1374
1375 /// Calm outcome and exact evidence for a structured File mutation.
1376 #[derive(Debug, Clone)]
1377 pub struct PatchSummaryCell {
1378 pub path: String,
1379 pub summary: String,
1380 pub status: ToolStatus,
1381 pub error: Option<String>,
1382 pub receipt: Option<FileMutationReceipt>,
1383 }
1384
1385 impl PatchSummaryCell {
1386 pub(super) fn render(
1387 &self,
1388 width: u16,
1389 low_motion: bool,
1390 mode: RenderMode,
1391 inline_diff_mode: crate::settings::InlineDiffMode,
1392 ) -> Vec<Line<'static>> {
1393 let mut lines = Vec::new();
1394 let header_summary = self
1395 .receipt
1396 .as_ref()
1397 .map(FileMutationReceipt::outcome_label)
1398 .unwrap_or_else(|| self.path.clone());
1399 lines.push(render_tool_header_with_summary(
1400 "File",
1401 Some(&header_summary),
1402 tool_status_label(self.status),
1403 self.status,
1404 None,
1405 low_motion,
1406 ));
1407 if self.status == ToolStatus::Success
1408 && let Some(receipt) = self.receipt.as_ref()
1409 {
1410 lines.extend(receipt.render_inline(width, inline_diff_mode));
1411 } else {
1412 lines.extend(render_compact_kv(
1413 "file",
1414 &self.path,
1415 tool_value_style(),
1416 width,
1417 ));
1418 lines.extend(render_tool_output_mode(
1419 &self.summary,
1420 width,
1421 TOOL_COMMAND_LINE_LIMIT,
1422 mode,
1423 ));
1424 }
1425 if let Some(error) = self.error.as_ref() {
1426 lines.extend(render_tool_output_mode(
1427 error,
1428 width,
1429 TOOL_COMMAND_LINE_LIMIT,
1430 mode,
1431 ));
1432 }
1433 lines
1434 }
1435 }
1436
1437 /// Cell for structured review output.
1438 #[derive(Debug, Clone)]
1439 pub struct ReviewCell {
1440 pub target: String,
1441 pub status: ToolStatus,
1442 pub output: Option<ReviewOutput>,
1443 pub error: Option<String>,
1444 }
1445
1446 impl ReviewCell {
1447 pub(super) fn render(
1448 &self,
1449 width: u16,
1450 low_motion: bool,
1451 mode: RenderMode,
1452 ) -> Vec<Line<'static>> {
1453 let mut lines = Vec::new();
1454 lines.push(render_tool_header(
1455 "Review",
1456 tool_status_label(self.status),
1457 self.status,
1458 None,
1459 low_motion,
1460 ));
1461
1462 if !self.target.trim().is_empty() {
1463 lines.extend(render_compact_kv(
1464 "target",
1465 self.target.trim(),
1466 tool_value_style(),
1467 width,
1468 ));
1469 }
1470
1471 if self.status == ToolStatus::Running {
1472 return lines;
1473 }
1474
1475 if let Some(error) = self.error.as_ref() {
1476 lines.extend(render_tool_output_mode(
1477 error,
1478 width,
1479 TOOL_COMMAND_LINE_LIMIT,
1480 mode,
1481 ));
1482 return lines;
1483 }
1484
1485 let Some(output) = self.output.as_ref() else {
1486 return lines;
1487 };
1488
1489 if !output.summary.trim().is_empty() {
1490 lines.extend(wrap_plain_line(
1491 &format!("Summary: {}", output.summary.trim()),
1492 Style::default().fg(palette::TEXT_PRIMARY),
1493 width,
1494 ));
1495 }
1496
1497 lines.push(Line::from(""));
1498 lines.push(Line::from(Span::styled(
1499 "Issues",
1500 Style::default()
1501 .fg(palette::WHALE_ACTION)
1502 .add_modifier(Modifier::BOLD),
1503 )));
1504 if output.issues.is_empty() {
1505 lines.extend(wrap_plain_line(
1506 " (none)",
1507 Style::default().fg(palette::TEXT_MUTED),
1508 width,
1509 ));
1510 } else {
1511 for issue in &output.issues {
1512 let severity = issue.severity.trim().to_ascii_lowercase();
1513 let color = review_severity_color(&severity);
1514 let location = format_review_location(issue.path.as_ref(), issue.line);
1515 let label = if location.is_empty() {
1516 format!(" - [{}] {}", severity, issue.title.trim())
1517 } else {
1518 format!(" - [{}] {} ({})", severity, issue.title.trim(), location)
1519 };
1520 lines.extend(wrap_plain_line(&label, Style::default().fg(color), width));
1521 if !issue.description.trim().is_empty() {
1522 lines.extend(wrap_plain_line(
1523 &format!(" {}", issue.description.trim()),
1524 Style::default().fg(palette::TEXT_MUTED),
1525 width,
1526 ));
1527 }
1528 }
1529 }
1530
1531 lines.push(Line::from(""));
1532 lines.push(Line::from(Span::styled(
1533 "Suggestions",
1534 Style::default()
1535 .fg(palette::WHALE_ACTION)
1536 .add_modifier(Modifier::BOLD),
1537 )));
1538 if output.suggestions.is_empty() {
1539 lines.extend(wrap_plain_line(
1540 " (none)",
1541 Style::default().fg(palette::TEXT_MUTED),
1542 width,
1543 ));
1544 } else {
1545 for suggestion in &output.suggestions {
1546 let location = format_review_location(suggestion.path.as_ref(), suggestion.line);
1547 let label = if location.is_empty() {
1548 format!(" - {}", suggestion.suggestion.trim())
1549 } else {
1550 format!(" - {} ({})", suggestion.suggestion.trim(), location)
1551 };
1552 lines.extend(wrap_plain_line(
1553 &label,
1554 Style::default().fg(palette::TEXT_PRIMARY),
1555 width,
1556 ));
1557 }
1558 }
1559
1560 if !output.overall_assessment.trim().is_empty() {
1561 lines.push(Line::from(""));
1562 lines.extend(wrap_plain_line(
1563 &format!("Overall: {}", output.overall_assessment.trim()),
1564 Style::default().fg(palette::TEXT_PRIMARY),
1565 width,
1566 ));
1567 }
1568
1569 lines
1570 }
1571 }
1572
1573 /// Cell representing an MCP tool execution.
1574 #[derive(Debug, Clone)]
1575 pub struct McpToolCell {
1576 pub tool: String,
1577 pub status: ToolStatus,
1578 pub content: Option<String>,
1579 pub is_image: bool,
1580 }
1581
1582 impl McpToolCell {
1583 pub(super) fn render(
1584 &self,
1585 width: u16,
1586 low_motion: bool,
1587 mode: RenderMode,
1588 ) -> Vec<Line<'static>> {
1589 let mut lines = Vec::new();
1590 lines.push(render_tool_header_with_summary(
1591 "Tool",
1592 Some(&self.tool),
1593 tool_status_label(self.status),
1594 self.status,
1595 None,
1596 low_motion,
1597 ));
1598
1599 if self.is_image {
1600 lines.extend(render_compact_kv(
1601 "result",
1602 "image",
1603 tool_value_style(),
1604 width,
1605 ));
1606 }
1607
1608 if let Some(content) = self.content.as_ref() {
1609 if mode == RenderMode::Live && is_truncated_output_preview(content) {
1610 lines.push(render_spillover_annotation(width));
1611 return lines;
1612 }
1613 lines.extend(render_tool_output_mode(
1614 content,
1615 width,
1616 if self.status == ToolStatus::Failed {
1617 TOOL_FAILURE_PREVIEW_LINES
1618 } else {
1619 TOOL_COMMAND_LINE_LIMIT
1620 },
1621 mode,
1622 ));
1623 }
1624 lines
1625 }
1626 }
1627
1628 /// Cell for image view actions.
1629 #[derive(Debug, Clone)]
1630 pub struct ViewImageCell {
1631 pub path: PathBuf,
1632 }
1633
1634 impl ViewImageCell {
1635 /// Render the image view cell into lines.
1636 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1637 let path = self.path.display().to_string();
1638 let mut lines = vec![render_tool_header_with_summary(
1639 "Image",
1640 Some(&path),
1641 "done",
1642 ToolStatus::Success,
1643 None,
1644 low_motion,
1645 )];
1646 lines.extend(render_compact_kv("path", &path, tool_value_style(), width));
1647 lines
1648 }
1649 }
1650
1651 /// Cell for web search tool output.
1652 #[derive(Debug, Clone)]
1653 pub struct WebSearchCell {
1654 pub query: String,
1655 pub status: ToolStatus,
1656 pub summary: Option<String>,
1657 pub source: Option<String>,
1658 pub degraded: Option<String>,
1659 pub ref_count: usize,
1660 }
1661
1662 impl WebSearchCell {
1663 /// Render the web search cell into lines.
1664 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1665 let mut lines = Vec::new();
1666 lines.push(render_tool_header_with_summary(
1667 "Search",
1668 Some(&self.query),
1669 tool_status_label(self.status),
1670 self.status,
1671 None,
1672 low_motion,
1673 ));
1674 lines.extend(render_compact_kv(
1675 "query",
1676 &self.query,
1677 tool_value_style(),
1678 width,
1679 ));
1680 if let Some(source) = self.source.as_ref() {
1681 lines.extend(render_compact_kv(
1682 "source",
1683 source,
1684 tool_value_style(),
1685 width,
1686 ));
1687 }
1688 if let Some(degraded) = self.degraded.as_ref() {
1689 lines.extend(render_compact_kv(
1690 "degraded",
1691 degraded,
1692 tool_value_style(),
1693 width,
1694 ));
1695 }
1696 if self.ref_count > 0 {
1697 lines.extend(render_compact_kv(
1698 "citations",
1699 &self.ref_count.to_string(),
1700 tool_value_style(),
1701 width,
1702 ));
1703 }
1704 if let Some(summary) = self.summary.as_ref() {
1705 lines.extend(render_compact_kv(
1706 "result",
1707 summary,
1708 tool_value_style(),
1709 width,
1710 ));
1711 }
1712 lines
1713 }
1714 }
1715
1716 /// Generic cell for tool output when no specialized rendering exists.
1717 #[derive(Debug, Clone)]
1718 pub struct GenericToolCell {
1719 pub name: String,
1720 pub status: ToolStatus,
1721 pub input_summary: Option<String>,
1722 pub output: Option<String>,
1723 /// Optional list of per-child prompts. When populated (by any future
1724 /// fan-out tool), each prompt is shown on its own indented row instead
1725 /// of the inline `args:` summary. `None` for ordinary tools.
1726 pub prompts: Option<Vec<String>>,
1727 /// Filesystem path to the full output's spillover file (#422/#423).
1728 /// Set by the tool-routing layer when `ToolResult.metadata` carried a
1729 /// `spillover_path` field. The truncation affordance includes the
1730 /// path so the user can `read_file` it (or Cmd+click in
1731 /// OSC 8-aware terminals — the path renders as a hyperlink when
1732 /// `tui.osc8_links` is enabled).
1733 pub spillover_path: Option<std::path::PathBuf>,
1734 // --- Pre-computed render cache (populated once at cell creation) ---
1735 /// Cached output summary — avoids re-parsing JSON every frame.
1736 pub output_summary: Option<String>,
1737 /// Whether the output looks like a unified diff (cached after first check).
1738 pub is_diff: bool,
1739 }
1740
1741 fn should_show_raw_tool_name(
1742 name: &str,
1743 family: crate::tui::widgets::tool_card::ToolFamily,
1744 mode: RenderMode,
1745 ) -> bool {
1746 matches!(mode, RenderMode::Transcript)
1747 || matches!(family, crate::tui::widgets::tool_card::ToolFamily::Generic)
1748 || name.starts_with("mcp_")
1749 }
1750
1751 impl GenericToolCell {
1752 /// Render the generic tool cell into lines.
1753 ///
1754 /// `mode` controls multi-line output handling: `Live` caps at
1755 /// `TOOL_OUTPUT_LINE_LIMIT` rows with a "+N more" affordance;
1756 /// `Transcript` emits the full output.
1757 #[cfg(test)]
1758 pub fn lines_with_mode(
1759 &self,
1760 width: u16,
1761 low_motion: bool,
1762 mode: RenderMode,
1763 ) -> Vec<Line<'static>> {
1764 self.lines_with_mode_and_locale(width, low_motion, mode, Locale::En)
1765 }
1766
1767 pub fn lines_with_mode_and_locale(
1768 &self,
1769 width: u16,
1770 low_motion: bool,
1771 mode: RenderMode,
1772 locale: Locale,
1773 ) -> Vec<Line<'static>> {
1774 if self.name == "activity_group" {
1775 return agent_activity::render_activity_group(self, width);
1776 }
1777
1778 // Issue #241: when the underlying tool is a checklist/todo update and
1779 // the output is parseable, render a purpose-built progress card
1780 // instead of dumping the JSON into the generic tool block.
1781 if let Some(lines) = self.try_render_as_checklist(width, low_motion, mode) {
1782 return lines;
1783 }
1784
1785 // #4038 / #4122: purpose-built workflow run card (compact in live,
1786 // expanded in transcript) shared with the WorkflowPanel state machine.
1787 if let Some(lines) = self.try_render_as_workflow(width, low_motion, mode, locale) {
1788 return lines;
1789 }
1790
1791 // Sub-agent launch already gets a dedicated `DelegateCard`
1792 // that owns the live action tree, status, and final summary (#4133).
1793 // Spawns therefore render nothing here in either mode — one visible
1794 // artifact per delegated unit. Inspection/join calls (peek/status/
1795 // wait) stay as a single compact line (#4112 dogfood A5).
1796 if self.name == "agent" {
1797 if agent_activity::is_agent_inspection(self) {
1798 return agent_activity::render_agent_compact(self, low_motion);
1799 }
1800 // Spawn / start / run: suppress the generic tool card entirely.
1801 return Vec::new();
1802 }
1803
1804 // A call to a tool that doesn't exist carries exactly one useful
1805 // fact: the catalog error. The full name:/args:/result: block turns
1806 // each model slip into a four-line card (dogfood A5) — collapse it
1807 // to a single header line in both render modes.
1808 if self.status == ToolStatus::Failed
1809 && let Some(output) = self.output.as_deref()
1810 && output.contains("is not available in the current tool catalog")
1811 {
1812 let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
1813 let summary = truncate_text(output.trim(), 200);
1814 return wrap_card_rail(
1815 vec![render_tool_header_with_family_and_summary(
1816 family,
1817 Some(summary.as_str()),
1818 tool_status_label(self.status),
1819 self.status,
1820 None,
1821 low_motion,
1822 )],
1823 self.status,
1824 );
1825 }
1826
1827 // Live mode stays calm: successful tool calls collapse to one header
1828 // line, and non-read in-flight tools do the same. Failures keep their
1829 // body visible because error output is the useful part.
1830 if matches!(mode, RenderMode::Live) {
1831 let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
1832 let is_read_family = matches!(
1833 family,
1834 crate::tui::widgets::tool_card::ToolFamily::Read
1835 | crate::tui::widgets::tool_card::ToolFamily::Find
1836 );
1837 let should_collapse = self.status == ToolStatus::Success
1838 || (self.status != ToolStatus::Failed && !is_read_family);
1839 if should_collapse || self.spillover_path.is_some() {
1840 let header_summary = crate::tui::widgets::tool_card::tool_header_summary_for_name(
1841 &self.name,
1842 self.input_summary.as_deref(),
1843 );
1844 let mut collapsed = vec![render_tool_header_with_family_and_summary(
1845 family,
1846 header_summary.as_deref(),
1847 &tool_receipt_label(family, self.status, self.output.as_deref(), locale),
1848 self.status,
1849 None,
1850 low_motion,
1851 )];
1852 if self.spillover_path.is_some() {
1853 collapsed.push(render_spillover_annotation(width));
1854 }
1855 return wrap_card_rail(collapsed, self.status);
1856 }
1857 }
1858
1859 let mut lines = Vec::new();
1860 // Map the actual tool name (e.g. `agent`, `apply_patch`) to a
1861 // family rather than the catch-all `"Tool"` title — this is what
1862 // gives a `GenericToolCell` the right verb glyph (◐ delegate, ⋮⋮
1863 // fanout, etc.) instead of falling back to the neutral bullet.
1864 let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
1865 let header_summary = crate::tui::widgets::tool_card::tool_header_summary_for_name(
1866 &self.name,
1867 self.input_summary.as_deref(),
1868 );
1869 lines.push(render_tool_header_with_family_and_summary(
1870 family,
1871 header_summary.as_deref(),
1872 &tool_receipt_label(family, self.status, self.output.as_deref(), locale),
1873 self.status,
1874 None,
1875 low_motion,
1876 ));
1877 if should_show_raw_tool_name(&self.name, family, mode) {
1878 lines.extend(render_compact_kv(
1879 "name",
1880 &self.name,
1881 tool_value_style(),
1882 width,
1883 ));
1884 }
1885
1886 // Prefer per-prompt rows over the generic args summary when the tool
1887 // exposes a list of child prompts. One row per child with a `[i]`
1888 // index makes the fan-out legible without expanding JSON.
1889 let show_prompts = matches!(self.status, ToolStatus::Running) || self.output.is_none();
1890 if show_prompts
1891 && let Some(prompts) = self.prompts.as_ref()
1892 && !prompts.is_empty()
1893 {
1894 for (idx, prompt) in prompts.iter().enumerate() {
1895 let label = if idx == 0 { "prompts" } else { "" };
1896 let value = format!("[{idx}] {}", truncate_text(prompt.trim(), 200));
1897 lines.extend(render_card_detail_line(
1898 if label.is_empty() { None } else { Some(label) },
1899 &value,
1900 tool_value_style(),
1901 width,
1902 ));
1903 }
1904 } else {
1905 let show_args = matches!(self.status, ToolStatus::Running | ToolStatus::Failed)
1906 || self.output.is_none();
1907 if show_args && let Some(summary) = self.input_summary.as_ref() {
1908 lines.extend(render_compact_kv(
1909 "args",
1910 summary,
1911 tool_value_style(),
1912 width,
1913 ));
1914 }
1915 }
1916
1917 if let Some(output) = self.output.as_ref() {
1918 if self.is_diff {
1919 let diff_summary = diff_render::diff_summary_label(output);
1920 lines.push(render_tool_header_with_summary(
1921 "Diff",
1922 diff_summary.as_deref(),
1923 tool_status_label(self.status),
1924 self.status,
1925 None,
1926 low_motion,
1927 ));
1928 if matches!(mode, RenderMode::Live) {
1929 let limit = if self.status == ToolStatus::Failed {
1930 TOOL_FAILURE_PREVIEW_LINES
1931 } else {
1932 TOOL_OUTPUT_LINE_LIMIT
1933 };
1934 let rendered = diff_render::render_diff_bounded(output, width, limit);
1935 lines.extend(rendered.lines);
1936 if rendered.omitted_rows > 0 {
1937 let detail_hint =
1938 crate::tui::key_shortcuts::tool_details_shortcut_action_hint("diff");
1939 lines.push(details_affordance_line(
1940 &format!("+{} diff lines · {detail_hint}", rendered.omitted_rows),
1941 Style::default().fg(palette::TEXT_MUTED).italic(),
1942 ));
1943 }
1944 } else {
1945 // Transcript/detail mode remains the exact-evidence path;
1946 // only the live frame is budgeted.
1947 lines.extend(diff_render::render_diff(output, width));
1948 }
1949 } else {
1950 let limit = if self.status == ToolStatus::Failed {
1951 TOOL_FAILURE_PREVIEW_LINES
1952 } else {
1953 TOOL_OUTPUT_LINE_LIMIT
1954 };
1955 lines.extend(render_tool_output_mode(output, width, limit, mode));
1956 }
1957
1958 if matches!(mode, RenderMode::Live) && self.spillover_path.is_some() {
1959 lines.push(render_spillover_annotation(width));
1960 }
1961 }
1962 wrap_card_rail(lines, self.status)
1963 }
1964
1965 /// If this cell is a checklist/todo write/add/update and the output is
1966 /// parseable as a checklist snapshot, render a purpose-built checklist
1967 /// card instead of the generic `name: ... { json }` block (issue #241).
1968 fn try_render_as_checklist(
1969 &self,
1970 width: u16,
1971 low_motion: bool,
1972 mode: RenderMode,
1973 ) -> Option<Vec<Line<'static>>> {
1974 if !is_checklist_tool_name(&self.name) {
1975 return None;
1976 }
1977 let output = self.output.as_ref()?;
1978 let snapshot = parse_checklist_snapshot(output)?;
1979
1980 // Concise update rendering (#403). When the tool emits an
1981 // "Updated todo #N to STATUS" prefix line — which `todo_update` /
1982 // `checklist_update` always do on a successful match — render
1983 // only the changed item plus a `M/N · pct%` summary instead of
1984 // dumping the full list every time. The full list is still
1985 // reachable via `v` on the tool detail record. This keeps the
1986 // transcript scannable in long sessions.
1987 if matches!(mode, RenderMode::Live)
1988 && let Some(change) = parse_update_prefix(output)
1989 {
1990 return Some(render_checklist_change_card(
1991 &self.name,
1992 self.status,
1993 &snapshot,
1994 &change,
1995 width,
1996 low_motion,
1997 ));
1998 }
1999
2000 Some(render_checklist_card(
2001 &self.name,
2002 self.status,
2003 &snapshot,
2004 width,
2005 low_motion,
2006 mode,
2007 ))
2008 }
2009
2010 /// Render the `workflow` tool as the transcript's one row per run
2011 /// (#4122): while the run is live, the call that launched it is one
2012 /// `started` line naming the run; when it settles,
2013 /// `App::announce_settled_workflows` swaps that card's record for the
2014 /// finish, so the same row becomes the final state — outcome counts,
2015 /// elapsed, tokens — with the result or the reason under it. A card that
2016 /// returned a settled record (a foreground `run`, a `status` poll) shows
2017 /// only the finish. Live progress is the workbar's, not the transcript's.
2018 /// Transcript mode expands the finish card's phase/child detail.
2019 /// Status-list payloads keep a multi-run summary card.
2020 fn try_render_as_workflow(
2021 &self,
2022 width: u16,
2023 low_motion: bool,
2024 mode: RenderMode,
2025 locale: Locale,
2026 ) -> Option<Vec<Line<'static>>> {
2027 if self.name != "workflow" {
2028 return None;
2029 }
2030 let output = self.output.as_ref()?;
2031 let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
2032 // A call the runtime refused (invalid plan, unknown fleet) never
2033 // started a run. It says why — the error's first sentence, wrapped
2034 // under the header — instead of echoing `action: start`; the full
2035 // error and validation feedback stay behind the details key.
2036 if self.status != ToolStatus::Failed {
2037 return None;
2038 }
2039 let reason = workflow_call_error_reason(output)?;
2040 let family = crate::tui::widgets::tool_card::tool_family_for_name("workflow");
2041 let mut lines = vec![render_tool_header_with_family_and_summary(
2042 family,
2043 None,
2044 tool_status_label(self.status),
2045 self.status,
2046 None,
2047 low_motion,
2048 )];
2049 lines.extend(render_card_detail_line(
2050 None,
2051 &reason,
2052 tool_value_style(),
2053 width,
2054 ));
2055 if matches!(mode, RenderMode::Transcript) {
2056 lines.extend(render_tool_output_mode(
2057 output,
2058 width,
2059 TOOL_OUTPUT_LINE_LIMIT,
2060 mode,
2061 ));
2062 }
2063 return Some(wrap_card_rail(lines, self.status));
2064 };
2065 let is_status_list =
2066 value.get("action").and_then(serde_json::Value::as_str) == Some("status");
2067 if value.get("run_id").is_none() && !is_status_list {
2068 return None;
2069 }
2070 let family = crate::tui::widgets::tool_card::tool_family_for_name("workflow");
2071 let mut lines = Vec::new();
2072
2073 if is_status_list {
2074 let runs = value.get("runs").and_then(serde_json::Value::as_array);
2075 let count = value
2076 .get("count")
2077 .and_then(serde_json::Value::as_u64)
2078 .unwrap_or_else(|| runs.map(|r| r.len() as u64).unwrap_or(0));
2079 let header = format!("{count} run(s)");
2080 lines.push(render_tool_header_with_family_and_summary(
2081 family,
2082 Some(header.as_str()),
2083 tool_status_label(self.status),
2084 self.status,
2085 None,
2086 low_motion,
2087 ));
2088 if let Some(runs) = runs {
2089 for run in runs {
2090 let run_id = run
2091 .get("run_id")
2092 .and_then(serde_json::Value::as_str)
2093 .unwrap_or("?");
2094 let status = run
2095 .get("status")
2096 .and_then(serde_json::Value::as_str)
2097 .unwrap_or("?");
2098 let children = run
2099 .get("child_count")
2100 .and_then(serde_json::Value::as_u64)
2101 .or_else(|| {
2102 run.get("child_ids")
2103 .and_then(serde_json::Value::as_array)
2104 .map(|a| a.len() as u64)
2105 })
2106 .unwrap_or(0);
2107 lines.extend(render_card_detail_line(
2108 None,
2109 &format!("{run_id} · {status} · {children} child(ren)"),
2110 tool_value_style(),
2111 width,
2112 ));
2113 }
2114 }
2115 return Some(wrap_card_rail(lines, self.status));
2116 }
2117
2118 use crate::tui::widgets::workflow_panel::{WorkflowHistoryExtras, WorkflowPanel};
2119 let mut panel = WorkflowPanel::from_run_json(&value)?;
2120 panel.locale = locale;
2121 let finish_card = value
2122 .get("transcript_line")
2123 .and_then(serde_json::Value::as_str)
2124 == Some("finished");
2125 // A foreground `run` card returns the settled record itself, so it
2126 // carries its own finish; a detached start is finished by the card
2127 // `App::announce_settled_workflows` writes later.
2128 let owns_finish =
2129 finish_card || (self.status != ToolStatus::Running && panel.lifecycle.is_terminal());
2130 if !owns_finish {
2131 // The start line: the run's name. Its progress lives in the
2132 // workbar, never here. A card that already holds the settled
2133 // record shows only the finish: the final state replaces
2134 // `started`, one row per run.
2135 let name = panel.short_title();
2136 lines.push(render_tool_header_with_family_and_summary(
2137 family,
2138 Some(name.as_str()),
2139 codewhale_localization::tr(
2140 locale,
2141 codewhale_localization::MessageId::WorkflowLineStarted,
2142 )
2143 .as_ref(),
2144 if owns_finish {
2145 ToolStatus::Success
2146 } else {
2147 self.status
2148 },
2149 None,
2150 low_motion,
2151 ));
2152 }
2153 let finish = owns_finish.then(|| panel.finish_line()).flatten();
2154 let Some((state, facts, detail)) = finish else {
2155 return Some(wrap_card_rail(lines, self.status));
2156 };
2157 let finish_status = match panel.lifecycle {
2158 crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Succeeded => {
2159 ToolStatus::Success
2160 }
2161 crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Degraded => {
2162 ToolStatus::Warning
2163 }
2164 _ => ToolStatus::Failed,
2165 };
2166 lines.push(render_tool_header_with_family_and_summary(
2167 family,
2168 Some(facts.as_str()),
2169 &state,
2170 finish_status,
2171 None,
2172 low_motion,
2173 ));
2174 if let Some(detail) = detail {
2175 // Why a run fell short is the one fact worth reading: its first
2176 // sentence wraps instead of being cut. A success summary keeps to
2177 // one row, cut at a word boundary; the expand key shows the rest.
2178 let detail = if finish_status == ToolStatus::Failed {
2179 detail
2180 } else {
2181 let room = usize::from(width).saturating_sub(4).max(8);
2182 crate::tui::ui_text::semantic_truncate(&detail, room)
2183 };
2184 lines.extend(render_card_detail_line(
2185 None,
2186 &detail,
2187 tool_value_style(),
2188 width,
2189 ));
2190 }
2191 let expanded = matches!(mode, RenderMode::Transcript);
2192 if expanded {
2193 let extras = WorkflowHistoryExtras {
2194 result_summary: panel.result_summary.clone(),
2195 source_path: panel.source_path.clone().or_else(|| {
2196 value
2197 .get("source_path")
2198 .and_then(serde_json::Value::as_str)
2199 .map(std::path::PathBuf::from)
2200 }),
2201 spillover_path: self.spillover_path.clone(),
2202 verification_summary: value
2203 .get("verification")
2204 .and_then(|v| v.get("summary"))
2205 .and_then(serde_json::Value::as_str)
2206 .map(str::to_string),
2207 };
2208 for detail in panel.history_expanded_lines(width, &extras) {
2209 // history_expanded_lines omit the leading indent; re-use the
2210 // card detail path so rails and spacing stay consistent.
2211 let text: String = detail.spans.iter().map(|s| s.content.as_ref()).collect();
2212 lines.extend(render_card_detail_line(
2213 None,
2214 &text,
2215 tool_value_style(),
2216 width,
2217 ));
2218 }
2219 }
2220 Some(wrap_card_rail(lines, finish_status))
2221 }
2222 }
2223
2224 /// The first sentence of a refused `workflow` call's error, without the
2225 /// dispatcher's `Error: Invalid input for tool 'workflow':` preamble, which
2226 /// names the tool the card already names.
2227 fn workflow_call_error_reason(output: &str) -> Option<String> {
2228 let first = output
2229 .lines()
2230 .map(str::trim)
2231 .find(|line| !line.is_empty())?;
2232 let first = first.strip_prefix("Error:").map_or(first, str::trim_start);
2233 let first = if first.starts_with("Invalid input for tool") {
2234 first.split_once("': ").map_or(first, |(_, rest)| rest)
2235 } else {
2236 first
2237 };
2238 let reason = crate::tui::widgets::workflow_panel::first_sentence(first);
2239 (!reason.is_empty()).then_some(reason)
2240 }
2241
2242 /// Render the inline annotation for a tool cell whose full output was
2243 /// retained internally and replaced by a bounded preview. The annotation
2244 /// stays calm and path-free: it only says the output was shortened and that
2245 /// the details shortcut opens the full retained output.
2246 fn render_spillover_annotation(width: u16) -> Line<'static> {
2247 // Matches the model-facing preview footer (truncate.rs) and the existing
2248 // "Alt+V opens …" hint style (#3256): one quiet line, no handles or paths.
2249 let affordance = format!(
2250 "Output shortened — {}",
2251 crate::tui::key_shortcuts::tool_details_shortcut_action_hint("output")
2252 );
2253 Line::from(Span::styled(
2254 truncate_text(&affordance, usize::from(width).max(8)),
2255 Style::default().fg(palette::TEXT_MUTED).italic(),
2256 ))
2257 }
2258
2259 /// Detect a truncated-output preview: the current model-facing footer (which
2260 /// names the artifact path and recovery instruction), the previous plain
2261 /// footer, or the legacy receipt header still present in older saved
2262 /// sessions. Live cards collapse to the expand affordance for all of them.
2263 fn is_truncated_output_preview(content: &str) -> bool {
2264 content.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT)
2265 || content.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT)
2266 || content.trim_start().starts_with("[Exact evidence retained")
2267 }
2268
2269 fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec<Line<'static>> {
2270 let mut lines = Vec::new();
2271 let cap = match mode {
2272 RenderMode::Live => TOOL_COMMAND_LINE_LIMIT,
2273 RenderMode::Transcript => usize::MAX,
2274 };
2275 for (count, chunk) in wrap_text(command, width.saturating_sub(4).max(1) as usize)
2276 .into_iter()
2277 .enumerate()
2278 {
2279 if count >= cap {
2280 lines.push(details_affordance_line(
2281 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("command"),
2282 Style::default().fg(palette::TEXT_MUTED),
2283 ));
2284 break;
2285 }
2286 lines.extend(render_card_detail_line(
2287 if count == 0 { Some("command") } else { None },
2288 chunk.as_str(),
2289 tool_value_style(),
2290 width,
2291 ));
2292 }
2293 lines
2294 }
2295
2296 fn command_header_summary(command: &str) -> String {
2297 command
2298 .lines()
2299 .next()
2300 .unwrap_or(command)
2301 .trim_start_matches("$ ")
2302 .trim()
2303 .to_string()
2304 }
2305
2306 fn exploring_header_summary(entries: &[ExploringEntry]) -> Option<String> {
2307 match entries {
2308 [] => None,
2309 [entry] => Some(entry.label.clone()),
2310 entries => Some(format!("{} items", entries.len())),
2311 }
2312 }
2313
2314 /// Choose the verb family for an exploring card's header. A card whose entries
2315 /// are all searches reads with the `find` verb so the completed action agrees
2316 /// with its `Searching for …` labels (#4145); every other exploration mix keeps
2317 /// the neutral `read` verb the Workspace card uses. The search signal is the
2318 /// English label prefix produced by `exploring_label` in `tool_routing`.
2319 fn exploring_card_family(entries: &[ExploringEntry]) -> crate::tui::widgets::tool_card::ToolFamily {
2320 use crate::tui::widgets::tool_card::ToolFamily;
2321 let all_search = !entries.is_empty()
2322 && entries
2323 .iter()
2324 .all(|entry| entry.label.starts_with("Searching"));
2325 if all_search {
2326 ToolFamily::Find
2327 } else {
2328 ToolFamily::Read
2329 }
2330 }
2331
2332 fn render_compact_kv(label: &str, value: &str, style: Style, width: u16) -> Vec<Line<'static>> {
2333 render_card_detail_line(Some(label.trim_end_matches(':')), value, style, width)
2334 }
2335
2336 /// Wrap rendered tool-card lines with card-rail glyphs (╭ │ ╰).
2337 /// First non-empty line gets `╭`, middle lines get `│`, last line gets `╰`.
2338 /// Single-line cards get a single `─` prefix.
2339 ///
2340 /// The rail is the card's border, so it carries the cell's own state: it is
2341 /// painted with [`tool_rail_color`], OMP's border rule. A running card is
2342 /// bordered in the action accent, a failed one in the error colour, and a
2343 /// settled one recedes to muted — which is what makes a tool cell legible
2344 /// without a neighbouring row narrating what the card already shows. The
2345 /// header glyph inside it follows [`tool_glyph_color`] instead, so a finished
2346 /// card still says what it was.
2347 fn wrap_card_rail(mut lines: Vec<Line<'static>>, status: ToolStatus) -> Vec<Line<'static>> {
2348 let n = lines.len();
2349 if n == 0 {
2350 return lines;
2351 }
2352 let rail_style = Style::default().fg(tool_rail_color(status));
2353 if n == 1 {
2354 lines[0].spans.insert(0, Span::styled("─ ", rail_style));
2355 return lines;
2356 }
2357 for (i, line) in lines.iter_mut().enumerate() {
2358 let rail = if i == 0 {
2359 "\u{256D} " // ╭
2360 } else if i == n - 1 {
2361 "\u{2570} " // ╰
2362 } else {
2363 "\u{2502} " // │
2364 };
2365 line.spans.insert(0, Span::styled(rail, rail_style));
2366 }
2367 lines
2368 }
2369
2370 /// The legacy tool renderers accept only a low-motion boolean, which gives
2371 /// both Reduced and Still a mix of legacy static frames. Preserve that stable
2372 /// rendering path, then apply the central mode's exact fallback at the typed
2373 /// header span (never by rewriting user/tool output text).
2374 fn apply_static_tool_markers(lines: &mut [Line<'static>], marker: &'static str) {
2375 for line in lines {
2376 let mut index = 0;
2377 if line
2378 .spans
2379 .first()
2380 .is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ "))
2381 {
2382 index = 1;
2383 }
2384 let Some(status) = line.spans.get(index).map(|span| span.content.as_ref()) else {
2385 continue;
2386 };
2387 let Some(family) = line.spans.get(index + 1).map(|span| span.content.as_ref()) else {
2388 continue;
2389 };
2390 if !status.ends_with(' ')
2391 || !is_tool_status_glyph(status.trim_end())
2392 || !family.ends_with(' ')
2393 || !is_tool_family_glyph(family.trim_end())
2394 {
2395 continue;
2396 }
2397 let mut chars = status.trim_end().chars();
2398 if matches!(chars.next(), Some('\u{2800}'..='\u{28FF}')) && chars.next().is_none() {
2399 line.spans[index].content = format!("{marker} ").into();
2400 }
2401 }
2402 }
2403
2404 /// Return the width of tool-cell chrome that remains after the transcript
2405 /// cache removes the cell-local card rail. Tool headers have two additional
2406 /// visual tokens (`✓`/spinner and the family glyph); detail rows have the
2407 /// thin transcript rail. Keeping this width with the rendered line avoids
2408 /// making selection copy infer chrome from glyph ranges, which can consume
2409 /// real code or CJK text that happens to begin with a box-drawing character.
2410 fn tool_copy_prefix_width(line: &Line<'static>) -> usize {
2411 let spans = line.spans.as_slice();
2412 let mut index = 0;
2413
2414 // The cache removes these exact local card rails before flattening.
2415 if spans
2416 .first()
2417 .is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ "))
2418 {
2419 index = 1;
2420 }
2421
2422 // Detail rows and pager affordances use the transcript rail as their
2423 // first span. The live transcript's general rail accounting removes it;
2424 // do not report it again as cell-local copy chrome.
2425 if spans
2426 .get(index)
2427 .is_some_and(|span| span.content.as_ref() == TRANSCRIPT_RAIL)
2428 {
2429 return 0;
2430 }
2431
2432 // A tool header starts with `<status> <family> `. Only consume this
2433 // pair when both tokens are present, so output beginning with `✓` or a
2434 // braille character remains copyable content.
2435 let Some(status) = spans.get(index).map(|span| span.content.as_ref()) else {
2436 return 0;
2437 };
2438 let Some(family) = spans.get(index + 1).map(|span| span.content.as_ref()) else {
2439 return 0;
2440 };
2441 if !status.ends_with(' ')
2442 || !is_tool_status_glyph(status.trim_end())
2443 || !family.ends_with(' ')
2444 || !is_tool_family_glyph(family.trim_end())
2445 {
2446 return 0;
2447 }
2448
2449 UnicodeWidthStr::width(status) + UnicodeWidthStr::width(family)
2450 }
2451
2452 fn is_tool_status_glyph(text: &str) -> bool {
2453 let mut chars = text.chars();
2454 let Some(ch) = chars.next() else {
2455 return false;
2456 };
2457 chars.next().is_none()
2458 && matches!(
2459 ch,
2460 '\u{2713}' // ✓
2461 | '\u{2715}' // ✕
2462 | '\u{00B7}' // ·
2463 | '!' // terminal warning
2464 | '\u{203A}' // › static still-mode marker
2465 | '\u{2800}'..='\u{28FF}' // braille spinner frames
2466 )
2467 }
2468
2469 fn is_tool_family_glyph(text: &str) -> bool {
2470 use crate::tui::widgets::tool_card::{ToolFamily, family_glyph};
2471
2472 [
2473 ToolFamily::Read,
2474 ToolFamily::Patch,
2475 ToolFamily::Run,
2476 ToolFamily::Find,
2477 ToolFamily::Delegate,
2478 ToolFamily::Fanout,
2479 ToolFamily::Rlm,
2480 ToolFamily::Verify,
2481 ToolFamily::Think,
2482 ToolFamily::Generic,
2483 ]
2484 .into_iter()
2485 .any(|family| family_glyph(family) == text)
2486 }
2487
2488 fn review_severity_color(severity: &str) -> Color {
2489 match severity {
2490 "error" => palette::STATUS_ERROR,
2491 "warning" => palette::STATUS_WARNING,
2492 _ => palette::WHALE_ACTION,
2493 }
2494 }
2495
2496 fn format_review_location(path: Option<&String>, line: Option<u32>) -> String {
2497 let path = path.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
2498 match (path, line) {
2499 (Some(path), Some(line)) => format!("{path}:{line}"),
2500 (Some(path), None) => path,
2501 (None, Some(line)) => format!("line {line}"),
2502 (None, None) => String::new(),
2503 }
2504 }
2505
2506 /// Detect whether a system message is a cycle-boundary announcement
2507 /// (e.g. `─── cycle 0 → 1 (briefing: 2500 tokens) ───`).
2508 fn is_cycle_boundary(content: &str) -> bool {
2509 content.contains("cycle")
2510 }
2511
2512 /// Render a cycle-boundary system message with distinct visual styling (#395):
2513 /// full-width line with primary accent text and bold weight, plus a thin
2514 /// horizontal rule above for visual separation.
2515 fn render_cycle_boundary(content: &str, width: u16) -> Vec<Line<'static>> {
2516 let style = Style::default()
2517 .fg(palette::WHALE_ACTION)
2518 .add_modifier(Modifier::BOLD);
2519 let rule_style = Style::default().fg(palette::TEXT_DIM);
2520 let content_width = usize::from(width.saturating_sub(2).max(1));
2521 let mut lines = Vec::new();
2522 // Thin horizontal rule above for visual separation
2523 if width >= 4 {
2524 let rule = "\u{2500}".repeat(content_width);
2525 lines.push(Line::from(Span::styled(format!(" {rule}"), rule_style)));
2526 }
2527 // Cycle boundary text — just the content, full-width
2528 let rendered =
2529 crate::tui::markdown_render::render_markdown(content, content_width as u16, style);
2530 for line in rendered {
2531 let mut spans = vec![Span::raw(" ")];
2532 spans.extend(line.spans);
2533 lines.push(Line::from(spans));
2534 }
2535 if lines.len() == 1 && width >= 4 {
2536 // Only the rule was added (unlikely), but add at least a spacer
2537 lines.push(Line::from(""));
2538 }
2539 lines
2540 }
2541
2542 fn status_symbol(
2543 started_at: Option<Instant>,
2544 status: ToolStatus,
2545 low_motion: bool,
2546 family: crate::tui::widgets::tool_card::ToolFamily,
2547 ) -> String {
2548 match status {
2549 ToolStatus::Running if family == crate::tui::widgets::tool_card::ToolFamily::Verify => {
2550 crate::tui::spinner::verification_tick_frame(started_at, low_motion).to_string()
2551 }
2552 ToolStatus::Running => {
2553 crate::tui::spinner::braille_spinner_frame(started_at, low_motion).to_string()
2554 }
2555 ToolStatus::Success | ToolStatus::Hydrated => TOOL_DONE_SYMBOL.to_string(),
2556 ToolStatus::Warning => "!".to_string(),
2557 ToolStatus::Failed => TOOL_FAILED_SYMBOL.to_string(),
2558 }
2559 }
2560
2561 fn details_affordance_line(text: &str, style: Style) -> Line<'static> {
2562 Line::from(vec![
2563 Span::styled(
2564 TRANSCRIPT_RAIL.to_string(),
2565 Style::default().fg(palette::TEXT_DIM),
2566 ),
2567 Span::styled(text.to_string(), style),
2568 ])
2569 }
2570
2571 /// Truncate to `max_len` display cells, cutting only at grapheme-cluster
2572 /// boundaries. Counting chars let CJK text overflow a width budget and could
2573 /// split a ZWJ or combining sequence (U04-m5); ASCII output is unchanged.
2574 fn truncate_text(text: &str, max_len: usize) -> String {
2575 use unicode_segmentation::UnicodeSegmentation;
2576 if crate::tui::ui_text::text_display_width(text) <= max_len {
2577 return text.to_string();
2578 }
2579 let budget = max_len.saturating_sub(3);
2580 let mut out = String::new();
2581 let mut used = 0usize;
2582 for grapheme in text.graphemes(true) {
2583 let width = crate::tui::ui_text::text_display_width(grapheme);
2584 if used + width > budget {
2585 break;
2586 }
2587 used += width;
2588 out.push_str(grapheme);
2589 }
2590 out.push_str("...");
2591 out
2592 }
2593
2594 /// Label glyph for an error cell. `Critical`/`Error` get the loudest marker;
2595 /// `Warning` is softer; `Info` is neutral. Kept as ASCII so it survives any
2596 /// terminal font fallback.
2597 fn error_label_text(severity: crate::error_taxonomy::ErrorSeverity) -> &'static str {
2598 match severity {
2599 crate::error_taxonomy::ErrorSeverity::Critical
2600 | crate::error_taxonomy::ErrorSeverity::Error => "Error",
2601 crate::error_taxonomy::ErrorSeverity::Warning => "Warn",
2602 crate::error_taxonomy::ErrorSeverity::Info => "Info",
2603 }
2604 }
2605
2606 /// Label color for an error cell — drives the leading rail glyph.
2607 fn error_label_style(severity: crate::error_taxonomy::ErrorSeverity) -> Style {
2608 let color = match severity {
2609 crate::error_taxonomy::ErrorSeverity::Critical
2610 | crate::error_taxonomy::ErrorSeverity::Error => palette::STATUS_ERROR,
2611 crate::error_taxonomy::ErrorSeverity::Warning => palette::STATUS_WARNING,
2612 crate::error_taxonomy::ErrorSeverity::Info => palette::TEXT_DIM,
2613 };
2614 Style::default().fg(color).add_modifier(Modifier::BOLD)
2615 }
2616
2617 /// Body color for an error cell — softer than the label so the rail draws
2618 /// the eye but the prose stays readable.
2619 fn error_body_style(severity: crate::error_taxonomy::ErrorSeverity) -> Style {
2620 let color = match severity {
2621 crate::error_taxonomy::ErrorSeverity::Critical
2622 | crate::error_taxonomy::ErrorSeverity::Error => palette::STATUS_ERROR,
2623 crate::error_taxonomy::ErrorSeverity::Warning => palette::STATUS_WARNING,
2624 crate::error_taxonomy::ErrorSeverity::Info => palette::TEXT_MUTED,
2625 };
2626 Style::default().fg(color)
2627 }
2628
2629 /// Render an engine error without markdown interpretation. The live transcript
2630 /// always advertises the dedicated full-error pager: terminal height, scroll
2631 /// position, and adjacent tool cards can otherwise make a multiline recovery
2632 /// instruction look like a clipped one-line failure. Transcript/pager mode
2633 /// omits the recursive affordance while preserving every character.
2634 fn render_error_message(
2635 message: &str,
2636 severity: crate::error_taxonomy::ErrorSeverity,
2637 width: u16,
2638 show_full_error_affordance: bool,
2639 ) -> Vec<Line<'static>> {
2640 // Error messages are machine-generated and should not be run through
2641 // markdown rendering, which would mangle env-var names containing
2642 // underscores (e.g. CODEWHALE_ALLOW_INSECURE_HTTP would lose them as
2643 // italic markers).
2644 let label = error_label_text(severity);
2645 let label_style = error_label_style(severity);
2646 let body_style = error_body_style(severity);
2647 let prefix_width = UnicodeWidthStr::width(label);
2648 let content_width = width.saturating_sub(2 + prefix_width as u16).max(1);
2649 let mut lines: Vec<_> = message
2650 .split('\n')
2651 .flat_map(|line| wrap_plain_line(line, body_style, content_width))
2652 .collect();
2653 if let Some(first) = lines.get_mut(0) {
2654 first.spans.insert(0, Span::raw(" "));
2655 first.spans.insert(0, Span::styled(label, label_style));
2656 }
2657 let rail = format!("{}{}", '\u{258F}', " ".repeat(prefix_width));
2658 let rail_style = Style::default().fg(palette::TEXT_DIM);
2659 for line in lines.iter_mut().skip(1) {
2660 line.spans.insert(0, Span::styled(rail.clone(), rail_style));
2661 }
2662 if show_full_error_affordance {
2663 lines.push(details_affordance_line(
2664 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("full error"),
2665 Style::default().fg(palette::TEXT_MUTED).italic(),
2666 ));
2667 }
2668 lines
2669 }
2670
2671 fn render_tool_header(
2672 title: &str,
2673 state: &str,
2674 status: ToolStatus,
2675 started_at: Option<Instant>,
2676 low_motion: bool,
2677 ) -> Line<'static> {
2678 let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
2679 render_tool_header_with_family(family, state, status, started_at, low_motion)
2680 }
2681
2682 fn render_tool_header_with_summary(
2683 title: &str,
2684 summary: Option<&str>,
2685 state: &str,
2686 status: ToolStatus,
2687 started_at: Option<Instant>,
2688 low_motion: bool,
2689 ) -> Line<'static> {
2690 let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
2691 render_tool_header_with_family_and_summary(
2692 family, summary, state, status, started_at, low_motion,
2693 )
2694 }
2695
2696 /// Render a tool-card header with an explicit verb family. Lets callers
2697 /// (e.g. `GenericToolCell`) bypass the legacy title→family mapping when
2698 /// they already know the actual tool name.
2699 fn render_tool_header_with_family(
2700 family: crate::tui::widgets::tool_card::ToolFamily,
2701 state: &str,
2702 status: ToolStatus,
2703 started_at: Option<Instant>,
2704 low_motion: bool,
2705 ) -> Line<'static> {
2706 render_tool_header_with_family_and_summary(family, None, state, status, started_at, low_motion)
2707 }
2708
2709 fn render_tool_header_with_family_and_summary(
2710 family: crate::tui::widgets::tool_card::ToolFamily,
2711 summary: Option<&str>,
2712 state: &str,
2713 status: ToolStatus,
2714 started_at: Option<Instant>,
2715 low_motion: bool,
2716 ) -> Line<'static> {
2717 // For long-running tools, append elapsed seconds so the user can see the
2718 // call isn't stuck. Threshold matches the eye's "did this hang?" reflex
2719 // — under 3s we stay quiet so quick reads/greps don't visually churn.
2720 let state_owned: String = if state == "running"
2721 && status == ToolStatus::Running
2722 && let Some(started) = started_at
2723 {
2724 running_status_label_with_elapsed(started.elapsed().as_secs())
2725 } else {
2726 state.to_string()
2727 };
2728
2729 let glyph = crate::tui::widgets::tool_card::family_glyph(family);
2730 let verb = crate::tui::widgets::tool_card::family_label(family);
2731
2732 let glyph_style = Style::default().fg(tool_glyph_color(status, family));
2733 let mut spans = vec![
2734 Span::styled(
2735 format!("{} ", status_symbol(started_at, status, low_motion, family)),
2736 glyph_style,
2737 ),
2738 Span::styled(format!("{glyph} "), glyph_style),
2739 Span::styled(verb.to_string(), tool_title_style(status)),
2740 Span::styled(" ", Style::default()),
2741 Span::styled(state_owned, tool_status_style(status, family)),
2742 ];
2743
2744 // #4148: don't let the summary echo the verb it sits next to — an
2745 // identity/summary that resolves to the family word itself would render a
2746 // duplicate like "delegate · delegate". When the summary collapses to the
2747 // verb, the verb already carries the signal, so drop the redundant tail.
2748 if let Some(summary) = summary
2749 .and_then(normalize_header_summary)
2750 .filter(|summary| !summary.eq_ignore_ascii_case(verb))
2751 {
2752 spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
2753 spans.push(Span::styled(
2754 truncate_text(&summary, TOOL_HEADER_SUMMARY_LIMIT),
2755 Style::default().fg(palette::TEXT_MUTED),
2756 ));
2757 }
2758
2759 Line::from(spans)
2760 }
2761
2762 fn normalize_header_summary(summary: &str) -> Option<String> {
2763 let normalized = summary
2764 .split_whitespace()
2765 .collect::<Vec<_>>()
2766 .join(" ")
2767 .trim()
2768 .to_string();
2769 if normalized.is_empty() {
2770 None
2771 } else {
2772 Some(normalized)
2773 }
2774 }
2775
2776 /// Build the "running" label with an elapsed-seconds badge for long-running
2777 /// tools. Below 3s the badge is suppressed to avoid visual churn for tools
2778 /// that resolve in milliseconds; at 3s and beyond the badge appears and ticks
2779 /// every second the tool stays in flight.
2780 pub(crate) fn running_status_label_with_elapsed(elapsed_secs: u64) -> String {
2781 if elapsed_secs < 3 {
2782 "running".to_string()
2783 } else {
2784 format!("running ({elapsed_secs}s)")
2785 }
2786 }
2787
2788 pub(crate) fn stale_shell_status_label(elapsed_since_output_ms: u64) -> String {
2789 format!(
2790 "running · stale · no output {}",
2791 crate::elapsed::format_elapsed_ms(elapsed_since_output_ms)
2792 )
2793 }
2794
2795 fn render_card_detail_line(
2796 label: Option<&str>,
2797 value: &str,
2798 value_style: Style,
2799 width: u16,
2800 ) -> Vec<Line<'static>> {
2801 let label_text = label.map(|text| format!("{text}:"));
2802 let prefix_width = UnicodeWidthStr::width(TRANSCRIPT_RAIL)
2803 + label_text.as_deref().map_or(0, UnicodeWidthStr::width)
2804 + usize::from(label.is_some());
2805 let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
2806
2807 let mut lines = Vec::new();
2808 for (idx, part) in wrap_text(value, content_width).into_iter().enumerate() {
2809 let mut spans = vec![Span::styled(
2810 TRANSCRIPT_RAIL.to_string(),
2811 Style::default().fg(palette::TEXT_DIM),
2812 )];
2813 if idx == 0 {
2814 if let Some(label_text) = label_text.as_deref() {
2815 spans.push(Span::styled(
2816 label_text.to_string(),
2817 tool_detail_label_style(),
2818 ));
2819 spans.push(Span::raw(" "));
2820 }
2821 } else if let Some(label_text) = label_text.as_deref() {
2822 spans.push(Span::raw(
2823 " ".repeat(UnicodeWidthStr::width(label_text) + 1),
2824 ));
2825 }
2826 spans.push(Span::styled(part, value_style));
2827 lines.push(Line::from(spans));
2828 }
2829 lines
2830 }
2831
2832 /// `render_card_detail_line` for a row whose text carries its own SGR
2833 /// colours: every segment's style is patched over `value_style`, so the
2834 /// tool's colour wins where it set one and the cell's own ink (dim, state,
2835 /// file:line emphasis) shows through where it did not. `text` is the row's
2836 /// plain text (what the segments concatenate to); wrapping follows the same
2837 /// boundaries as the plain path.
2838 fn render_card_detail_line_styled(
2839 label: Option<&str>,
2840 text: &str,
2841 segments: &[tool_output::StyledSegment],
2842 value_style: Style,
2843 width: u16,
2844 ) -> Vec<Line<'static>> {
2845 let label_text = label.map(|text| format!("{text}:"));
2846 let prefix_width = UnicodeWidthStr::width(TRANSCRIPT_RAIL)
2847 + label_text.as_deref().map_or(0, UnicodeWidthStr::width)
2848 + usize::from(label.is_some());
2849 let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
2850
2851 let parts = wrap_text(text, content_width);
2852 let split = tool_output::split_segments(segments, &parts);
2853
2854 let mut lines = Vec::new();
2855 for (idx, part) in split.into_iter().enumerate() {
2856 let mut spans = vec![Span::styled(
2857 TRANSCRIPT_RAIL.to_string(),
2858 Style::default().fg(palette::TEXT_DIM),
2859 )];
2860 if idx == 0 {
2861 if let Some(label_text) = label_text.as_deref() {
2862 spans.push(Span::styled(
2863 label_text.to_string(),
2864 tool_detail_label_style(),
2865 ));
2866 spans.push(Span::raw(" "));
2867 }
2868 } else if let Some(label_text) = label_text.as_deref() {
2869 spans.push(Span::raw(
2870 " ".repeat(UnicodeWidthStr::width(label_text) + 1),
2871 ));
2872 }
2873 for (text, style) in part {
2874 spans.push(Span::styled(text, value_style.patch(style)));
2875 }
2876 lines.push(Line::from(spans));
2877 }
2878 lines
2879 }
2880
2881 /// `render_card_detail_line_single` for a coloured row: one line, never
2882 /// wrapped, so an intact path or URL keeps the same hitbox with or without
2883 /// colour.
2884 fn render_card_detail_line_single_styled(
2885 label: Option<&str>,
2886 segments: &[tool_output::StyledSegment],
2887 value_style: Style,
2888 ) -> Line<'static> {
2889 let mut line = render_card_detail_line_single(label, "", value_style);
2890 line.spans.pop();
2891 for (text, style) in segments {
2892 line.spans
2893 .push(Span::styled(text.clone(), value_style.patch(*style)));
2894 }
2895 line
2896 }
2897
2898 fn render_card_detail_line_single(
2899 label: Option<&str>,
2900 value: &str,
2901 value_style: Style,
2902 ) -> Line<'static> {
2903 let label_text = label.map(|text| format!("{text}:"));
2904 let mut spans = vec![Span::styled(
2905 TRANSCRIPT_RAIL.to_string(),
2906 Style::default().fg(palette::TEXT_DIM),
2907 )];
2908 if let Some(label_text) = label_text {
2909 spans.push(Span::styled(label_text, tool_detail_label_style()));
2910 spans.push(Span::raw(" "));
2911 }
2912 spans.push(Span::styled(value.to_string(), value_style));
2913 Line::from(spans)
2914 }
2915
2916 // Tool-card ink. The transcript paints tool cards with the dark whale tokens
2917 // regardless of the selected theme, as every other cell in this file does by
2918 // reading the same `palette` constants directly.
2919
2920 fn tool_title_style(status: ToolStatus) -> Style {
2921 if status == ToolStatus::Success {
2922 Style::default().fg(palette::TEXT_MUTED)
2923 } else {
2924 Style::default()
2925 .fg(palette::TEXT_SOFT)
2926 .add_modifier(Modifier::BOLD)
2927 }
2928 }
2929
2930 /// Right-side status text ("running", "done", "issue"). Reads as the glyph it
2931 /// sits beside, not as the rail.
2932 fn tool_status_style(
2933 status: ToolStatus,
2934 family: crate::tui::widgets::tool_card::ToolFamily,
2935 ) -> Style {
2936 Style::default().fg(tool_glyph_color(status, family))
2937 }
2938
2939 /// Detail label style ("command:", "time:", step markers).
2940 fn tool_detail_label_style() -> Style {
2941 Style::default().fg(palette::TEXT_DIM)
2942 }
2943
2944 /// Colour of a tool cell's **rail** — the card border.
2945 ///
2946 /// This is OMP's `output-block.ts` rule verbatim: a block takes a state and
2947 /// its border colour follows it. In-flight takes the action accent, a settled
2948 /// success recedes into muted text so finished work stops competing for the
2949 /// eye, and only warning and failure keep a loud colour. `Hydrated` is a
2950 /// stalled "tool loaded — retry required", not live work, so it takes the hint
2951 /// colour instead of borrowing the running accent and reading as in-flight.
2952 ///
2953 /// The glyph and status word use the same lifecycle ink; the glyph shape
2954 /// preserves the tool's identity after its successful result settles.
2955 fn tool_rail_color(status: ToolStatus) -> Color {
2956 match status {
2957 ToolStatus::Running => palette::WHALE_ACTION,
2958 ToolStatus::Success => palette::TEXT_MUTED,
2959 ToolStatus::Hydrated => palette::TEXT_DIM,
2960 ToolStatus::Warning => palette::WHALE_HUMAN,
2961 ToolStatus::Failed => palette::WHALE_ERROR,
2962 }
2963 }
2964
2965 /// Settled success recedes; warning and failure retain their attention ink.
2966 fn tool_glyph_color(
2967 status: ToolStatus,
2968 _family: crate::tui::widgets::tool_card::ToolFamily,
2969 ) -> Color {
2970 tool_rail_color(status)
2971 }
2972
2973 fn tool_status_label(status: ToolStatus) -> &'static str {
2974 match status {
2975 ToolStatus::Running => "running",
2976 ToolStatus::Success => "done",
2977 ToolStatus::Hydrated => "tool loaded - retry required",
2978 ToolStatus::Warning => "issue",
2979 ToolStatus::Failed => "issue",
2980 }
2981 }
2982
2983 /// A finished read/find card can truthfully name how many rendered lines came
2984 /// back. Generic command output does not preserve typed stdout/stderr streams,
2985 /// so Run receipts stay at localized `done` instead of inventing per-stream
2986 /// counts from display text.
2987 pub(crate) fn tool_receipt_label(
2988 family: crate::tui::widgets::tool_card::ToolFamily,
2989 status: ToolStatus,
2990 output: Option<&str>,
2991 locale: Locale,
2992 ) -> Cow<'static, str> {
2993 if status != ToolStatus::Success {
2994 return Cow::Borrowed(tool_status_label(status));
2995 }
2996 use crate::tui::widgets::tool_card::ToolFamily;
2997 match family {
2998 ToolFamily::Read | ToolFamily::Find => {
2999 let lines = output.map(count_output_lines).unwrap_or(0);
3000 if lines == 0 {
3001 codewhale_localization::tr(
3002 locale,
3003 codewhale_localization::MessageId::ToolReceiptDone,
3004 )
3005 } else if lines == 1 {
3006 codewhale_localization::tr(
3007 locale,
3008 codewhale_localization::MessageId::ToolReceiptLinesSingular,
3009 )
3010 } else {
3011 Cow::Owned(
3012 codewhale_localization::tr(
3013 locale,
3014 codewhale_localization::MessageId::ToolReceiptLinesPlural,
3015 )
3016 .replace("{count}", &lines.to_string()),
3017 )
3018 }
3019 }
3020 ToolFamily::Run => {
3021 codewhale_localization::tr(locale, codewhale_localization::MessageId::ToolReceiptDone)
3022 }
3023 _ => codewhale_localization::tr(locale, codewhale_localization::MessageId::ToolReceiptDone),
3024 }
3025 }
3026
3027 fn count_output_lines(output: &str) -> usize {
3028 if output.is_empty() {
3029 0
3030 } else {
3031 output.lines().count()
3032 }
3033 }
3034
3035 /// Default value style for tool detail rows.
3036 fn tool_value_style() -> Style {
3037 Style::default().fg(palette::TEXT_MUTED)
3038 }
3039
3040 /// Find the first `path:line` reference in a rendered cell.
3041 ///
3042 /// Pure: it resolves and stats candidate paths but never launches anything.
3043 /// Spawning the editor belongs to `external_editor`, which owns the terminal
3044 /// handoff — this used to build its own `Command` and `spawn()` it detached
3045 /// while the TUI still held raw mode, the alt screen and mouse capture, and it
3046 /// did that once per matching line, so one click could leave N editors fighting
3047 /// the TUI for the same tty (#6235).
3048 ///
3049 /// Returns the first match rather than every match: a click is one request to
3050 /// open one file.
3051 pub(crate) fn first_file_line_reference(text: &str, workspace: &Path) -> Option<(PathBuf, u32)> {
3052 text.lines()
3053 .find_map(|line| file_line_reference(line, workspace))
3054 }
3055
3056 /// The first `path:line` on one line of text that names a file inside the
3057 /// workspace, as `(absolute path, line)`.
3058 ///
3059 /// Accepts the forms tools and models print: `src/a.rs:12`, `src/a.rs:12:5`,
3060 /// `--> src/a.rs:12:5` (rustc), `` `src/a.rs:12` `` and a reference inside a
3061 /// sentence or brackets. The text is model output, so it is not trusted to
3062 /// name a file: every candidate goes through [`workspace_file`], which
3063 /// refuses `..`, absolute paths outside the workspace and links.
3064 pub(crate) fn file_line_reference(line: &str, workspace: &Path) -> Option<(PathBuf, u32)> {
3065 line.split_whitespace().find_map(|token| {
3066 // Leading `.` stays: `./src/a.rs` is relative, not `/src/a.rs`.
3067 let token = token
3068 .trim_start_matches(['`', '\'', '"', '(', '[', '<'])
3069 .trim_end_matches(['`', '\'', '"', ')', ']', '>', ',', ';', '.']);
3070 let (path_str, line_no) = split_path_line(token)?;
3071 if !looks_like_file_path(path_str) {
3072 return None;
3073 }
3074 let path_str = path_str.strip_prefix("./").unwrap_or(path_str);
3075 workspace_file(workspace, path_str).map(|absolute| (absolute, line_no))
3076 })
3077 }
3078
3079 /// The regular file `raw` names inside `workspace`, as
3080 /// `workspace.join(relative)`, or `None`.
3081 ///
3082 /// [`crate::snapshot::workspace_relative_path`] checks only the text, and
3083 /// `is_file()` follows links, so a link inside the workspace (`vendor -> /`,
3084 /// `notes.md -> ~/.ssh/config`) used to pass. Each part below the workspace
3085 /// is read with `symlink_metadata` and a link is refused, as
3086 /// `runtime_api::workspace::confined_directory` does, and the resolved path
3087 /// must still sit under the resolved workspace. Callers that act later check
3088 /// again at that point: this is a check at one moment, not a lock.
3089 pub(crate) fn workspace_file(workspace: &Path, raw: &str) -> Option<PathBuf> {
3090 let relative = crate::snapshot::workspace_relative_path(workspace, raw)?;
3091 let mut path = workspace.to_path_buf();
3092 let mut is_file = false;
3093 for component in relative.components() {
3094 path.push(component);
3095 let metadata = std::fs::symlink_metadata(&path).ok()?;
3096 if crate::plugins::metadata_is_link_or_reparse(&metadata) {
3097 return None;
3098 }
3099 is_file = metadata.is_file();
3100 }
3101 let resolved_workspace = workspace.canonicalize().ok()?;
3102 let inside = path.canonicalize().ok()?.starts_with(&resolved_workspace);
3103 (is_file && inside).then_some(path)
3104 }
3105
3106 /// Split `path:N`, `path:N:C` or `path:N:text` at the first all-digit
3107 /// segment after the path. Earlier colons stay in the path (`C:\x.rs:3`).
3108 fn split_path_line(token: &str) -> Option<(&str, u32)> {
3109 let mut offset = 0;
3110 for (index, part) in token.split(':').enumerate() {
3111 if index > 0 && !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()) {
3112 let path = token[..offset].strip_suffix(':')?;
3113 let line = part.parse().ok().filter(|line| *line > 0)?;
3114 return (!path.is_empty()).then_some((path, line));
3115 }
3116 offset += part.len() + 1;
3117 }
3118 None
3119 }
3120
3121 /// Heuristic check whether a string looks like a file path (contains a
3122 /// directory separator or a known source file extension).
3123 fn looks_like_file_path(s: &str) -> bool {
3124 if s.contains('/') || s.contains('\\') {
3125 return true;
3126 }
3127 // Check for a known file extension
3128 if let Some((_, ext)) = s.rsplit_once('.') {
3129 let ext = ext.trim();
3130 matches!(
3131 ext,
3132 "rs" | "toml"
3133 | "md"
3134 | "sh"
3135 | "py"
3136 | "js"
3137 | "ts"
3138 | "json"
3139 | "yaml"
3140 | "yml"
3141 | "css"
3142 | "html"
3143 | "go"
3144 | "c"
3145 | "h"
3146 | "cpp"
3147 | "hpp"
3148 | "java"
3149 | "kt"
3150 | "swift"
3151 | "rb"
3152 | "php"
3153 | "lua"
3154 | "zig"
3155 | "mod"
3156 | "sum"
3157 | "lock"
3158 | "txt"
3159 | "ini"
3160 | "cfg"
3161 | "conf"
3162 | "env"
3163 | "gitignore"
3164 | "dockerfile"
3165 | "sql"
3166 | "r"
3167 | "ex"
3168 | "exs"
3169 | "vue"
3170 | "svelte"
3171 | "tsx"
3172 | "jsx"
3173 | "scss"
3174 | "sass"
3175 | "less"
3176 | "gradle"
3177 | "properties"
3178 | "xml"
3179 | "proto"
3180 | "nix"
3181 )
3182 } else {
3183 false
3184 }
3185 }
3186
3187 /// Aggregated file activity for compact Work panel display (#4636).
3188 #[derive(Debug, Clone, Default, PartialEq, Eq)]
3189 pub struct FileActivitySummary {
3190 pub files_read: u32,
3191 pub dirs_listed: u32,
3192 pub patterns_searched: u32,
3193 pub files_written: u32,
3194 }
3195
3196 impl FileActivitySummary {
3197 pub fn is_empty(&self) -> bool {
3198 self.files_read == 0
3199 && self.dirs_listed == 0
3200 && self.patterns_searched == 0
3201 && self.files_written == 0
3202 }
3203
3204 pub fn compact_display(&self) -> Vec<String> {
3205 let mut parts = Vec::new();
3206 if self.files_read > 0 {
3207 parts.push(format!("Read {} files", self.files_read));
3208 }
3209 if self.dirs_listed > 0 {
3210 parts.push(format!("Listed {} directories", self.dirs_listed));
3211 }
3212 if self.patterns_searched > 0 {
3213 parts.push(format!("Searched {} patterns", self.patterns_searched));
3214 }
3215 if self.files_written > 0 {
3216 parts.push(format!("Wrote {} files", self.files_written));
3217 }
3218 parts
3219 }
3220
3221 pub fn from_tool_name(name: &str) -> Option<FileActivityKind> {
3222 match name {
3223 "read_file" | "Read" | "read" => Some(FileActivityKind::Read),
3224 "list_dir" | "list_directory" | "Glob" | "glob" => Some(FileActivityKind::List),
3225 "search" | "grep" | "Grep" | "grep_files" | "file_search" | "codebase_search" => {
3226 Some(FileActivityKind::Search)
3227 }
3228 "write_file" | "Write" | "apply_patch" | "Edit" | "edit_file" | "fim_edit" => {
3229 Some(FileActivityKind::Write)
3230 }
3231 _ => None,
3232 }
3233 }
3234 }
3235
3236 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3237 pub enum FileActivityKind {
3238 Read,
3239 List,
3240 Search,
3241 Write,
3242 }
3243
3244 impl FileActivitySummary {
3245 pub fn record(&mut self, kind: FileActivityKind) {
3246 match kind {
3247 FileActivityKind::Read => self.files_read += 1,
3248 FileActivityKind::List => self.dirs_listed += 1,
3249 FileActivityKind::Search => self.patterns_searched += 1,
3250 FileActivityKind::Write => self.files_written += 1,
3251 }
3252 }
3253 }
3254
3255 /// Illuminate the newest graphemes of an actively streaming assistant line.
3256 fn apply_hot_tail_to_last_line(lines: &mut [Line<'static>], low_motion: bool) {
3257 if let Some(last) = lines.last_mut() {
3258 apply_hot_tail_to_line(last, low_motion);
3259 }
3260 }
3261
3262 pub(crate) fn apply_hot_tail_to_line(line: &mut Line<'static>, low_motion: bool) {
3263 if line.spans.is_empty() {
3264 return;
3265 }
3266 // Reconstruct plain text from spans, split hot tail, re-style.
3267 let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
3268 if plain.trim().is_empty() {
3269 return;
3270 }
3271 let (_settled, hot) = crate::tui::hot_tail::split_hot_tail(
3272 &plain,
3273 true,
3274 crate::tui::hot_tail::HOT_TAIL_GRAPHEMES,
3275 );
3276 if hot.is_empty() {
3277 return;
3278 }
3279 let hot_start = plain.len().saturating_sub(hot.len());
3280 let elapsed = std::time::SystemTime::now()
3281 .duration_since(std::time::UNIX_EPOCH)
3282 .map(|d| d.as_millis())
3283 .unwrap_or(0);
3284 let base_fg = palette::TEXT_PRIMARY;
3285 let hot_style = crate::tui::hot_tail::hot_tail_style(base_fg, elapsed, low_motion);
3286
3287 // Walk spans and re-style the trailing hot graphemes.
3288 let mut cursor = 0usize;
3289 let mut new_spans = Vec::with_capacity(line.spans.len() + 2);
3290 for span in line.spans.drain(..) {
3291 let content = span.content.to_string();
3292 let len = content.len();
3293 let span_end = cursor + len;
3294 if span_end <= hot_start {
3295 new_spans.push(span);
3296 } else if cursor >= hot_start {
3297 new_spans.push(Span::styled(content, hot_style));
3298 } else {
3299 // Split this span across the boundary.
3300 let local = hot_start - cursor;
3301 let (left, right) = content.split_at(local.min(content.len()));
3302 if !left.is_empty() {
3303 new_spans.push(Span::styled(left.to_string(), span.style));
3304 }
3305 if !right.is_empty() {
3306 new_spans.push(Span::styled(right.to_string(), hot_style));
3307 }
3308 }
3309 cursor = span_end;
3310 }
3311 line.spans = new_spans;
3312 }
3313
3314 #[cfg(test)]
3315 mod tests;
3316
3316 lines RUST