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