返回 DeepSeek-TUI-2026
history.rs
根目录 / crates / tui / src / tui / history.rs
1 //! TUI rendering helpers for chat history and tool output.
2
3 use std::path::{Path, PathBuf};
4 use std::time::Instant;
5
6 use ratatui::style::{Color, Modifier, Style, Stylize};
7 use ratatui::text::{Line, Span};
8 use serde_json::Value;
9 use unicode_width::UnicodeWidthStr;
10
11 use crate::deepseek_theme::active_theme;
12 use crate::models::{ContentBlock, Message};
13 use crate::palette;
14 use crate::tools::review::ReviewOutput;
15 use crate::tui::app::TranscriptSpacing;
16 use crate::tui::diff_render;
17 use crate::tui::markdown_render;
18
19 // === Constants ===
20
21 use std::process::Command;
22 const TOOL_COMMAND_LINE_LIMIT: usize = 3;
23 const TOOL_OUTPUT_LINE_LIMIT: usize = 6;
24 const TOOL_TEXT_LIMIT: usize = 180;
25 const TOOL_HEADER_SUMMARY_LIMIT: usize = 56;
26 const TOOL_OUTPUT_HEAD_LINES: usize = 2;
27 const TOOL_OUTPUT_TAIL_LINES: usize = 2;
28 const TOOL_RUNNING_SYMBOLS: [&str; 4] = ["·", "◦", "•", "◦"];
29 // Spinner cadence per glyph. The status-animation tick (UI_STATUS_ANIMATION_MS
30 // = 360 ms) fires every two glyphs, so a full 4-glyph "heartbeat" lands in
31 // ~2.88 s — fast enough that the user sees motion within a few hundred ms of
32 // starting a tool, slow enough to read as a pulse rather than a strobe.
33 const TOOL_STATUS_SYMBOL_MS: u64 = 720;
34 /// Visual marker for the user role at the start of their message line. Solid
35 /// vertical bar — no animation; user input is a finished thing.
36 const USER_GLYPH: &str = "\u{258E}"; // ▎
37 /// Visual marker for the assistant role. Solid bullet that pulses at 2s
38 /// cycle while the response is streaming, holds full brightness when idle.
39 const ASSISTANT_GLYPH: &str = "\u{25CF}"; // ●
40 /// Transcript body left rail. Solid 1/8 block (`▏`) followed by a space —
41 /// used as a visual left-margin anchor for continuation lines, tool-card
42 /// detail rows, and affordance lines. Dimmed so it guides the eye without
43 /// competing with content.
44 const TRANSCRIPT_RAIL: &str = "\u{258F} "; // ▏ + space
45 /// Reasoning header opener. Replaces the spinner glyph on thinking cells —
46 /// reasoning is a slow exhale, not a tool spin.
47 const REASONING_OPENER: &str = "\u{2026}"; // …
48 /// Reasoning body left rail. Dashed (`╎`) instead of the solid `▏` block to
49 /// visually separate reasoning from message body and tool output.
50 const REASONING_RAIL: &str = "\u{254E} "; // ╎ + space
51 /// Trailing-line cursor on streaming reasoning. Anchored to the live colour
52 /// so the user sees where new tokens land.
53 const REASONING_CURSOR: &str = "\u{258E}"; // ▎
54 const TOOL_CARD_SUMMARY_LINES: usize = 4;
55 const THINKING_SUMMARY_LINE_LIMIT: usize = 4;
56 const TOOL_DONE_SYMBOL: &str = "•";
57 const TOOL_FAILED_SYMBOL: &str = "•";
58
59 /// Render mode controlling whether tool/thinking cells render their compact
60 /// "live" form (with caps and collapsed reasoning) or their full transcript
61 /// form (uncapped, suitable for the pager / clipboard / message export).
62 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
63 pub enum RenderMode {
64 /// Live in-stream view: thinking is collapsed to a summary, tool output is
65 /// truncated with a "Alt+V for details" affordance.
66 Live,
67 /// Full transcript view: every line of reasoning and tool output is
68 /// emitted, no caps, no affordance.
69 Transcript,
70 }
71
72 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
73 enum ThinkingVisualState {
74 Live,
75 Done,
76 Idle,
77 }
78
79 // === History Cells ===
80
81 /// Renderable history cell for user/assistant/system entries.
82 #[derive(Debug, Clone)]
83 pub enum HistoryCell {
84 User {
85 content: String,
86 },
87 Assistant {
88 content: String,
89 streaming: bool,
90 },
91 System {
92 content: String,
93 },
94 /// Categorized engine-error cell. Severity drives the label glyph + color
95 /// (red for `Error`/`Critical`, amber for `Warning`, dim for `Info`) so
96 /// the user can prioritize at a glance.
97 Error {
98 message: String,
99 severity: crate::error_taxonomy::ErrorSeverity,
100 },
101 Thinking {
102 content: String,
103 streaming: bool,
104 duration_secs: Option<f32>,
105 },
106 /// An `<archived_context>` seam block produced by the Flash seam manager
107 /// (issue #159). Rendered dimmed/italic with a level + range label so
108 /// the user can see at a glance where context seams exist.
109 ArchivedContext {
110 /// Seam level (1, 2, 3, or 0 for cycle-level).
111 level: u8,
112 /// Message range covered (e.g. "msg 0-128").
113 range: String,
114 /// Token estimate string (e.g. "~2500").
115 tokens: String,
116 /// Density label (e.g. "~2,500 tokens").
117 density: String,
118 /// Model that produced the summary.
119 model: String,
120 /// RFC 3339 timestamp.
121 timestamp: String,
122 /// The summary text content.
123 summary: String,
124 },
125 Tool(ToolCell),
126 /// Live in-transcript card for sub-agent activity (issue #128). Owns
127 /// either a single `DelegateCard` or a multi-worker `FanoutCard`; the
128 /// UI re-binds it from the mailbox stream as envelopes arrive.
129 SubAgent(SubAgentCell),
130 }
131
132 /// In-transcript sub-agent cell — either a single delegate or a fanout.
133 /// State mutates over the turn as mailbox envelopes are drained.
134 #[derive(Debug, Clone)]
135 pub enum SubAgentCell {
136 Delegate(crate::tui::widgets::agent_card::DelegateCard),
137 Fanout(crate::tui::widgets::agent_card::FanoutCard),
138 }
139
140 impl SubAgentCell {
141 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
142 match self {
143 SubAgentCell::Delegate(card) => card.render_lines(width),
144 SubAgentCell::Fanout(card) => card.render_lines(width),
145 }
146 }
147 }
148
149 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
150 pub struct TranscriptRenderOptions {
151 pub show_thinking: bool,
152 pub show_tool_details: bool,
153 pub calm_mode: bool,
154 pub low_motion: bool,
155 pub spacing: TranscriptSpacing,
156 }
157
158 impl Default for TranscriptRenderOptions {
159 fn default() -> Self {
160 Self {
161 show_thinking: true,
162 show_tool_details: true,
163 calm_mode: false,
164 low_motion: false,
165 spacing: TranscriptSpacing::Comfortable,
166 }
167 }
168 }
169
170 impl HistoryCell {
171 /// Render the cell into a set of terminal lines.
172 ///
173 /// This is the live-display path used by widgets that don't already pass
174 /// `TranscriptRenderOptions`. Tool output is capped, but thinking is shown
175 /// in full because callers using bare `lines()` historically expected the
176 /// uncollapsed body. For the in-stream transcript view prefer
177 /// `lines_with_options`; for the pager / clipboard prefer
178 /// `transcript_lines`.
179 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
180 match self {
181 HistoryCell::User { content } => render_message(
182 USER_GLYPH,
183 user_label_style(),
184 message_body_style(),
185 content,
186 width,
187 ),
188 HistoryCell::Assistant { content, streaming } => render_message(
189 ASSISTANT_GLYPH,
190 assistant_label_style_for(*streaming, /*low_motion*/ false),
191 message_body_style(),
192 content,
193 width,
194 ),
195 HistoryCell::System { content } => {
196 if is_cycle_boundary(content) {
197 render_cycle_boundary(content, width)
198 } else {
199 render_message(
200 "Note",
201 system_label_style(),
202 system_body_style(),
203 content,
204 width,
205 )
206 }
207 }
208 HistoryCell::Error { message, severity } => render_message(
209 error_label_text(*severity),
210 error_label_style(*severity),
211 error_body_style(*severity),
212 message,
213 width,
214 ),
215 HistoryCell::Thinking {
216 content,
217 streaming,
218 duration_secs,
219 } => render_thinking(content, width, *streaming, *duration_secs, false, false),
220 HistoryCell::Tool(cell) => cell.lines_with_motion(width, false),
221 HistoryCell::SubAgent(cell) => cell.lines(width),
222 HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, false),
223 }
224 }
225
226 pub fn lines_with_options(
227 &self,
228 width: u16,
229 options: TranscriptRenderOptions,
230 ) -> Vec<Line<'static>> {
231 match self {
232 HistoryCell::Thinking { .. } if !options.show_thinking => Vec::new(),
233 HistoryCell::Thinking {
234 content,
235 streaming,
236 duration_secs,
237 } => render_thinking(
238 content,
239 width,
240 *streaming,
241 *duration_secs,
242 !*streaming,
243 options.low_motion,
244 ),
245 HistoryCell::Tool(cell) if !options.show_tool_details => {
246 let mut lines = cell.lines_with_motion(width, options.low_motion);
247 if lines.len() > 2 {
248 lines.truncate(2);
249 lines.push(details_affordance_line(
250 "details hidden",
251 Style::default().fg(palette::TEXT_MUTED).italic(),
252 ));
253 }
254 lines
255 }
256 HistoryCell::Tool(cell) if options.calm_mode => {
257 let mut lines = cell.lines_with_motion(width, options.low_motion);
258 if lines.len() > TOOL_CARD_SUMMARY_LINES {
259 lines.truncate(TOOL_CARD_SUMMARY_LINES);
260 lines.push(details_affordance_line(
261 "Alt+V for details",
262 Style::default().fg(palette::TEXT_MUTED).italic(),
263 ));
264 }
265 lines
266 }
267 HistoryCell::Tool(cell) => cell.lines_with_motion(width, options.low_motion),
268 HistoryCell::User { content } => render_message(
269 USER_GLYPH,
270 user_label_style(),
271 message_body_style(),
272 content,
273 width,
274 ),
275 HistoryCell::Assistant { content, streaming } => render_message(
276 ASSISTANT_GLYPH,
277 assistant_label_style_for(*streaming, options.low_motion),
278 message_body_style(),
279 content,
280 width,
281 ),
282 HistoryCell::System { .. } | HistoryCell::Error { .. } => self.lines(width),
283 HistoryCell::SubAgent(cell) => cell.lines(width),
284 HistoryCell::ArchivedContext { .. } => {
285 render_archived_context(self, width, options.low_motion)
286 }
287 }
288 }
289
290 /// Render the cell in transcript mode: full content, no caps, no
291 /// "Alt+V for details" affordances.
292 ///
293 /// Use this for the pager (`v` / `Ctrl+O`), clipboard exports, and any
294 /// surface that wants the complete body rather than the live summary.
295 /// For most variants (User / Assistant / System) this matches `lines()`;
296 /// `Thinking` and `Tool` are where the live and transcript surfaces
297 /// diverge.
298 pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
299 match self {
300 HistoryCell::User { content } => render_message(
301 USER_GLYPH,
302 user_label_style(),
303 message_body_style(),
304 content,
305 width,
306 ),
307 HistoryCell::Assistant { content, streaming } => render_message(
308 ASSISTANT_GLYPH,
309 // Pager / clipboard surface — pin the glyph at full
310 // brightness so a screenshot reads the same as a live frame.
311 assistant_label_style_for(*streaming, /*low_motion*/ true),
312 message_body_style(),
313 content,
314 width,
315 ),
316 HistoryCell::System { .. } | HistoryCell::Error { .. } => self.lines(width),
317 HistoryCell::Thinking {
318 content,
319 streaming,
320 duration_secs,
321 } => render_thinking(
322 content,
323 width,
324 *streaming,
325 *duration_secs,
326 /*collapsed*/ false,
327 /*low_motion*/ false,
328 ),
329 HistoryCell::Tool(cell) => cell.transcript_lines(width),
330 HistoryCell::SubAgent(cell) => cell.lines(width),
331 HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, true),
332 }
333 }
334
335 /// Whether this cell is the continuation of a streaming assistant message.
336 #[must_use]
337 pub fn is_stream_continuation(&self) -> bool {
338 matches!(
339 self,
340 HistoryCell::Assistant {
341 streaming: true,
342 ..
343 }
344 )
345 }
346
347 #[must_use]
348 pub fn is_conversational(&self) -> bool {
349 matches!(
350 self,
351 HistoryCell::User { .. } | HistoryCell::Assistant { .. } | HistoryCell::Thinking { .. }
352 )
353 }
354 }
355
356 /// Parse an `<archived_context>` block from an assistant Text block.
357 ///
358 /// Returns `Some(HistoryCell::ArchivedContext)` when the text contains a
359 /// well-formed `<archived_context>...</archived_context>` block, or `None`
360 /// if the text is regular assistant content.
361 fn parse_archived_context(text: &str) -> Option<HistoryCell> {
362 let text = text.trim();
363 if !text.starts_with("<archived_context") || !text.ends_with("</archived_context>") {
364 return None;
365 }
366
367 let tag_end = text.find('>')?;
368 let tag = &text[..tag_end];
369
370 let level = archived_context_attr(tag, "level")
371 .and_then(|v| v.parse::<u8>().ok())
372 .unwrap_or(0);
373
374 let range = archived_context_attr(tag, "range").unwrap_or_default();
375
376 let tokens = archived_context_attr(tag, "tokens").unwrap_or_default();
377
378 let density = archived_context_attr(tag, "density").unwrap_or_default();
379
380 let model = archived_context_attr(tag, "model").unwrap_or_default();
381
382 let timestamp = archived_context_attr(tag, "timestamp").unwrap_or_default();
383
384 let close_tag = text.rfind("</archived_context>")?;
385 let summary_start = tag_end + 1;
386 let summary = text[summary_start..close_tag].trim().to_string();
387
388 Some(HistoryCell::ArchivedContext {
389 level,
390 range,
391 tokens,
392 density,
393 model,
394 timestamp,
395 summary,
396 })
397 }
398
399 fn archived_context_attr(tag: &str, name: &str) -> Option<String> {
400 let needle = format!("{name}=\"");
401 let start = tag.find(&needle)? + needle.len();
402 let rest = &tag[start..];
403 let end = rest.find('"')?;
404 Some(rest[..end].to_string())
405 }
406
407 /// Render an `<archived_context>` block with dimmed/italic styling.
408 fn render_archived_context(
409 cell: &HistoryCell,
410 width: u16,
411 _low_motion: bool,
412 ) -> Vec<Line<'static>> {
413 let HistoryCell::ArchivedContext {
414 level,
415 range,
416 tokens,
417 density,
418 model,
419 timestamp,
420 summary,
421 } = cell
422 else {
423 return Vec::new();
424 };
425
426 let body = if summary.is_empty() {
427 "(no summary)".to_string()
428 } else {
429 summary.clone()
430 };
431
432 let label = format!("Context L{level}");
433 let label_style = Style::default()
434 .fg(palette::TEXT_DIM)
435 .add_modifier(Modifier::BOLD);
436 let body_style = Style::default().fg(palette::TEXT_DIM).italic();
437
438 let content_width = width.saturating_sub(4).max(1);
439
440 let mut lines = Vec::new();
441
442 let range_display = if range.is_empty() {
443 String::new()
444 } else {
445 range.to_string()
446 };
447 let mut header = format!("{label} {range_display}");
448 if !tokens.is_empty() {
449 header.push_str(&format!(" {tokens}"));
450 }
451 if !density.is_empty() && density != tokens {
452 header.push_str(&format!(" {density}"));
453 }
454 lines.push(Line::from(Span::styled(header, label_style)));
455
456 let model_display = if model.is_empty() {
457 String::new()
458 } else {
459 format!("via {model}")
460 };
461 let ts_display = if timestamp.is_empty() {
462 String::new()
463 } else {
464 timestamp.clone()
465 };
466 let mut sub = String::new();
467 if !model_display.is_empty() {
468 sub.push_str(&model_display);
469 }
470 if !ts_display.is_empty() {
471 if !sub.is_empty() {
472 sub.push_str(" · ");
473 }
474 sub.push_str(&ts_display);
475 }
476 if !sub.is_empty() {
477 lines.push(Line::from(Span::styled(
478 sub,
479 Style::default().fg(palette::TEXT_MUTED),
480 )));
481 }
482
483 let rendered = crate::tui::markdown_render::render_markdown(&body, content_width, body_style);
484 for (idx, line) in rendered.into_iter().enumerate() {
485 if idx == 0 {
486 let mut spans = vec![Span::styled(
487 TRANSCRIPT_RAIL.to_string(),
488 Style::default().fg(palette::TEXT_DIM),
489 )];
490 spans.extend(line.spans);
491 lines.push(Line::from(spans));
492 } else {
493 let mut spans = vec![Span::raw(" ")];
494 spans.extend(line.spans);
495 lines.push(Line::from(spans));
496 }
497 }
498
499 lines.push(Line::from(""));
500
501 lines
502 }
503
504 /// Convert a message into history cells for rendering.
505 #[must_use]
506 pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
507 let mut cells = Vec::new();
508
509 for block in &msg.content {
510 match block {
511 ContentBlock::Text { text, .. } => {
512 // Check if this is an `<archived_context>` block.
513 if msg.role == "assistant"
514 && let Some(archived) = parse_archived_context(text)
515 {
516 cells.push(archived);
517 continue;
518 }
519 match msg.role.as_str() {
520 "user" => {
521 if let Some(HistoryCell::User { content }) = cells.last_mut() {
522 if !content.is_empty() {
523 content.push('\n');
524 }
525 content.push_str(text);
526 } else {
527 cells.push(HistoryCell::User {
528 content: text.clone(),
529 });
530 }
531 }
532 "assistant" => {
533 if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() {
534 if !content.is_empty() {
535 content.push('\n');
536 }
537 content.push_str(text);
538 } else {
539 cells.push(HistoryCell::Assistant {
540 content: text.clone(),
541 streaming: false,
542 });
543 }
544 }
545 "system" => {
546 if let Some(HistoryCell::System { content }) = cells.last_mut() {
547 if !content.is_empty() {
548 content.push('\n');
549 }
550 content.push_str(text);
551 } else {
552 cells.push(HistoryCell::System {
553 content: text.clone(),
554 });
555 }
556 }
557 _ => {}
558 }
559 }
560 ContentBlock::Thinking { thinking } => {
561 if let Some(HistoryCell::Thinking { content, .. }) = cells.last_mut() {
562 if !content.is_empty() {
563 content.push('\n');
564 }
565 content.push_str(thinking);
566 } else {
567 cells.push(HistoryCell::Thinking {
568 content: thinking.clone(),
569 streaming: false,
570 duration_secs: None,
571 });
572 }
573 }
574 _ => {}
575 }
576 }
577
578 cells
579 }
580
581 // === Tool Cells ===
582
583 /// Variants describing a tool result cell.
584 #[derive(Debug, Clone)]
585 pub enum ToolCell {
586 Exec(ExecCell),
587 Exploring(ExploringCell),
588 PlanUpdate(PlanUpdateCell),
589 PatchSummary(PatchSummaryCell),
590 Review(ReviewCell),
591 DiffPreview(DiffPreviewCell),
592 Mcp(McpToolCell),
593 ViewImage(ViewImageCell),
594 WebSearch(WebSearchCell),
595 Generic(GenericToolCell),
596 }
597
598 impl ToolCell {
599 /// Render the tool cell into lines.
600 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
601 self.lines_with_motion(width, false)
602 }
603
604 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
605 self.render(width, low_motion, RenderMode::Live)
606 }
607
608 /// Full-content rendering for the pager / clipboard. Tool output that
609 /// would be capped + suffixed with "Alt+V for details" in the live view
610 /// is emitted in full here.
611 pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
612 self.render(width, /*low_motion*/ false, RenderMode::Transcript)
613 }
614
615 fn render(&self, width: u16, low_motion: bool, mode: RenderMode) -> Vec<Line<'static>> {
616 match self {
617 ToolCell::Exec(cell) => cell.render(width, low_motion, mode),
618 ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion),
619 ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion),
620 ToolCell::PatchSummary(cell) => cell.render(width, low_motion, mode),
621 ToolCell::Review(cell) => cell.render(width, low_motion, mode),
622 ToolCell::DiffPreview(cell) => cell.lines_with_motion(width, low_motion),
623 ToolCell::Mcp(cell) => cell.render(width, low_motion, mode),
624 ToolCell::ViewImage(cell) => cell.lines_with_motion(width, low_motion),
625 ToolCell::WebSearch(cell) => cell.lines_with_motion(width, low_motion),
626 ToolCell::Generic(cell) => cell.lines_with_mode(width, low_motion, mode),
627 }
628 }
629 }
630
631 /// Overall status for a tool execution.
632 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
633 pub enum ToolStatus {
634 Running,
635 Success,
636 Failed,
637 }
638
639 /// Shell command execution rendering data.
640 #[derive(Debug, Clone)]
641 pub struct ExecCell {
642 pub command: String,
643 pub status: ToolStatus,
644 pub output: Option<String>,
645 pub started_at: Option<Instant>,
646 pub duration_ms: Option<u64>,
647 pub source: ExecSource,
648 pub interaction: Option<String>,
649 }
650
651 impl ExecCell {
652 /// Render the execution cell into lines (live view, capped output).
653 #[cfg(test)]
654 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
655 self.render(width, low_motion, RenderMode::Live)
656 }
657
658 pub(super) fn render(
659 &self,
660 width: u16,
661 low_motion: bool,
662 mode: RenderMode,
663 ) -> Vec<Line<'static>> {
664 let mut lines = Vec::new();
665 let command_summary = command_header_summary(&self.command);
666 let header_summary = self
667 .interaction
668 .as_deref()
669 .or(Some(command_summary.as_str()));
670 lines.push(render_tool_header_with_summary(
671 "Shell",
672 header_summary,
673 tool_status_label(self.status),
674 self.status,
675 self.started_at,
676 low_motion,
677 ));
678
679 if self.status == ToolStatus::Success && self.source == ExecSource::User {
680 lines.extend(render_compact_kv(
681 "source",
682 "started by you",
683 Style::default().fg(palette::TEXT_MUTED),
684 width,
685 ));
686 }
687
688 if let Some(interaction) = self.interaction.as_ref() {
689 lines.extend(wrap_plain_line(
690 &format!(" {interaction}"),
691 Style::default().fg(palette::TEXT_MUTED),
692 width,
693 ));
694 } else {
695 lines.extend(render_command_mode(&self.command, width, mode));
696 }
697
698 if self.interaction.is_none() {
699 if let Some(output) = self.output.as_ref() {
700 lines.extend(render_exec_output_mode(
701 output,
702 width,
703 TOOL_OUTPUT_LINE_LIMIT,
704 mode,
705 ));
706 } else if self.status == ToolStatus::Running && self.source == ExecSource::Assistant {
707 lines.extend(wrap_plain_line(
708 " Ctrl+B opens shell controls.",
709 Style::default().fg(palette::TEXT_MUTED),
710 width,
711 ));
712 } else if self.status != ToolStatus::Running {
713 lines.push(Line::from(Span::styled(
714 " (no output)",
715 Style::default().fg(palette::TEXT_MUTED).italic(),
716 )));
717 }
718 }
719
720 if let Some(duration_ms) = self.duration_ms {
721 let seconds = f64::from(u32::try_from(duration_ms).unwrap_or(u32::MAX)) / 1000.0;
722 lines.extend(render_compact_kv(
723 "time",
724 &format!("{seconds:.2}s"),
725 Style::default().fg(palette::TEXT_DIM),
726 width,
727 ));
728 }
729
730 lines
731 }
732 }
733
734 /// Source of a shell command execution.
735 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
736 pub enum ExecSource {
737 User,
738 Assistant,
739 }
740
741 /// Aggregate cell for tool exploration runs.
742 #[derive(Debug, Clone)]
743 pub struct ExploringCell {
744 pub entries: Vec<ExploringEntry>,
745 }
746
747 impl ExploringCell {
748 /// Render the exploring cell into lines.
749 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
750 let mut lines = Vec::new();
751 let all_done = self
752 .entries
753 .iter()
754 .all(|entry| entry.status != ToolStatus::Running);
755 let status = if all_done {
756 ToolStatus::Success
757 } else {
758 ToolStatus::Running
759 };
760 let header_summary = exploring_header_summary(&self.entries);
761 lines.push(render_tool_header_with_summary(
762 "Workspace",
763 header_summary.as_deref(),
764 if all_done { "done" } else { "running" },
765 status,
766 None,
767 low_motion,
768 ));
769
770 for entry in &self.entries {
771 let prefix = match entry.status {
772 ToolStatus::Running => "live",
773 ToolStatus::Success => "done",
774 ToolStatus::Failed => "issue",
775 };
776 lines.extend(render_compact_kv(
777 prefix,
778 &entry.label,
779 tool_value_style(),
780 width,
781 ));
782 }
783 lines
784 }
785
786 /// Insert a new entry and return its index.
787 #[must_use]
788 pub fn insert_entry(&mut self, entry: ExploringEntry) -> usize {
789 self.entries.push(entry);
790 self.entries.len().saturating_sub(1)
791 }
792 }
793
794 /// Single entry for exploring tool output.
795 #[derive(Debug, Clone)]
796 pub struct ExploringEntry {
797 pub label: String,
798 pub status: ToolStatus,
799 }
800
801 /// Cell for plan updates emitted by the plan tool.
802 #[derive(Debug, Clone)]
803 pub struct PlanUpdateCell {
804 pub explanation: Option<String>,
805 pub steps: Vec<PlanStep>,
806 pub status: ToolStatus,
807 }
808
809 impl PlanUpdateCell {
810 /// Render the plan update cell into lines.
811 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
812 let mut lines = Vec::new();
813 lines.push(render_tool_header(
814 "Plan",
815 tool_status_label(self.status),
816 self.status,
817 None,
818 low_motion,
819 ));
820
821 if let Some(explanation) = self.explanation.as_ref() {
822 lines.extend(render_message(
823 "",
824 system_label_style(),
825 system_body_style(),
826 explanation,
827 width,
828 ));
829 }
830
831 for step in &self.steps {
832 let marker = match step.status.as_str() {
833 "completed" => "done",
834 "in_progress" => "live",
835 _ => "next",
836 };
837 lines.extend(render_compact_kv(
838 marker,
839 &step.step,
840 tool_value_style(),
841 width,
842 ));
843 }
844
845 lines
846 }
847 }
848
849 /// Single plan step rendered in the UI.
850 #[derive(Debug, Clone)]
851 pub struct PlanStep {
852 pub step: String,
853 pub status: String,
854 }
855
856 /// Cell for patch summaries emitted by the patch tool.
857 #[derive(Debug, Clone)]
858 pub struct PatchSummaryCell {
859 pub path: String,
860 pub summary: String,
861 pub status: ToolStatus,
862 pub error: Option<String>,
863 }
864
865 impl PatchSummaryCell {
866 pub(super) fn render(
867 &self,
868 width: u16,
869 low_motion: bool,
870 mode: RenderMode,
871 ) -> Vec<Line<'static>> {
872 let mut lines = Vec::new();
873 lines.push(render_tool_header_with_summary(
874 "Patch",
875 Some(&self.path),
876 tool_status_label(self.status),
877 self.status,
878 None,
879 low_motion,
880 ));
881 lines.extend(render_compact_kv(
882 "file",
883 &self.path,
884 tool_value_style(),
885 width,
886 ));
887 lines.extend(render_tool_output_mode(
888 &self.summary,
889 width,
890 TOOL_COMMAND_LINE_LIMIT,
891 mode,
892 ));
893 if let Some(error) = self.error.as_ref() {
894 lines.extend(render_tool_output_mode(
895 error,
896 width,
897 TOOL_COMMAND_LINE_LIMIT,
898 mode,
899 ));
900 }
901 lines
902 }
903 }
904
905 /// Cell for structured review output.
906 #[derive(Debug, Clone)]
907 pub struct ReviewCell {
908 pub target: String,
909 pub status: ToolStatus,
910 pub output: Option<ReviewOutput>,
911 pub error: Option<String>,
912 }
913
914 impl ReviewCell {
915 pub(super) fn render(
916 &self,
917 width: u16,
918 low_motion: bool,
919 mode: RenderMode,
920 ) -> Vec<Line<'static>> {
921 let mut lines = Vec::new();
922 lines.push(render_tool_header(
923 "Review",
924 tool_status_label(self.status),
925 self.status,
926 None,
927 low_motion,
928 ));
929
930 if !self.target.trim().is_empty() {
931 lines.extend(render_compact_kv(
932 "target",
933 self.target.trim(),
934 tool_value_style(),
935 width,
936 ));
937 }
938
939 if self.status == ToolStatus::Running {
940 return lines;
941 }
942
943 if let Some(error) = self.error.as_ref() {
944 lines.extend(render_tool_output_mode(
945 error,
946 width,
947 TOOL_COMMAND_LINE_LIMIT,
948 mode,
949 ));
950 return lines;
951 }
952
953 let Some(output) = self.output.as_ref() else {
954 return lines;
955 };
956
957 if !output.summary.trim().is_empty() {
958 lines.extend(wrap_plain_line(
959 &format!("Summary: {}", output.summary.trim()),
960 Style::default().fg(palette::TEXT_PRIMARY),
961 width,
962 ));
963 }
964
965 lines.push(Line::from(""));
966 lines.push(Line::from(Span::styled(
967 "Issues",
968 Style::default()
969 .fg(palette::DEEPSEEK_BLUE)
970 .add_modifier(Modifier::BOLD),
971 )));
972 if output.issues.is_empty() {
973 lines.extend(wrap_plain_line(
974 " (none)",
975 Style::default().fg(palette::TEXT_MUTED),
976 width,
977 ));
978 } else {
979 for issue in &output.issues {
980 let severity = issue.severity.trim().to_ascii_lowercase();
981 let color = review_severity_color(&severity);
982 let location = format_review_location(issue.path.as_ref(), issue.line);
983 let label = if location.is_empty() {
984 format!(" - [{}] {}", severity, issue.title.trim())
985 } else {
986 format!(" - [{}] {} ({})", severity, issue.title.trim(), location)
987 };
988 lines.extend(wrap_plain_line(&label, Style::default().fg(color), width));
989 if !issue.description.trim().is_empty() {
990 lines.extend(wrap_plain_line(
991 &format!(" {}", issue.description.trim()),
992 Style::default().fg(palette::TEXT_MUTED),
993 width,
994 ));
995 }
996 }
997 }
998
999 lines.push(Line::from(""));
1000 lines.push(Line::from(Span::styled(
1001 "Suggestions",
1002 Style::default()
1003 .fg(palette::DEEPSEEK_BLUE)
1004 .add_modifier(Modifier::BOLD),
1005 )));
1006 if output.suggestions.is_empty() {
1007 lines.extend(wrap_plain_line(
1008 " (none)",
1009 Style::default().fg(palette::TEXT_MUTED),
1010 width,
1011 ));
1012 } else {
1013 for suggestion in &output.suggestions {
1014 let location = format_review_location(suggestion.path.as_ref(), suggestion.line);
1015 let label = if location.is_empty() {
1016 format!(" - {}", suggestion.suggestion.trim())
1017 } else {
1018 format!(" - {} ({})", suggestion.suggestion.trim(), location)
1019 };
1020 lines.extend(wrap_plain_line(
1021 &label,
1022 Style::default().fg(palette::TEXT_PRIMARY),
1023 width,
1024 ));
1025 }
1026 }
1027
1028 if !output.overall_assessment.trim().is_empty() {
1029 lines.push(Line::from(""));
1030 lines.extend(wrap_plain_line(
1031 &format!("Overall: {}", output.overall_assessment.trim()),
1032 Style::default().fg(palette::TEXT_PRIMARY),
1033 width,
1034 ));
1035 }
1036
1037 lines
1038 }
1039 }
1040
1041 /// Cell for showing a diff preview before applying changes.
1042 #[derive(Debug, Clone)]
1043 pub struct DiffPreviewCell {
1044 pub title: String,
1045 pub diff: String,
1046 }
1047
1048 impl DiffPreviewCell {
1049 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1050 let mut lines = Vec::new();
1051 let diff_summary = diff_render::diff_summary_label(&self.diff);
1052 lines.push(render_tool_header_with_summary(
1053 "Diff",
1054 diff_summary.as_deref(),
1055 "done",
1056 ToolStatus::Success,
1057 None,
1058 low_motion,
1059 ));
1060 lines.extend(render_compact_kv(
1061 "title",
1062 &self.title,
1063 tool_value_style(),
1064 width,
1065 ));
1066 lines.extend(diff_render::render_diff(&self.diff, width));
1067 lines
1068 }
1069 }
1070
1071 /// Cell representing an MCP tool execution.
1072 #[derive(Debug, Clone)]
1073 pub struct McpToolCell {
1074 pub tool: String,
1075 pub status: ToolStatus,
1076 pub content: Option<String>,
1077 pub is_image: bool,
1078 }
1079
1080 impl McpToolCell {
1081 pub(super) fn render(
1082 &self,
1083 width: u16,
1084 low_motion: bool,
1085 mode: RenderMode,
1086 ) -> Vec<Line<'static>> {
1087 let mut lines = Vec::new();
1088 lines.push(render_tool_header_with_summary(
1089 "Tool",
1090 Some(&self.tool),
1091 tool_status_label(self.status),
1092 self.status,
1093 None,
1094 low_motion,
1095 ));
1096 lines.extend(render_compact_kv(
1097 "name",
1098 &self.tool,
1099 tool_value_style(),
1100 width,
1101 ));
1102
1103 if self.is_image {
1104 lines.extend(render_compact_kv(
1105 "result",
1106 "image",
1107 tool_value_style(),
1108 width,
1109 ));
1110 }
1111
1112 if let Some(content) = self.content.as_ref() {
1113 lines.extend(render_tool_output_mode(
1114 content,
1115 width,
1116 TOOL_COMMAND_LINE_LIMIT,
1117 mode,
1118 ));
1119 }
1120 lines
1121 }
1122 }
1123
1124 /// Cell for image view actions.
1125 #[derive(Debug, Clone)]
1126 pub struct ViewImageCell {
1127 pub path: PathBuf,
1128 }
1129
1130 impl ViewImageCell {
1131 /// Render the image view cell into lines.
1132 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1133 let path = self.path.display().to_string();
1134 let mut lines = vec![render_tool_header_with_summary(
1135 "Image",
1136 Some(&path),
1137 "done",
1138 ToolStatus::Success,
1139 None,
1140 low_motion,
1141 )];
1142 lines.extend(render_compact_kv("path", &path, tool_value_style(), width));
1143 lines
1144 }
1145 }
1146
1147 /// Cell for web search tool output.
1148 #[derive(Debug, Clone)]
1149 pub struct WebSearchCell {
1150 pub query: String,
1151 pub status: ToolStatus,
1152 pub summary: Option<String>,
1153 }
1154
1155 impl WebSearchCell {
1156 /// Render the web search cell into lines.
1157 pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
1158 let mut lines = Vec::new();
1159 lines.push(render_tool_header_with_summary(
1160 "Search",
1161 Some(&self.query),
1162 tool_status_label(self.status),
1163 self.status,
1164 None,
1165 low_motion,
1166 ));
1167 lines.extend(render_compact_kv(
1168 "query",
1169 &self.query,
1170 tool_value_style(),
1171 width,
1172 ));
1173 if let Some(summary) = self.summary.as_ref() {
1174 lines.extend(render_compact_kv(
1175 "result",
1176 summary,
1177 tool_value_style(),
1178 width,
1179 ));
1180 }
1181 lines
1182 }
1183 }
1184
1185 /// Generic cell for tool output when no specialized rendering exists.
1186 #[derive(Debug, Clone)]
1187 pub struct GenericToolCell {
1188 pub name: String,
1189 pub status: ToolStatus,
1190 pub input_summary: Option<String>,
1191 pub output: Option<String>,
1192 /// Optional list of per-child prompts. When populated (by any future
1193 /// fan-out tool), each prompt is shown on its own indented row instead
1194 /// of the inline `args:` summary. `None` for ordinary tools.
1195 pub prompts: Option<Vec<String>>,
1196 /// Filesystem path to the full output's spillover file (#422/#423).
1197 /// Set by the tool-routing layer when `ToolResult.metadata` carried a
1198 /// `spillover_path` field. The truncation affordance includes the
1199 /// path so the user can `read_file` it (or Cmd+click in
1200 /// OSC 8-aware terminals — the path renders as a hyperlink when
1201 /// `tui.osc8_links` is enabled).
1202 pub spillover_path: Option<std::path::PathBuf>,
1203 }
1204
1205 impl GenericToolCell {
1206 /// Render the generic tool cell into lines.
1207 ///
1208 /// `mode` controls multi-line output handling: `Live` caps at
1209 /// `TOOL_OUTPUT_LINE_LIMIT` rows with a "+N more" affordance;
1210 /// `Transcript` emits the full output.
1211 pub fn lines_with_mode(
1212 &self,
1213 width: u16,
1214 low_motion: bool,
1215 mode: RenderMode,
1216 ) -> Vec<Line<'static>> {
1217 // Issue #241: when the underlying tool is a checklist/todo update and
1218 // the output is parseable, render a purpose-built progress card
1219 // instead of dumping the JSON into the generic tool block.
1220 if let Some(lines) = self.try_render_as_checklist(width, low_motion, mode) {
1221 return lines;
1222 }
1223
1224 // Issue #409: `agent_spawn` already gets a dedicated `DelegateCard`
1225 // that owns the live action tree, status, and final summary. The
1226 // generic tool block for the same call duplicates that signal at
1227 // 3-4 lines per spawn — N parallel spawns multiply the noise. In
1228 // live mode, render one compact summary line and let the
1229 // DelegateCard be the source of truth. Transcript mode keeps the
1230 // full block so session replay remains complete.
1231 if matches!(mode, RenderMode::Live) && self.name == "agent_spawn" {
1232 return self.render_agent_spawn_compact(low_motion);
1233 }
1234
1235 let mut lines = Vec::new();
1236 // Map the actual tool name (e.g. `agent_spawn`, `apply_patch`) to a
1237 // family rather than the catch-all `"Tool"` title — this is what
1238 // gives a `GenericToolCell` the right verb glyph (◐ delegate, ⋮⋮
1239 // fanout, etc.) instead of falling back to the neutral bullet.
1240 let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
1241 let header_summary = crate::tui::widgets::tool_card::tool_header_summary_for_name(
1242 &self.name,
1243 self.input_summary.as_deref(),
1244 );
1245 lines.push(render_tool_header_with_family_and_summary(
1246 family,
1247 header_summary.as_deref(),
1248 tool_status_label(self.status),
1249 self.status,
1250 None,
1251 low_motion,
1252 ));
1253 lines.extend(render_compact_kv(
1254 "name",
1255 &self.name,
1256 tool_value_style(),
1257 width,
1258 ));
1259
1260 // Prefer per-prompt rows over the generic args summary when the tool
1261 // exposes a list of child prompts. One row per child with a `[i]`
1262 // index makes the fan-out legible without expanding JSON.
1263 let show_prompts = matches!(self.status, ToolStatus::Running) || self.output.is_none();
1264 if show_prompts
1265 && let Some(prompts) = self.prompts.as_ref()
1266 && !prompts.is_empty()
1267 {
1268 for (idx, prompt) in prompts.iter().enumerate() {
1269 let label = if idx == 0 { "prompts" } else { "" };
1270 let value = format!("[{idx}] {}", truncate_text(prompt.trim(), 200));
1271 lines.extend(render_card_detail_line(
1272 if label.is_empty() { None } else { Some(label) },
1273 &value,
1274 tool_value_style(),
1275 width,
1276 ));
1277 }
1278 } else {
1279 let show_args = matches!(self.status, ToolStatus::Running) || self.output.is_none();
1280 if show_args && let Some(summary) = self.input_summary.as_ref() {
1281 lines.extend(render_compact_kv(
1282 "args",
1283 summary,
1284 tool_value_style(),
1285 width,
1286 ));
1287 }
1288 }
1289
1290 if let Some(output) = self.output.as_ref() {
1291 // If the output looks like a unified diff (contains hunk headers),
1292 // use the full diff renderer with line numbers and colored gutters
1293 // instead of the generic output path (#380).
1294 if output_looks_like_diff(output) {
1295 let diff_summary = diff_render::diff_summary_label(output);
1296 lines.push(render_tool_header_with_summary(
1297 "Diff",
1298 diff_summary.as_deref(),
1299 tool_status_label(self.status),
1300 self.status,
1301 None,
1302 low_motion,
1303 ));
1304 lines.extend(diff_render::render_diff(output, width));
1305 } else {
1306 // Multi-line outputs (diff stats, file lists, todo snapshots) used
1307 // to be crushed into one line by `render_compact_kv` because its
1308 // wrapper joined the entire string before wrapping. Route through
1309 // `render_tool_output_mode` so each `\n` becomes a real row, with
1310 // a `+N more lines` affordance in live mode (#80).
1311 lines.extend(render_tool_output_mode(
1312 output,
1313 width,
1314 TOOL_OUTPUT_LINE_LIMIT,
1315 mode,
1316 ));
1317 }
1318
1319 // #423: surface the spillover-file path inline so the user
1320 // (and the model) can find the elided tail. Only emitted in
1321 // live mode — transcript replay already has the full output
1322 // verbatim. The path is OSC 8-wrapped when the feature is
1323 // enabled so terminals that support hyperlinks make it
1324 // Cmd+click-openable; the clipboard / selection path
1325 // strips the escape on copy.
1326 if matches!(mode, RenderMode::Live)
1327 && let Some(path) = self.spillover_path.as_ref()
1328 {
1329 lines.push(render_spillover_annotation(path, width));
1330 }
1331 }
1332 lines
1333 }
1334
1335 /// Render `agent_spawn` as a single compact summary line for live
1336 /// mode (#409). The companion `DelegateCard` already carries the
1337 /// live action tree, status, and final summary; this line is just
1338 /// the pointer that says "a spawn happened, here's the agent id".
1339 ///
1340 /// Output shape (header):
1341 /// `◐ delegate · agent_spawn agent-abc12 [running]`
1342 /// Falls back to a placeholder when the spawn is still pending and
1343 /// no agent id has been assigned yet.
1344 fn render_agent_spawn_compact(&self, low_motion: bool) -> Vec<Line<'static>> {
1345 let family = crate::tui::widgets::tool_card::ToolFamily::Delegate;
1346 let agent_id = self
1347 .output
1348 .as_deref()
1349 .and_then(extract_agent_id)
1350 .unwrap_or("…");
1351 vec![render_tool_header_with_family_and_summary(
1352 family,
1353 Some(agent_id),
1354 tool_status_label(self.status),
1355 self.status,
1356 None,
1357 low_motion,
1358 )]
1359 }
1360
1361 /// If this cell is a checklist/todo write/add/update and the output is
1362 /// parseable as a checklist snapshot, render a purpose-built checklist
1363 /// card instead of the generic `name: ... { json }` block (issue #241).
1364 fn try_render_as_checklist(
1365 &self,
1366 width: u16,
1367 low_motion: bool,
1368 mode: RenderMode,
1369 ) -> Option<Vec<Line<'static>>> {
1370 if !is_checklist_tool_name(&self.name) {
1371 return None;
1372 }
1373 let output = self.output.as_ref()?;
1374 let snapshot = parse_checklist_snapshot(output)?;
1375
1376 // Concise update rendering (#403). When the tool emits an
1377 // "Updated todo #N to STATUS" prefix line — which `todo_update` /
1378 // `checklist_update` always do on a successful match — render
1379 // only the changed item plus a `M/N · pct%` summary instead of
1380 // dumping the full list every time. The full list is still
1381 // reachable via Alt+V on the tool detail record. This keeps the
1382 // transcript scannable in long sessions.
1383 if matches!(mode, RenderMode::Live)
1384 && let Some(change) = parse_update_prefix(output)
1385 {
1386 return Some(render_checklist_change_card(
1387 &self.name,
1388 self.status,
1389 &snapshot,
1390 &change,
1391 width,
1392 low_motion,
1393 ));
1394 }
1395
1396 Some(render_checklist_card(
1397 &self.name,
1398 self.status,
1399 &snapshot,
1400 width,
1401 low_motion,
1402 mode,
1403 ))
1404 }
1405 }
1406
1407 /// Render the inline annotation for a tool cell whose full output was
1408 /// spilled to disk (#422 + #423). Produces a one-line muted hint:
1409 ///
1410 /// ```text
1411 /// full output: /Users/you/.deepseek/tool_outputs/call-abc12.txt
1412 /// ```
1413 ///
1414 /// Path is plain text on this branch; the OSC 8 hyperlink-wrap that
1415 /// makes it Cmd+click-openable lives on the OSC 8 branch (PR #515)
1416 /// and merges in once both PRs land on `main`. The clipboard /
1417 /// selection path already strips OSC 8 there, so a future enhancement
1418 /// stays backward-compatible.
1419 fn render_spillover_annotation(path: &std::path::Path, width: u16) -> Line<'static> {
1420 let display = path.display().to_string();
1421 let prefix = " full output: ";
1422 let budget = usize::from(width).saturating_sub(prefix.len()).max(8);
1423 let truncated = truncate_text(&display, budget);
1424 Line::from(vec![
1425 Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)),
1426 Span::styled(truncated, Style::default().fg(palette::TEXT_MUTED).italic()),
1427 ])
1428 }
1429
1430 /// Pull the `agent_id` field out of an `agent_spawn` tool output. The
1431 /// tool emits structured JSON shaped like
1432 /// `{"agent_id": "agent-abc12", "nickname": "...", "model": "..."}` so we
1433 /// look for the `agent_id` key and return its string value.
1434 ///
1435 /// Returns `None` for outputs we can't parse as JSON or that lack the
1436 /// expected key — the caller falls back to a placeholder so a still-pending
1437 /// spawn renders cleanly.
1438 fn extract_agent_id(output: &str) -> Option<&str> {
1439 // Cheap, deterministic, no allocations: scan for the literal key.
1440 // Avoids dragging serde_json into a render hot path on every frame.
1441 let key = "\"agent_id\"";
1442 let key_idx = output.find(key)?;
1443 let rest = &output[key_idx + key.len()..];
1444 let colon = rest.find(':')?;
1445 let after_colon = rest[colon + 1..].trim_start();
1446 let after_colon = after_colon.strip_prefix('"')?;
1447 let end = after_colon.find('"')?;
1448 let id = &after_colon[..end];
1449 (!id.is_empty()).then_some(id)
1450 }
1451
1452 fn is_checklist_tool_name(name: &str) -> bool {
1453 matches!(
1454 name,
1455 "checklist_write"
1456 | "checklist_add"
1457 | "checklist_update"
1458 | "todo_write"
1459 | "todo_add"
1460 | "todo_update"
1461 )
1462 }
1463
1464 /// Heuristic: does the output look like a unified diff? Returns true when
1465 /// the output contains at least one hunk header (`@@`) or a `diff --git`
1466 /// line, which are reliable markers of unified diff content (#380).
1467 fn output_looks_like_diff(output: &str) -> bool {
1468 let mut lines = output.lines();
1469 // Check first 5 lines for diff markers
1470 for _ in 0..5 {
1471 let Some(line) = lines.next() else { break };
1472 let trimmed = line.trim();
1473 if trimmed.starts_with("@@") || trimmed.starts_with("diff --git") {
1474 return true;
1475 }
1476 }
1477 false
1478 }
1479
1480 #[derive(Debug, Clone)]
1481 struct ChecklistItemSnapshot {
1482 content: String,
1483 status: String,
1484 }
1485
1486 #[derive(Debug, Clone, Default)]
1487 struct ChecklistSnapshot {
1488 items: Vec<ChecklistItemSnapshot>,
1489 completion_pct: u8,
1490 completed: usize,
1491 total: usize,
1492 }
1493
1494 /// Pull a structured checklist snapshot out of the tool's text output.
1495 /// The tool emits a leading human-readable line followed by JSON, so we
1496 /// scan for the first `{` and parse from there. Returns `None` if the
1497 /// payload is missing the expected `items` array.
1498 fn parse_checklist_snapshot(output: &str) -> Option<ChecklistSnapshot> {
1499 let json_start = output.find('{')?;
1500 let parsed: Value = serde_json::from_str(&output[json_start..]).ok()?;
1501 let items_value = parsed.get("items")?.as_array()?;
1502
1503 let items: Vec<ChecklistItemSnapshot> = items_value
1504 .iter()
1505 .map(|item| ChecklistItemSnapshot {
1506 content: item
1507 .get("content")
1508 .and_then(Value::as_str)
1509 .unwrap_or("")
1510 .to_string(),
1511 status: item
1512 .get("status")
1513 .and_then(Value::as_str)
1514 .unwrap_or("pending")
1515 .to_string(),
1516 })
1517 .collect();
1518
1519 if items.is_empty() {
1520 return None;
1521 }
1522
1523 let completed = items
1524 .iter()
1525 .filter(|item| item.status.eq_ignore_ascii_case("completed"))
1526 .count();
1527 let total = items.len();
1528 let completion_pct = parsed
1529 .get("completion_pct")
1530 .and_then(Value::as_u64)
1531 .map(|pct| u8::try_from(pct.min(100)).unwrap_or(100))
1532 .unwrap_or_else(|| {
1533 (completed * 100)
1534 .checked_div(total)
1535 .and_then(|pct| u8::try_from(pct).ok())
1536 .unwrap_or(0)
1537 });
1538
1539 Some(ChecklistSnapshot {
1540 items,
1541 completion_pct,
1542 completed,
1543 total,
1544 })
1545 }
1546
1547 /// One parsed "Updated todo #N to STATUS" prefix line emitted by
1548 /// `todo_update` / `checklist_update`. Used by [`render_checklist_change_card`]
1549 /// to show a compact state-change line instead of the full item list.
1550 #[derive(Debug, Clone, PartialEq, Eq)]
1551 struct ChecklistChange {
1552 id: u32,
1553 status: String,
1554 }
1555
1556 /// Parse the leading line of a checklist-update tool output. Returns
1557 /// `None` for non-update outputs (e.g. `todo_write` snapshots, errors,
1558 /// or an unexpected format) so the caller falls back to the full-list
1559 /// renderer.
1560 fn parse_update_prefix(output: &str) -> Option<ChecklistChange> {
1561 // The tool output shape is `Updated todo #3 to in_progress\n{ ... }`.
1562 // We tolerate `checklist` or `todo` as the noun and any reasonable
1563 // status word (the snapshot lookup in the renderer is the source of
1564 // truth for the title — we just need the id+status pair).
1565 let first = output.lines().next()?.trim();
1566 let rest = first
1567 .strip_prefix("Updated todo #")
1568 .or_else(|| first.strip_prefix("Updated checklist #"))?;
1569 let (id_str, after) = rest.split_once(' ')?;
1570 let id: u32 = id_str.parse().ok()?;
1571 let status = after.strip_prefix("to ")?.trim().to_string();
1572 if status.is_empty() {
1573 return None;
1574 }
1575 Some(ChecklistChange { id, status })
1576 }
1577
1578 /// Render a compact one-line state-change card for `todo_update` /
1579 /// `checklist_update` calls (#403). Shows the changed item's marker,
1580 /// title, and old → new status, with a `M/N · pct%` progress summary
1581 /// in the header. The full list is still available via Alt+V on the
1582 /// detail record.
1583 fn render_checklist_change_card(
1584 name: &str,
1585 status: ToolStatus,
1586 snapshot: &ChecklistSnapshot,
1587 change: &ChecklistChange,
1588 width: u16,
1589 low_motion: bool,
1590 ) -> Vec<Line<'static>> {
1591 let mut lines = Vec::new();
1592 let header_summary = format!(
1593 "{}/{} \u{00B7} {}%",
1594 snapshot.completed, snapshot.total, snapshot.completion_pct
1595 );
1596 let family = crate::tui::widgets::tool_card::tool_family_for_name(name);
1597 lines.push(render_tool_header_with_family_and_summary(
1598 family,
1599 Some(&header_summary),
1600 tool_status_label(status),
1601 status,
1602 None,
1603 low_motion,
1604 ));
1605
1606 // Look up the title from the snapshot. `id` in tool input is
1607 // 1-indexed; `items` is 0-indexed.
1608 let item = (change.id as usize)
1609 .checked_sub(1)
1610 .and_then(|idx| snapshot.items.get(idx));
1611 let title = item
1612 .map(|i| i.content.trim().to_string())
1613 .filter(|s| !s.is_empty())
1614 .unwrap_or_else(|| "(missing title)".to_string());
1615
1616 let (marker, marker_color) = checklist_status_marker(&change.status);
1617 let prefix = format!("{marker} ");
1618 let prefix_width =
1619 UnicodeWidthStr::width(TRANSCRIPT_RAIL) + UnicodeWidthStr::width(prefix.as_str());
1620 let id_label = format!("Todo #{}", change.id);
1621 let arrow = " \u{2192} ";
1622 let status_label = change.status.clone();
1623 let title_budget = usize::from(width)
1624 .saturating_sub(prefix_width)
1625 .saturating_sub(UnicodeWidthStr::width(id_label.as_str()))
1626 .saturating_sub(UnicodeWidthStr::width(arrow))
1627 .saturating_sub(UnicodeWidthStr::width(status_label.as_str()))
1628 .saturating_sub(2)
1629 .max(8);
1630 let title_truncated = truncate_text(title.as_str(), title_budget);
1631
1632 let spans = vec![
1633 Span::styled(
1634 "\u{258F} ".to_string(),
1635 Style::default().fg(palette::TEXT_DIM),
1636 ),
1637 Span::styled(prefix, Style::default().fg(marker_color)),
1638 Span::styled(id_label, Style::default().fg(palette::TEXT_DIM)),
1639 Span::styled(": ".to_string(), Style::default().fg(palette::TEXT_DIM)),
1640 Span::styled(title_truncated, tool_value_style()),
1641 Span::styled(arrow.to_string(), Style::default().fg(palette::TEXT_DIM)),
1642 Span::styled(status_label, Style::default().fg(marker_color)),
1643 ];
1644 lines.push(Line::from(spans));
1645
1646 // Tease that the full list is still available without leaving the
1647 // transcript. Mirrors the same affordance used by other tool cells.
1648 lines.push(render_card_detail_line_single(
1649 None,
1650 &format!(
1651 "{} item{} (Alt+V for full list)",
1652 snapshot.total,
1653 if snapshot.total == 1 { "" } else { "s" }
1654 ),
1655 Style::default().fg(palette::TEXT_MUTED),
1656 ));
1657 lines
1658 }
1659
1660 fn checklist_status_marker(status: &str) -> (&'static str, Color) {
1661 match status.to_ascii_lowercase().as_str() {
1662 "completed" | "done" => ("\u{2611}", palette::STATUS_SUCCESS), // ☑
1663 "in_progress" | "inprogress" | "running" => ("\u{25D0}", palette::DEEPSEEK_SKY), // ◐
1664 "blocked" | "failed" => ("\u{2717}", palette::STATUS_ERROR), // ✗
1665 "cancelled" | "canceled" | "skipped" => ("\u{2298}", palette::TEXT_MUTED), // ⊘
1666 _ => ("\u{2610}", palette::TEXT_MUTED), // ☐ pending
1667 }
1668 }
1669
1670 const CHECKLIST_LIVE_ITEM_LIMIT: usize = 8;
1671
1672 fn render_checklist_card(
1673 name: &str,
1674 status: ToolStatus,
1675 snapshot: &ChecklistSnapshot,
1676 width: u16,
1677 low_motion: bool,
1678 mode: RenderMode,
1679 ) -> Vec<Line<'static>> {
1680 let mut lines = Vec::new();
1681 let header_summary = format!(
1682 "{}/{} \u{00B7} {}%",
1683 snapshot.completed, snapshot.total, snapshot.completion_pct
1684 );
1685 let family = crate::tui::widgets::tool_card::tool_family_for_name(name);
1686 lines.push(render_tool_header_with_family_and_summary(
1687 family,
1688 Some(&header_summary),
1689 tool_status_label(status),
1690 status,
1691 None,
1692 low_motion,
1693 ));
1694 lines.extend(render_compact_kv(
1695 "checklist",
1696 name,
1697 tool_value_style(),
1698 width,
1699 ));
1700
1701 let cap = match mode {
1702 RenderMode::Live => CHECKLIST_LIVE_ITEM_LIMIT,
1703 RenderMode::Transcript => snapshot.items.len(),
1704 };
1705 let visible: Vec<&ChecklistItemSnapshot> = snapshot.items.iter().take(cap).collect();
1706 let omitted = snapshot.items.len().saturating_sub(visible.len());
1707
1708 for item in visible {
1709 let (marker, color) = checklist_status_marker(&item.status);
1710 let prefix = format!("{marker} ");
1711 // Reserve room for the rail + marker prefix when wrapping content.
1712 let prefix_width =
1713 UnicodeWidthStr::width(TRANSCRIPT_RAIL) + UnicodeWidthStr::width(prefix.as_str());
1714 let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
1715 for (idx, part) in wrap_text(item.content.trim(), content_width)
1716 .into_iter()
1717 .enumerate()
1718 {
1719 let mut spans = vec![Span::styled(
1720 "\u{258F} ".to_string(),
1721 Style::default().fg(palette::TEXT_DIM),
1722 )];
1723 if idx == 0 {
1724 spans.push(Span::styled(prefix.clone(), Style::default().fg(color)));
1725 } else {
1726 spans.push(Span::raw(
1727 " ".repeat(UnicodeWidthStr::width(prefix.as_str())),
1728 ));
1729 }
1730 spans.push(Span::styled(part, tool_value_style()));
1731 lines.push(Line::from(spans));
1732 }
1733 }
1734
1735 if omitted > 0 {
1736 lines.push(render_card_detail_line_single(
1737 None,
1738 &format!("+{omitted} more (Alt+V for full list)"),
1739 Style::default().fg(palette::TEXT_DIM),
1740 ));
1741 }
1742
1743 lines
1744 }
1745
1746 fn summarize_string_value(text: &str, max_len: usize, count_only: bool) -> String {
1747 let trimmed = text.trim();
1748 let len = trimmed.chars().count();
1749 if count_only || len > max_len {
1750 return format!("<{len} chars>");
1751 }
1752 truncate_text(trimmed, max_len)
1753 }
1754
1755 fn summarize_inline_value(value: &Value, max_len: usize, count_only: bool) -> String {
1756 match value {
1757 Value::String(s) => summarize_string_value(s, max_len, count_only),
1758 Value::Array(items) => format!("<{} items>", items.len()),
1759 Value::Object(map) => format!("<{} keys>", map.len()),
1760 Value::Bool(b) => b.to_string(),
1761 Value::Number(num) => num.to_string(),
1762 Value::Null => "null".to_string(),
1763 }
1764 }
1765
1766 #[must_use]
1767 pub fn summarize_tool_args(input: &Value) -> Option<String> {
1768 let obj = input.as_object()?;
1769 if obj.is_empty() {
1770 return None;
1771 }
1772
1773 let mut parts = Vec::new();
1774
1775 if let Some(value) = obj.get("path") {
1776 parts.push(format!(
1777 "path: {}",
1778 summarize_inline_value(value, 80, false)
1779 ));
1780 }
1781 if let Some(value) = obj.get("command") {
1782 parts.push(format!(
1783 "command: {}",
1784 summarize_inline_value(value, 80, false)
1785 ));
1786 }
1787 if let Some(value) = obj.get("query") {
1788 parts.push(format!(
1789 "query: {}",
1790 summarize_inline_value(value, 80, false)
1791 ));
1792 }
1793 if let Some(value) = obj.get("prompt") {
1794 parts.push(format!(
1795 "prompt: {}",
1796 summarize_inline_value(value, 80, false)
1797 ));
1798 }
1799 if let Some(value) = obj.get("text") {
1800 parts.push(format!(
1801 "text: {}",
1802 summarize_inline_value(value, 80, false)
1803 ));
1804 }
1805 if let Some(value) = obj.get("pattern") {
1806 parts.push(format!(
1807 "pattern: {}",
1808 summarize_inline_value(value, 80, false)
1809 ));
1810 }
1811 if let Some(value) = obj.get("model") {
1812 parts.push(format!(
1813 "model: {}",
1814 summarize_inline_value(value, 40, false)
1815 ));
1816 }
1817 if let Some(value) = obj.get("file_id") {
1818 parts.push(format!(
1819 "file_id: {}",
1820 summarize_inline_value(value, 40, false)
1821 ));
1822 }
1823 if let Some(value) = obj.get("task_id") {
1824 parts.push(format!(
1825 "task_id: {}",
1826 summarize_inline_value(value, 40, false)
1827 ));
1828 }
1829 if let Some(value) = obj.get("voice_id") {
1830 parts.push(format!(
1831 "voice_id: {}",
1832 summarize_inline_value(value, 40, false)
1833 ));
1834 }
1835 if let Some(value) = obj.get("content") {
1836 parts.push(format!(
1837 "content: {}",
1838 summarize_inline_value(value, 0, true)
1839 ));
1840 }
1841
1842 if parts.is_empty()
1843 && let Some((key, value)) = obj.iter().next()
1844 {
1845 return Some(format!(
1846 "{}: {}",
1847 key,
1848 summarize_inline_value(value, 80, false)
1849 ));
1850 }
1851
1852 if parts.is_empty() {
1853 None
1854 } else {
1855 Some(parts.join(", "))
1856 }
1857 }
1858
1859 #[must_use]
1860 pub fn summarize_tool_output(output: &str) -> String {
1861 if let Ok(json) = serde_json::from_str::<Value>(output) {
1862 if let Some(obj) = json.as_object() {
1863 if let Some(error) = obj.get("error").or(obj.get("status_msg")) {
1864 return format!("Error: {}", summarize_inline_value(error, 120, false));
1865 }
1866
1867 let mut parts = Vec::new();
1868
1869 if let Some(status) = obj.get("status").and_then(|v| v.as_str()) {
1870 parts.push(format!("status: {status}"));
1871 }
1872 if let Some(message) = obj.get("message").and_then(|v| v.as_str()) {
1873 parts.push(truncate_text(message, TOOL_TEXT_LIMIT));
1874 }
1875 if let Some(task_id) = obj.get("task_id").and_then(|v| v.as_str()) {
1876 parts.push(format!("task_id: {task_id}"));
1877 }
1878 if let Some(file_id) = obj.get("file_id").and_then(|v| v.as_str()) {
1879 parts.push(format!("file_id: {file_id}"));
1880 }
1881 if let Some(url) = obj
1882 .get("file_url")
1883 .or_else(|| obj.get("url"))
1884 .and_then(|v| v.as_str())
1885 {
1886 parts.push(format!("url: {}", truncate_text(url, 120)));
1887 }
1888 if let Some(data) = obj.get("data") {
1889 parts.push(format!("data: {}", summarize_inline_value(data, 80, true)));
1890 }
1891
1892 if !parts.is_empty() {
1893 return parts.join(" | ");
1894 }
1895
1896 if let Some(content) = obj
1897 .get("content")
1898 .or(obj.get("result"))
1899 .or(obj.get("output"))
1900 {
1901 return summarize_inline_value(content, TOOL_TEXT_LIMIT, false);
1902 }
1903 }
1904
1905 return summarize_inline_value(&json, TOOL_TEXT_LIMIT, true);
1906 }
1907
1908 truncate_text(output, TOOL_TEXT_LIMIT)
1909 }
1910
1911 // === MCP Output Summaries ===
1912
1913 /// Summary information extracted from an MCP tool output payload.
1914 pub struct McpOutputSummary {
1915 pub content: Option<String>,
1916 pub is_image: bool,
1917 pub is_error: Option<bool>,
1918 }
1919
1920 /// Summarize raw MCP output into UI-friendly content.
1921 #[must_use]
1922 pub fn summarize_mcp_output(output: &str) -> McpOutputSummary {
1923 if let Ok(json) = serde_json::from_str::<Value>(output) {
1924 let is_error = json
1925 .get("isError")
1926 .and_then(serde_json::Value::as_bool)
1927 .or_else(|| json.get("is_error").and_then(serde_json::Value::as_bool));
1928
1929 if let Some(blocks) = json.get("content").and_then(|v| v.as_array()) {
1930 let mut lines = Vec::new();
1931 let mut is_image = false;
1932
1933 for block in blocks {
1934 let block_type = block
1935 .get("type")
1936 .and_then(|v| v.as_str())
1937 .unwrap_or("unknown");
1938 match block_type {
1939 "text" => {
1940 let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
1941 if !text.is_empty() {
1942 lines.push(format!("- text: {}", truncate_text(text, 200)));
1943 }
1944 }
1945 "image" | "image_url" => {
1946 is_image = true;
1947 let url = block
1948 .get("url")
1949 .or_else(|| block.get("image_url"))
1950 .and_then(|v| v.as_str());
1951 if let Some(url) = url {
1952 lines.push(format!("- image: {}", truncate_text(url, 200)));
1953 } else {
1954 lines.push("- image".to_string());
1955 }
1956 }
1957 "resource" | "resource_link" => {
1958 let uri = block
1959 .get("uri")
1960 .or_else(|| block.get("url"))
1961 .and_then(|v| v.as_str())
1962 .unwrap_or("<resource>");
1963 lines.push(format!("- resource: {}", truncate_text(uri, 200)));
1964 }
1965 other => {
1966 lines.push(format!("- {other} content"));
1967 }
1968 }
1969 }
1970
1971 return McpOutputSummary {
1972 content: if lines.is_empty() {
1973 None
1974 } else {
1975 Some(lines.join("\n"))
1976 },
1977 is_image,
1978 is_error,
1979 };
1980 }
1981 }
1982
1983 McpOutputSummary {
1984 content: Some(summarize_tool_output(output)),
1985 is_image: output_is_image(output),
1986 is_error: None,
1987 }
1988 }
1989
1990 #[must_use]
1991 pub fn output_is_image(output: &str) -> bool {
1992 let lower = output.to_lowercase();
1993
1994 [
1995 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ppm",
1996 ]
1997 .iter()
1998 .any(|ext| lower.contains(ext))
1999 }
2000
2001 #[must_use]
2002 pub fn extract_reasoning_summary(text: &str) -> Option<String> {
2003 let mut lines = text.lines().peekable();
2004 while let Some(line) = lines.next() {
2005 let trimmed = line.trim();
2006 if trimmed.to_lowercase().starts_with("summary") {
2007 let mut summary = String::new();
2008 if let Some((_, rest)) = trimmed.split_once(':')
2009 && !rest.trim().is_empty()
2010 {
2011 summary.push_str(rest.trim());
2012 summary.push('\n');
2013 }
2014 while let Some(next) = lines.peek() {
2015 let next_trimmed = next.trim();
2016 if next_trimmed.is_empty() {
2017 break;
2018 }
2019 if next_trimmed.starts_with('#') || next_trimmed.starts_with("**") {
2020 break;
2021 }
2022 summary.push_str(next_trimmed);
2023 summary.push('\n');
2024 lines.next();
2025 }
2026 let summary = summary.trim().to_string();
2027 return if summary.is_empty() {
2028 None
2029 } else {
2030 Some(summary)
2031 };
2032 }
2033 }
2034 let fallback = text.trim();
2035 if fallback.is_empty() {
2036 None
2037 } else {
2038 Some(fallback.to_string())
2039 }
2040 }
2041
2042 fn render_thinking(
2043 content: &str,
2044 width: u16,
2045 streaming: bool,
2046 duration_secs: Option<f32>,
2047 collapsed: bool,
2048 low_motion: bool,
2049 ) -> Vec<Line<'static>> {
2050 let state = thinking_visual_state(streaming, duration_secs);
2051 let style = thinking_style();
2052 // 12% reasoning surface tint over the app ink — the only deliberately
2053 // warm element in the transcript. Dropped on Ansi-16 terminals where the
2054 // tint would distort the named palette.
2055 let depth = cached_color_depth();
2056 let body_bg = palette::reasoning_surface_tint(depth);
2057 let body_style = match body_bg {
2058 Some(bg) => style.italic().bg(bg),
2059 None => style.italic(),
2060 };
2061 let mut lines = Vec::new();
2062
2063 // Header: `…` opener (replaces the spinner; reasoning isn't a tool, it's
2064 // a slow exhale) followed by the `thinking` label and live status.
2065 let mut header_spans = vec![
2066 Span::styled(
2067 format!("{REASONING_OPENER} "),
2068 Style::default().fg(thinking_state_accent(state)),
2069 ),
2070 Span::styled("thinking", thinking_title_style()),
2071 ];
2072 header_spans.push(Span::styled(" ", Style::default()));
2073 header_spans.push(Span::styled(
2074 thinking_status_label(state),
2075 thinking_status_style(state),
2076 ));
2077 if let Some(dur) = duration_secs {
2078 header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
2079 header_spans.push(Span::styled(format!("{dur:.1}s"), thinking_meta_style()));
2080 }
2081 lines.push(Line::from(header_spans));
2082
2083 let content_width = width.saturating_sub(3).max(1);
2084 let body_text = if collapsed {
2085 extract_reasoning_summary(content).unwrap_or_else(|| content.trim().to_string())
2086 } else {
2087 content.to_string()
2088 };
2089 let mut rendered = markdown_render::render_markdown(&body_text, content_width, body_style);
2090 let mut truncated = false;
2091 if collapsed && rendered.len() > THINKING_SUMMARY_LINE_LIMIT {
2092 rendered.truncate(THINKING_SUMMARY_LINE_LIMIT);
2093 truncated = true;
2094 }
2095
2096 let rail_style = Style::default().fg(thinking_state_accent(state));
2097 let cursor_style = Style::default().fg(palette::ACCENT_REASONING_LIVE);
2098
2099 if rendered.is_empty() && streaming {
2100 let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
2101 spans.push(Span::styled(
2102 "reasoning in progress...",
2103 body_style.italic(),
2104 ));
2105 if !low_motion {
2106 spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
2107 }
2108 lines.push(Line::from(spans));
2109 }
2110
2111 let last_idx = rendered.len().saturating_sub(1);
2112 for (idx, line) in rendered.into_iter().enumerate() {
2113 let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
2114 spans.extend(line.spans);
2115 // Trailing cursor on the very last body line while streaming —
2116 // signals "still generating" without churning every line.
2117 if streaming && !low_motion && idx == last_idx {
2118 spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
2119 }
2120 lines.push(Line::from(spans));
2121 }
2122
2123 if collapsed && (!streaming && (truncated || body_text.trim() != content.trim())) {
2124 lines.push(Line::from(vec![
2125 Span::styled(REASONING_RAIL.to_string(), rail_style),
2126 Span::styled(
2127 "thinking collapsed; press Ctrl+O for full text",
2128 Style::default().fg(palette::TEXT_MUTED).italic(),
2129 ),
2130 ]));
2131 }
2132
2133 lines
2134 }
2135
2136 fn render_message(
2137 prefix: &str,
2138 label_style: Style,
2139 body_style: Style,
2140 content: &str,
2141 width: u16,
2142 ) -> Vec<Line<'static>> {
2143 let prefix_width = UnicodeWidthStr::width(prefix);
2144 let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
2145 let content_width = usize::from(width.saturating_sub(prefix_width_u16).max(1));
2146 let mut lines = Vec::new();
2147 let rendered = markdown_render::render_markdown(content, content_width as u16, body_style);
2148 for (idx, line) in rendered.into_iter().enumerate() {
2149 if idx == 0 {
2150 let mut spans = Vec::new();
2151 if !prefix.is_empty() {
2152 spans.push(Span::styled(
2153 prefix.to_string(),
2154 label_style.add_modifier(Modifier::BOLD),
2155 ));
2156 spans.push(Span::raw(" "));
2157 }
2158 spans.extend(line.spans);
2159 lines.push(Line::from(spans));
2160 } else {
2161 let indent = if prefix.is_empty() {
2162 String::new()
2163 } else {
2164 let mut s = String::with_capacity(prefix_width + 1);
2165 s.push('\u{258F}');
2166 s.extend(std::iter::repeat_n(' ', prefix_width));
2167 s
2168 };
2169 let rail_style = Style::default().fg(palette::TEXT_DIM);
2170 let mut spans = vec![Span::styled(indent, rail_style)];
2171 spans.extend(line.spans);
2172 lines.push(Line::from(spans));
2173 }
2174 }
2175 if lines.is_empty() {
2176 lines.push(Line::from(""));
2177 }
2178 lines
2179 }
2180
2181 fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec<Line<'static>> {
2182 let mut lines = Vec::new();
2183 let cap = match mode {
2184 RenderMode::Live => TOOL_COMMAND_LINE_LIMIT,
2185 RenderMode::Transcript => usize::MAX,
2186 };
2187 for (count, chunk) in wrap_text(command, width.saturating_sub(4).max(1) as usize)
2188 .into_iter()
2189 .enumerate()
2190 {
2191 if count >= cap {
2192 lines.push(details_affordance_line(
2193 "command clipped; Alt+V for details",
2194 Style::default().fg(palette::TEXT_MUTED),
2195 ));
2196 break;
2197 }
2198 lines.extend(render_card_detail_line(
2199 if count == 0 { Some("command") } else { None },
2200 chunk.as_str(),
2201 tool_value_style(),
2202 width,
2203 ));
2204 }
2205 lines
2206 }
2207
2208 fn command_header_summary(command: &str) -> String {
2209 command
2210 .lines()
2211 .next()
2212 .unwrap_or(command)
2213 .trim_start_matches("$ ")
2214 .trim()
2215 .to_string()
2216 }
2217
2218 fn exploring_header_summary(entries: &[ExploringEntry]) -> Option<String> {
2219 match entries {
2220 [] => None,
2221 [entry] => Some(entry.label.clone()),
2222 entries => Some(format!("{} items", entries.len())),
2223 }
2224 }
2225
2226 fn render_compact_kv(label: &str, value: &str, style: Style, width: u16) -> Vec<Line<'static>> {
2227 render_card_detail_line(Some(label.trim_end_matches(':')), value, style, width)
2228 }
2229
2230 fn render_tool_output_mode(
2231 output: &str,
2232 width: u16,
2233 line_limit: usize,
2234 mode: RenderMode,
2235 ) -> Vec<Line<'static>> {
2236 render_preserved_output_mode(output, width, line_limit, mode, "result")
2237 }
2238
2239 fn review_severity_color(severity: &str) -> Color {
2240 match severity {
2241 "error" => palette::STATUS_ERROR,
2242 "warning" => palette::STATUS_WARNING,
2243 _ => palette::STATUS_INFO,
2244 }
2245 }
2246
2247 fn format_review_location(path: Option<&String>, line: Option<u32>) -> String {
2248 let path = path.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
2249 match (path, line) {
2250 (Some(path), Some(line)) => format!("{path}:{line}"),
2251 (Some(path), None) => path,
2252 (None, Some(line)) => format!("line {line}"),
2253 (None, None) => String::new(),
2254 }
2255 }
2256
2257 fn render_exec_output_mode(
2258 output: &str,
2259 width: u16,
2260 line_limit: usize,
2261 mode: RenderMode,
2262 ) -> Vec<Line<'static>> {
2263 render_preserved_output_mode(output, width, line_limit, mode, "output")
2264 }
2265
2266 #[derive(Debug, Clone)]
2267 struct OutputRow {
2268 text: String,
2269 intact: bool,
2270 }
2271
2272 fn render_preserved_output_mode(
2273 output: &str,
2274 width: u16,
2275 line_limit: usize,
2276 mode: RenderMode,
2277 first_label: &str,
2278 ) -> Vec<Line<'static>> {
2279 let mut lines = Vec::new();
2280 if output.trim().is_empty() {
2281 lines.push(Line::from(Span::styled(
2282 " (no output)",
2283 Style::default().fg(palette::TEXT_MUTED).italic(),
2284 )));
2285 return lines;
2286 }
2287
2288 let all_lines = output_rows(output, width);
2289
2290 if matches!(mode, RenderMode::Transcript) {
2291 // Full-content path: emit every wrapped line with no head/tail split,
2292 // no "+N more" affordance.
2293 for (idx, row) in all_lines.iter().enumerate() {
2294 render_output_row(
2295 &mut lines,
2296 if idx == 0 { Some(first_label) } else { None },
2297 row,
2298 width,
2299 );
2300 }
2301 return lines;
2302 }
2303
2304 let selected = selected_output_indices(&all_lines, line_limit);
2305 let mut previous: Option<usize> = None;
2306 for (rendered_idx, idx) in selected.iter().copied().enumerate() {
2307 if let Some(prev) = previous {
2308 let omitted = idx.saturating_sub(prev + 1);
2309 if omitted > 0 {
2310 lines.push(details_affordance_line(
2311 &format!("{omitted} lines omitted; Alt+V for details"),
2312 Style::default().fg(palette::TEXT_MUTED),
2313 ));
2314 }
2315 }
2316
2317 let row = &all_lines[idx];
2318 render_output_row(
2319 &mut lines,
2320 if rendered_idx == 0 {
2321 Some(first_label)
2322 } else {
2323 None
2324 },
2325 row,
2326 width,
2327 );
2328 previous = Some(idx);
2329 }
2330
2331 lines
2332 }
2333
2334 fn output_rows(output: &str, width: u16) -> Vec<OutputRow> {
2335 let wrap_width = width.saturating_sub(4).max(1) as usize;
2336 let mut rows = Vec::new();
2337 let mut sanitized = String::with_capacity(output.len());
2338 for line in output.lines() {
2339 sanitized.clear();
2340 crate::tui::osc8::strip_ansi_into(line, &mut sanitized);
2341 let intact = is_path_or_url_like(&sanitized);
2342 if intact {
2343 rows.push(OutputRow {
2344 text: sanitized.clone(),
2345 intact: true,
2346 });
2347 } else {
2348 for wrapped in wrap_text(&sanitized, wrap_width) {
2349 rows.push(OutputRow {
2350 text: wrapped,
2351 intact: false,
2352 });
2353 }
2354 }
2355 }
2356 if rows.is_empty() {
2357 rows.push(OutputRow {
2358 text: String::new(),
2359 intact: false,
2360 });
2361 }
2362 rows
2363 }
2364
2365 fn selected_output_indices(rows: &[OutputRow], line_limit: usize) -> Vec<usize> {
2366 let total = rows.len();
2367 if total <= line_limit || line_limit == 0 {
2368 return (0..total).collect();
2369 }
2370
2371 let head = TOOL_OUTPUT_HEAD_LINES.min(line_limit).min(total);
2372 let tail = TOOL_OUTPUT_TAIL_LINES
2373 .min(line_limit.saturating_sub(head))
2374 .min(total.saturating_sub(head));
2375 let mut selected = std::collections::BTreeSet::new();
2376 selected.extend(0..head);
2377 selected.extend(total.saturating_sub(tail)..total);
2378
2379 let budget = line_limit.saturating_sub(selected.len());
2380 if budget > 0 {
2381 let mut important: Vec<(usize, usize)> = rows
2382 .iter()
2383 .enumerate()
2384 .skip(head)
2385 .take(total.saturating_sub(head + tail))
2386 .filter_map(|(idx, row)| output_importance_rank(&row.text).map(|rank| (idx, rank)))
2387 .collect();
2388 important.sort_by_key(|(idx, rank)| (*rank, *idx));
2389 for (idx, _) in important.into_iter().take(budget) {
2390 selected.insert(idx);
2391 }
2392 }
2393
2394 selected.into_iter().collect()
2395 }
2396
2397 fn output_importance_rank(line: &str) -> Option<usize> {
2398 let lower = line.to_ascii_lowercase();
2399 if [
2400 "error",
2401 "failed",
2402 "failure",
2403 "fatal",
2404 "panic",
2405 "exception",
2406 "traceback",
2407 "denied",
2408 "not found",
2409 "no such file",
2410 "cannot",
2411 "can't",
2412 ]
2413 .iter()
2414 .any(|needle| lower.contains(needle))
2415 {
2416 return Some(0);
2417 }
2418 if lower.contains("warning") || lower.contains("warn") {
2419 return Some(1);
2420 }
2421 if is_path_or_url_like(line) {
2422 return Some(2);
2423 }
2424 None
2425 }
2426
2427 fn is_path_or_url_like(line: &str) -> bool {
2428 let trimmed = line.trim();
2429 if trimmed.contains("://") || trimmed.starts_with("file:") {
2430 return true;
2431 }
2432 let has_separator = trimmed.contains('/') || trimmed.contains('\\');
2433 let has_extension = trimmed
2434 .split_whitespace()
2435 .any(|part| part.rsplit_once('.').is_some_and(|(_, ext)| ext.len() <= 8));
2436 has_separator && has_extension
2437 }
2438
2439 /// Detect whether a system message is a cycle-boundary announcement
2440 /// (e.g. `─── cycle 0 → 1 (briefing: 2500 tokens) ───`).
2441 fn is_cycle_boundary(content: &str) -> bool {
2442 content.contains("cycle")
2443 }
2444
2445 /// Render a cycle-boundary system message with distinct visual styling (#395):
2446 /// full-width line with DEEPSEEK_BLUE text and bold weight, plus a thin
2447 /// horizontal rule above for visual separation.
2448 fn render_cycle_boundary(content: &str, width: u16) -> Vec<Line<'static>> {
2449 let style = Style::default()
2450 .fg(palette::DEEPSEEK_BLUE)
2451 .add_modifier(Modifier::BOLD);
2452 let rule_style = Style::default().fg(palette::TEXT_DIM);
2453 let content_width = usize::from(width.saturating_sub(2).max(1));
2454 let mut lines = Vec::new();
2455 // Thin horizontal rule above for visual separation
2456 if width >= 4 {
2457 let rule = "\u{2500}".repeat(content_width);
2458 lines.push(Line::from(Span::styled(format!(" {rule}"), rule_style)));
2459 }
2460 // Cycle boundary text — just the content, full-width
2461 let rendered =
2462 crate::tui::markdown_render::render_markdown(content, content_width as u16, style);
2463 for line in rendered {
2464 let mut spans = vec![Span::raw(" ")];
2465 spans.extend(line.spans);
2466 lines.push(Line::from(spans));
2467 }
2468 if lines.len() == 1 && width >= 4 {
2469 // Only the rule was added (unlikely), but add at least a spacer
2470 lines.push(Line::from(""));
2471 }
2472 lines
2473 }
2474
2475 /// Detect whether a line contains a `path:line` pattern that could be
2476 /// opened by `try_open_file_at_line`. Returns a distinctive style
2477 /// (underline + blue) when the pattern matches, or `None` otherwise.
2478 /// The style is applied over the existing value style so the line
2479 /// remains readable.
2480 fn file_line_style(text: &str) -> Option<Style> {
2481 let trimmed = text.trim();
2482 if let Some((before, after)) = trimmed.rsplit_once(':')
2483 && !before.is_empty()
2484 && after.chars().all(|c| c.is_ascii_digit())
2485 && looks_like_file_path(before)
2486 {
2487 Some(
2488 Style::default()
2489 .fg(palette::DEEPSEEK_SKY)
2490 .add_modifier(Modifier::UNDERLINED),
2491 )
2492 } else {
2493 None
2494 }
2495 }
2496
2497 /// Apply inline diff highlighting to a single text line.
2498 ///
2499 /// Returns the appropriate style for the line based on its prefix:
2500 /// - Lines starting with `+` (after trimming) => `palette::DIFF_ADDED` (green)
2501 /// - Lines starting with `-` (after trimming) => `palette::STATUS_ERROR` (red)
2502 /// - Lines starting with `@@` => `palette::DEEPSEEK_SKY` (cyan/blue)
2503 /// - All other lines => None (use default style)
2504 fn diff_line_style(text: &str) -> Option<Style> {
2505 let trimmed = text.trim_start();
2506 if trimmed.starts_with("@@") {
2507 Some(Style::default().fg(palette::DEEPSEEK_BLUE))
2508 } else if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
2509 Some(Style::default().fg(palette::DIFF_ADDED))
2510 } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
2511 Some(Style::default().fg(palette::STATUS_ERROR))
2512 } else {
2513 None
2514 }
2515 }
2516
2517 fn render_output_row(
2518 lines: &mut Vec<Line<'static>>,
2519 label: Option<&str>,
2520 row: &OutputRow,
2521 width: u16,
2522 ) {
2523 // #374: apply file:line highlighting when the row text contains
2524 // a `path:line` pattern. Diff style takes precedence (colored
2525 // prefix lines should stay colored), but if no diff style matched,
2526 // check for a file:line pattern and highlight it distinctively.
2527 let diff_style = diff_line_style(&row.text);
2528 let file_style = file_line_style(&row.text);
2529 let value_style = diff_style.or(file_style).unwrap_or_else(tool_value_style);
2530 if row.intact {
2531 lines.push(render_card_detail_line_single(
2532 label,
2533 &row.text,
2534 value_style,
2535 ));
2536 } else {
2537 lines.extend(render_card_detail_line(
2538 label,
2539 &row.text,
2540 value_style,
2541 width,
2542 ));
2543 }
2544 }
2545
2546 fn wrap_plain_line(line: &str, style: Style, width: u16) -> Vec<Line<'static>> {
2547 let mut lines = Vec::new();
2548 for part in wrap_text(line, width.max(1) as usize) {
2549 lines.push(Line::from(Span::styled(part, style)));
2550 }
2551 lines
2552 }
2553
2554 fn wrap_text(text: &str, width: usize) -> Vec<String> {
2555 if width == 0 {
2556 return vec![text.to_string()];
2557 }
2558 if text.is_empty() {
2559 return vec![String::new()];
2560 }
2561
2562 let mut lines = Vec::new();
2563 let mut current = String::new();
2564
2565 for ch in text.chars() {
2566 let tentative = if current.is_empty() {
2567 ch.to_string()
2568 } else {
2569 let mut t = current.clone();
2570 t.push(ch);
2571 t
2572 };
2573
2574 if UnicodeWidthStr::width(tentative.as_str()) > width && !current.is_empty() {
2575 lines.push(std::mem::take(&mut current));
2576 }
2577
2578 current.push(ch);
2579 }
2580
2581 lines.push(current);
2582
2583 if lines.is_empty() {
2584 vec![String::new()]
2585 } else {
2586 lines
2587 }
2588 }
2589
2590 fn status_symbol(started_at: Option<Instant>, status: ToolStatus, low_motion: bool) -> String {
2591 match status {
2592 ToolStatus::Running => {
2593 if low_motion {
2594 return TOOL_RUNNING_SYMBOLS[0].to_string();
2595 }
2596 let elapsed_ms = started_at.map_or_else(
2597 || {
2598 std::time::SystemTime::now()
2599 .duration_since(std::time::UNIX_EPOCH)
2600 .map_or(0, |duration| duration.as_millis())
2601 },
2602 |t| t.elapsed().as_millis(),
2603 );
2604 let cycle = u128::from(TOOL_STATUS_SYMBOL_MS);
2605 let idx = elapsed_ms
2606 .checked_div(cycle)
2607 .map_or(0, |d| d % (TOOL_RUNNING_SYMBOLS.len() as u128));
2608 TOOL_RUNNING_SYMBOLS[usize::try_from(idx).unwrap_or_default()].to_string()
2609 }
2610 ToolStatus::Success => TOOL_DONE_SYMBOL.to_string(),
2611 ToolStatus::Failed => TOOL_FAILED_SYMBOL.to_string(),
2612 }
2613 }
2614
2615 fn details_affordance_line(text: &str, style: Style) -> Line<'static> {
2616 Line::from(vec![
2617 Span::styled(
2618 TRANSCRIPT_RAIL.to_string(),
2619 Style::default().fg(palette::TEXT_DIM),
2620 ),
2621 Span::styled(text.to_string(), style),
2622 ])
2623 }
2624
2625 fn truncate_text(text: &str, max_len: usize) -> String {
2626 if text.chars().count() <= max_len {
2627 return text.to_string();
2628 }
2629 let mut out = String::new();
2630 for ch in text.chars().take(max_len.saturating_sub(3)) {
2631 out.push(ch);
2632 }
2633 out.push_str("...");
2634 out
2635 }
2636
2637 fn user_label_style() -> Style {
2638 Style::default().fg(palette::TEXT_MUTED)
2639 }
2640
2641 /// Style for the assistant glyph (`●`). When the cell is streaming and
2642 /// motion is allowed, the foreground pulses on a 2s cycle between 30% and
2643 /// 100% brightness — the only deliberately animated element in a calm
2644 /// transcript. When idle (or low_motion is on) it sits at the full DeepSeek
2645 /// sky color so finished turns read as solid rather than dim.
2646 fn assistant_label_style_for(streaming: bool, low_motion: bool) -> Style {
2647 let color = if streaming && !low_motion {
2648 let now_ms = std::time::SystemTime::now()
2649 .duration_since(std::time::UNIX_EPOCH)
2650 .map(|d| d.as_millis() as u64)
2651 .unwrap_or(0);
2652 palette::pulse_brightness(palette::DEEPSEEK_SKY, now_ms)
2653 } else {
2654 palette::DEEPSEEK_SKY
2655 };
2656 Style::default().fg(color)
2657 }
2658
2659 fn system_label_style() -> Style {
2660 Style::default().fg(palette::TEXT_DIM)
2661 }
2662
2663 fn message_body_style() -> Style {
2664 Style::default().fg(palette::TEXT_PRIMARY)
2665 }
2666
2667 fn system_body_style() -> Style {
2668 Style::default().fg(palette::TEXT_MUTED).italic()
2669 }
2670
2671 /// Label glyph for an error cell. `Critical`/`Error` get the loudest marker;
2672 /// `Warning` is softer; `Info` is neutral. Kept as ASCII so it survives any
2673 /// terminal font fallback.
2674 fn error_label_text(severity: crate::error_taxonomy::ErrorSeverity) -> &'static str {
2675 match severity {
2676 crate::error_taxonomy::ErrorSeverity::Critical
2677 | crate::error_taxonomy::ErrorSeverity::Error => "Error",
2678 crate::error_taxonomy::ErrorSeverity::Warning => "Warn",
2679 crate::error_taxonomy::ErrorSeverity::Info => "Info",
2680 }
2681 }
2682
2683 /// Label color for an error cell — drives the leading rail glyph.
2684 fn error_label_style(severity: crate::error_taxonomy::ErrorSeverity) -> Style {
2685 let color = match severity {
2686 crate::error_taxonomy::ErrorSeverity::Critical
2687 | crate::error_taxonomy::ErrorSeverity::Error => palette::STATUS_ERROR,
2688 crate::error_taxonomy::ErrorSeverity::Warning => palette::STATUS_WARNING,
2689 crate::error_taxonomy::ErrorSeverity::Info => palette::TEXT_DIM,
2690 };
2691 Style::default().fg(color).add_modifier(Modifier::BOLD)
2692 }
2693
2694 /// Body color for an error cell — softer than the label so the rail draws
2695 /// the eye but the prose stays readable.
2696 fn error_body_style(severity: crate::error_taxonomy::ErrorSeverity) -> Style {
2697 let color = match severity {
2698 crate::error_taxonomy::ErrorSeverity::Critical
2699 | crate::error_taxonomy::ErrorSeverity::Error => palette::STATUS_ERROR,
2700 crate::error_taxonomy::ErrorSeverity::Warning => palette::STATUS_WARNING,
2701 crate::error_taxonomy::ErrorSeverity::Info => palette::TEXT_MUTED,
2702 };
2703 Style::default().fg(color)
2704 }
2705
2706 fn thinking_style() -> Style {
2707 Style::default().fg(palette::TEXT_TOOL_OUTPUT)
2708 }
2709
2710 fn render_tool_header(
2711 title: &str,
2712 state: &str,
2713 status: ToolStatus,
2714 started_at: Option<Instant>,
2715 low_motion: bool,
2716 ) -> Line<'static> {
2717 let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
2718 render_tool_header_with_family(family, state, status, started_at, low_motion)
2719 }
2720
2721 fn render_tool_header_with_summary(
2722 title: &str,
2723 summary: Option<&str>,
2724 state: &str,
2725 status: ToolStatus,
2726 started_at: Option<Instant>,
2727 low_motion: bool,
2728 ) -> Line<'static> {
2729 let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
2730 render_tool_header_with_family_and_summary(
2731 family, summary, state, status, started_at, low_motion,
2732 )
2733 }
2734
2735 /// Render a tool-card header with an explicit verb family. Lets callers
2736 /// (e.g. `GenericToolCell`) bypass the legacy title→family mapping when
2737 /// they already know the actual tool name.
2738 fn render_tool_header_with_family(
2739 family: crate::tui::widgets::tool_card::ToolFamily,
2740 state: &str,
2741 status: ToolStatus,
2742 started_at: Option<Instant>,
2743 low_motion: bool,
2744 ) -> Line<'static> {
2745 render_tool_header_with_family_and_summary(family, None, state, status, started_at, low_motion)
2746 }
2747
2748 fn render_tool_header_with_family_and_summary(
2749 family: crate::tui::widgets::tool_card::ToolFamily,
2750 summary: Option<&str>,
2751 state: &str,
2752 status: ToolStatus,
2753 started_at: Option<Instant>,
2754 low_motion: bool,
2755 ) -> Line<'static> {
2756 // For long-running tools, append elapsed seconds so the user can see the
2757 // call isn't stuck. Threshold matches the eye's "did this hang?" reflex
2758 // — under 3s we stay quiet so quick reads/greps don't visually churn.
2759 let state_owned: String = if state == "running"
2760 && status == ToolStatus::Running
2761 && let Some(started) = started_at
2762 {
2763 running_status_label_with_elapsed(started.elapsed().as_secs())
2764 } else {
2765 state.to_string()
2766 };
2767
2768 let glyph = crate::tui::widgets::tool_card::family_glyph(family);
2769 let verb = crate::tui::widgets::tool_card::family_label(family);
2770
2771 let mut spans = vec![
2772 Span::styled(
2773 format!("{} ", status_symbol(started_at, status, low_motion)),
2774 Style::default().fg(tool_state_color(status)),
2775 ),
2776 Span::styled(
2777 format!("{glyph} "),
2778 Style::default().fg(tool_state_color(status)),
2779 ),
2780 Span::styled(verb.to_string(), tool_title_style()),
2781 Span::styled(" ", Style::default()),
2782 Span::styled(state_owned, tool_status_style(status)),
2783 ];
2784
2785 if let Some(summary) = summary.and_then(normalize_header_summary) {
2786 spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
2787 spans.push(Span::styled(
2788 truncate_text(&summary, TOOL_HEADER_SUMMARY_LIMIT),
2789 Style::default().fg(palette::TEXT_MUTED),
2790 ));
2791 }
2792
2793 Line::from(spans)
2794 }
2795
2796 fn normalize_header_summary(summary: &str) -> Option<String> {
2797 let normalized = summary
2798 .split_whitespace()
2799 .collect::<Vec<_>>()
2800 .join(" ")
2801 .trim()
2802 .to_string();
2803 if normalized.is_empty() {
2804 None
2805 } else {
2806 Some(normalized)
2807 }
2808 }
2809
2810 /// Build the "running" label with an elapsed-seconds badge for long-running
2811 /// tools. Below 3s the badge is suppressed to avoid visual churn for tools
2812 /// that resolve in milliseconds; at 3s and beyond the badge appears and ticks
2813 /// every second the tool stays in flight.
2814 pub(crate) fn running_status_label_with_elapsed(elapsed_secs: u64) -> String {
2815 if elapsed_secs < 3 {
2816 "running".to_string()
2817 } else {
2818 format!("running ({elapsed_secs}s)")
2819 }
2820 }
2821
2822 fn render_card_detail_line(
2823 label: Option<&str>,
2824 value: &str,
2825 value_style: Style,
2826 width: u16,
2827 ) -> Vec<Line<'static>> {
2828 let label_text = label.map(|text| format!("{text}:"));
2829 let prefix_width = UnicodeWidthStr::width(TRANSCRIPT_RAIL)
2830 + label_text.as_deref().map_or(0, UnicodeWidthStr::width)
2831 + usize::from(label.is_some());
2832 let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
2833
2834 let mut lines = Vec::new();
2835 for (idx, part) in wrap_text(value, content_width).into_iter().enumerate() {
2836 let mut spans = vec![Span::styled(
2837 TRANSCRIPT_RAIL.to_string(),
2838 Style::default().fg(palette::TEXT_DIM),
2839 )];
2840 if idx == 0 {
2841 if let Some(label_text) = label_text.as_deref() {
2842 spans.push(Span::styled(
2843 label_text.to_string(),
2844 tool_detail_label_style(),
2845 ));
2846 spans.push(Span::raw(" "));
2847 }
2848 } else if let Some(label_text) = label_text.as_deref() {
2849 spans.push(Span::raw(
2850 " ".repeat(UnicodeWidthStr::width(label_text) + 1),
2851 ));
2852 }
2853 spans.push(Span::styled(part, value_style));
2854 lines.push(Line::from(spans));
2855 }
2856 lines
2857 }
2858
2859 fn render_card_detail_line_single(
2860 label: Option<&str>,
2861 value: &str,
2862 value_style: Style,
2863 ) -> Line<'static> {
2864 let label_text = label.map(|text| format!("{text}:"));
2865 let mut spans = vec![Span::styled(
2866 TRANSCRIPT_RAIL.to_string(),
2867 Style::default().fg(palette::TEXT_DIM),
2868 )];
2869 if let Some(label_text) = label_text {
2870 spans.push(Span::styled(label_text, tool_detail_label_style()));
2871 spans.push(Span::raw(" "));
2872 }
2873 spans.push(Span::styled(value.to_string(), value_style));
2874 Line::from(spans)
2875 }
2876
2877 fn tool_title_style() -> Style {
2878 active_theme().tool_title_style()
2879 }
2880
2881 fn tool_status_style(status: ToolStatus) -> Style {
2882 active_theme().tool_status_style(status)
2883 }
2884
2885 fn tool_detail_label_style() -> Style {
2886 active_theme().tool_label_style()
2887 }
2888
2889 fn tool_state_color(status: ToolStatus) -> Color {
2890 active_theme().tool_status_color(status)
2891 }
2892
2893 fn tool_status_label(status: ToolStatus) -> &'static str {
2894 match status {
2895 ToolStatus::Running => "running",
2896 ToolStatus::Success => "done",
2897 ToolStatus::Failed => "issue",
2898 }
2899 }
2900
2901 fn tool_value_style() -> Style {
2902 active_theme().tool_value_style()
2903 }
2904
2905 fn thinking_visual_state(streaming: bool, duration_secs: Option<f32>) -> ThinkingVisualState {
2906 if streaming {
2907 ThinkingVisualState::Live
2908 } else if duration_secs.is_some() {
2909 ThinkingVisualState::Done
2910 } else {
2911 ThinkingVisualState::Idle
2912 }
2913 }
2914
2915 fn thinking_status_label(state: ThinkingVisualState) -> &'static str {
2916 match state {
2917 ThinkingVisualState::Live => "live",
2918 ThinkingVisualState::Done => "done",
2919 ThinkingVisualState::Idle => "idle",
2920 }
2921 }
2922
2923 fn thinking_title_style() -> Style {
2924 Style::default()
2925 .fg(palette::TEXT_SOFT)
2926 .add_modifier(Modifier::BOLD)
2927 }
2928
2929 fn thinking_status_style(state: ThinkingVisualState) -> Style {
2930 Style::default().fg(match state {
2931 ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
2932 ThinkingVisualState::Done => palette::TEXT_DIM,
2933 ThinkingVisualState::Idle => palette::TEXT_DIM,
2934 })
2935 }
2936
2937 fn thinking_meta_style() -> Style {
2938 Style::default().fg(palette::TEXT_DIM)
2939 }
2940
2941 fn thinking_state_accent(state: ThinkingVisualState) -> Color {
2942 match state {
2943 ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
2944 ThinkingVisualState::Done => palette::TEXT_DIM,
2945 ThinkingVisualState::Idle => palette::TEXT_DIM,
2946 }
2947 }
2948
2949 // === Cached colour depth ===
2950
2951 /// Once-initialised colour depth for the terminal session. Avoids re-reading
2952 /// `COLORTERM` / `TERM` env vars on every frame.
2953 static COLOR_DEPTH: std::sync::OnceLock<palette::ColorDepth> = std::sync::OnceLock::new();
2954
2955 fn cached_color_depth() -> palette::ColorDepth {
2956 *COLOR_DEPTH.get_or_init(palette::ColorDepth::detect)
2957 }
2958
2959 /// Parse `path:line` patterns from `text` and open the file at the given line
2960 /// in the user's preferred editor (`$VISUAL` / `$EDITOR` / `vim`).
2961 ///
2962 /// Scans lines of `text` for patterns like `src/main.rs:42`. Resolves the path
2963 /// relative to `workspace` (if not absolute) and opens the editor. Returns
2964 /// `true` if at least one file was opened successfully.
2965 pub fn try_open_file_at_line(text: &str, workspace: &Path) -> bool {
2966 let editor = std::env::var("VISUAL")
2967 .ok()
2968 .filter(|s| !s.trim().is_empty())
2969 .or_else(|| {
2970 std::env::var("EDITOR")
2971 .ok()
2972 .filter(|s| !s.trim().is_empty())
2973 })
2974 .unwrap_or_else(|| "vim".to_string());
2975
2976 let mut any_opened = false;
2977 for line in text.lines() {
2978 let trimmed = line.trim();
2979 if let Some((before, after)) = trimmed.rsplit_once(':')
2980 && after.chars().all(|c| c.is_ascii_digit())
2981 {
2982 let line_num: u32 = after.parse().unwrap_or(1);
2983 let path_str = before.trim();
2984 if !path_str.is_empty() && looks_like_file_path(path_str) {
2985 let abs_path = if Path::new(path_str).is_absolute() {
2986 PathBuf::from(path_str)
2987 } else {
2988 workspace.join(path_str)
2989 };
2990 if abs_path.is_file()
2991 && Command::new(&editor)
2992 .arg(format!("+{line_num}"))
2993 .arg(&abs_path)
2994 .spawn()
2995 .is_ok()
2996 {
2997 any_opened = true;
2998 }
2999 }
3000 }
3001 }
3002 any_opened
3003 }
3004
3005 /// Heuristic check whether a string looks like a file path (contains a
3006 /// directory separator or a known source file extension).
3007 fn looks_like_file_path(s: &str) -> bool {
3008 if s.contains('/') || s.contains('\\') {
3009 return true;
3010 }
3011 // Check for a known file extension
3012 if let Some((_, ext)) = s.rsplit_once('.') {
3013 let ext = ext.trim();
3014 matches!(
3015 ext,
3016 "rs" | "toml"
3017 | "md"
3018 | "sh"
3019 | "py"
3020 | "js"
3021 | "ts"
3022 | "json"
3023 | "yaml"
3024 | "yml"
3025 | "css"
3026 | "html"
3027 | "go"
3028 | "c"
3029 | "h"
3030 | "cpp"
3031 | "hpp"
3032 | "java"
3033 | "kt"
3034 | "swift"
3035 | "rb"
3036 | "php"
3037 | "lua"
3038 | "zig"
3039 | "mod"
3040 | "sum"
3041 | "lock"
3042 | "txt"
3043 | "ini"
3044 | "cfg"
3045 | "conf"
3046 | "env"
3047 | "gitignore"
3048 | "dockerfile"
3049 | "sql"
3050 | "r"
3051 | "ex"
3052 | "exs"
3053 | "vue"
3054 | "svelte"
3055 | "tsx"
3056 | "jsx"
3057 | "scss"
3058 | "sass"
3059 | "less"
3060 | "gradle"
3061 | "properties"
3062 | "xml"
3063 | "proto"
3064 | "nix"
3065 )
3066 } else {
3067 false
3068 }
3069 }
3070
3071 #[cfg(test)]
3072 mod tests {
3073 use super::{
3074 ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanStep,
3075 PlanUpdateCell, REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS,
3076 TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions, USER_GLYPH,
3077 assistant_label_style_for, extract_reasoning_summary, render_thinking,
3078 running_status_label_with_elapsed,
3079 };
3080 use crate::deepseek_theme::Theme;
3081 use crate::models::{ContentBlock, Message};
3082 use crate::palette;
3083 use ratatui::style::Modifier;
3084 use std::time::{Duration, Instant};
3085
3086 // ---- elapsed-seconds badge for long-running tools ----
3087 //
3088 // Below 3s the label stays "running" — quick reads/greps shouldn't
3089 // visually churn. From 3s onward the badge appears and ticks each
3090 // second so the user can tell the call hasn't hung.
3091 // ---- #423 spillover-path UI annotation ----
3092 //
3093 // When a tool result carries a `spillover_path` (set by the
3094 // tool-routing layer when the tool's `metadata.spillover_path` is
3095 // populated), the live render appends a one-line muted hint
3096 // pointing at the file. Transcript-mode replay leaves the hint
3097 // off because the full output is already inline.
3098
3099 #[test]
3100 fn render_spillover_annotation_shows_path() {
3101 use std::path::PathBuf;
3102 let cell = GenericToolCell {
3103 name: "exec_shell".to_string(),
3104 status: ToolStatus::Success,
3105 input_summary: Some("cmd: cargo build --release".to_string()),
3106 output: Some("very large output...".to_string()),
3107 prompts: None,
3108 spillover_path: Some(PathBuf::from(
3109 "/Users/dev/.deepseek/tool_outputs/call-abc12.txt",
3110 )),
3111 };
3112 let lines = cell.lines_with_mode(120, true, super::RenderMode::Live);
3113 let joined: String = lines
3114 .iter()
3115 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3116 .collect();
3117 assert!(
3118 joined.contains("full output:"),
3119 "expected annotation prefix: {joined:?}"
3120 );
3121 assert!(
3122 joined.contains("/Users/dev/.deepseek/tool_outputs/call-abc12.txt"),
3123 "expected the spillover path: {joined:?}"
3124 );
3125 }
3126
3127 #[test]
3128 fn render_spillover_annotation_omitted_in_transcript_mode() {
3129 use std::path::PathBuf;
3130 // Transcript mode is for replay; the full output is already
3131 // inline so the annotation would just be redundant.
3132 let cell = GenericToolCell {
3133 name: "exec_shell".to_string(),
3134 status: ToolStatus::Success,
3135 input_summary: None,
3136 output: Some("output".to_string()),
3137 prompts: None,
3138 spillover_path: Some(PathBuf::from("/tmp/spill.txt")),
3139 };
3140 let lines = cell.lines_with_mode(120, true, super::RenderMode::Transcript);
3141 let joined: String = lines
3142 .iter()
3143 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3144 .collect();
3145 assert!(
3146 !joined.contains("full output:"),
3147 "annotation should be omitted in transcript mode: {joined:?}"
3148 );
3149 }
3150
3151 #[test]
3152 fn render_spillover_annotation_omitted_when_no_path_set() {
3153 // The common case: most tool results don't trigger spillover.
3154 let cell = GenericToolCell {
3155 name: "read_file".to_string(),
3156 status: ToolStatus::Success,
3157 input_summary: None,
3158 output: Some("contents".to_string()),
3159 prompts: None,
3160 spillover_path: None,
3161 };
3162 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
3163 let joined: String = lines
3164 .iter()
3165 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3166 .collect();
3167 assert!(!joined.contains("full output:"), "{joined:?}");
3168 }
3169
3170 #[test]
3171 fn render_spillover_annotation_truncates_to_width() {
3172 use std::path::PathBuf;
3173 let long_path = "/Users/dev/.deepseek/tool_outputs/this-is-a-very-long-tool-call-id-that-will-not-fit-in-narrow-widths.txt";
3174 let cell = GenericToolCell {
3175 name: "exec_shell".to_string(),
3176 status: ToolStatus::Success,
3177 input_summary: None,
3178 output: Some("output".to_string()),
3179 prompts: None,
3180 spillover_path: Some(PathBuf::from(long_path)),
3181 };
3182 let lines = cell.lines_with_mode(40, true, super::RenderMode::Live);
3183 let annotation_line = lines
3184 .iter()
3185 .find(|l| {
3186 l.spans
3187 .iter()
3188 .any(|s| s.content.as_ref().contains("full output:"))
3189 })
3190 .expect("annotation line present");
3191 let rendered: String = annotation_line
3192 .spans
3193 .iter()
3194 .map(|s| s.content.as_ref())
3195 .collect();
3196 // Width budget is 40; annotation line should be at most ~40 chars.
3197 // (Some slack for the prefix; the truncate_text ellipsis costs
3198 // 3 cols.)
3199 assert!(
3200 rendered.chars().count() <= 60,
3201 "annotation overflowed at width 40: {} chars: {rendered:?}",
3202 rendered.chars().count()
3203 );
3204 }
3205
3206 // ---- #409 compact agent_spawn rendering ----
3207 //
3208 // The DelegateCard owns live state for spawned sub-agents; the
3209 // generic tool block previously duplicated that signal at 3-4 lines
3210 // per spawn. In live mode we now render a single compact line that
3211 // points at the spawned agent id; transcript-mode replay keeps the
3212 // full block so debug history is intact.
3213
3214 #[test]
3215 fn extract_agent_id_pulls_id_from_json_output() {
3216 let output =
3217 r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#;
3218 assert_eq!(super::extract_agent_id(output), Some("agent-abc12"));
3219 }
3220
3221 #[test]
3222 fn extract_agent_id_handles_extra_whitespace() {
3223 let output = r#"{
3224 "agent_id" : "agent-xyz",
3225 "model": "x"
3226 }"#;
3227 assert_eq!(super::extract_agent_id(output), Some("agent-xyz"));
3228 }
3229
3230 #[test]
3231 fn extract_agent_id_returns_none_when_missing() {
3232 let output = r#"{"nickname": "Orca", "model": "x"}"#;
3233 assert!(super::extract_agent_id(output).is_none());
3234 assert!(super::extract_agent_id("(not json)").is_none());
3235 assert!(super::extract_agent_id("").is_none());
3236 }
3237
3238 #[test]
3239 fn extract_agent_id_returns_none_for_empty_id() {
3240 let output = r#"{"agent_id": "", "model": "x"}"#;
3241 assert!(super::extract_agent_id(output).is_none());
3242 }
3243
3244 #[test]
3245 fn agent_spawn_renders_single_compact_line_in_live_mode() {
3246 let cell = GenericToolCell {
3247 name: "agent_spawn".to_string(),
3248 status: ToolStatus::Running,
3249 input_summary: Some("prompt: do thing".to_string()),
3250 output: Some(
3251 r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#
3252 .to_string(),
3253 ),
3254 prompts: None,
3255 spillover_path: None,
3256 };
3257 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
3258 // One header line, no details/args/output expansion.
3259 assert_eq!(lines.len(), 1, "expected exactly 1 line, got {:?}", lines);
3260 let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
3261 // Header carries the agent id and the running status.
3262 assert!(
3263 rendered.contains("agent-abc12"),
3264 "expected agent id in header: {rendered:?}"
3265 );
3266 assert!(
3267 rendered.contains("running"),
3268 "expected status in header: {rendered:?}"
3269 );
3270 // No verbose `args:` / `name:` rows.
3271 assert!(
3272 !rendered.contains("args"),
3273 "args should be hidden: {rendered:?}"
3274 );
3275 }
3276
3277 #[test]
3278 fn agent_spawn_pending_render_uses_placeholder_id() {
3279 // No output yet → use the … placeholder so the user still sees a
3280 // header line during the brief gap between tool-call-started and
3281 // the spawn returning the agent_id.
3282 let cell = GenericToolCell {
3283 name: "agent_spawn".to_string(),
3284 status: ToolStatus::Running,
3285 input_summary: Some("prompt: do thing".to_string()),
3286 output: None,
3287 prompts: None,
3288 spillover_path: None,
3289 };
3290 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
3291 assert_eq!(lines.len(), 1);
3292 let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
3293 assert!(rendered.contains('\u{2026}'), "{rendered:?}"); // …
3294 }
3295
3296 #[test]
3297 fn agent_spawn_transcript_mode_keeps_full_block() {
3298 // Transcript mode is for replay/debug — preserve the full block
3299 // so session export still carries the args/output verbatim.
3300 let cell = GenericToolCell {
3301 name: "agent_spawn".to_string(),
3302 status: ToolStatus::Success,
3303 input_summary: Some("prompt: do thing".to_string()),
3304 output: Some(
3305 r#"{"agent_id": "agent-abc12", "model": "deepseek-v4-flash"}"#.to_string(),
3306 ),
3307 prompts: None,
3308 spillover_path: None,
3309 };
3310 let lines = cell.lines_with_mode(80, true, super::RenderMode::Transcript);
3311 // Transcript mode emits header + name kv + (no args, output present)
3312 // + output rows. At minimum more than the live one-liner.
3313 assert!(lines.len() > 1, "expected verbose transcript render");
3314 }
3315
3316 #[test]
3317 fn other_tools_are_unaffected_by_agent_spawn_compact_path() {
3318 // Only `agent_spawn` is collapsed — `read_file` and friends
3319 // continue to render their normal multi-line block in live mode.
3320 let cell = GenericToolCell {
3321 name: "read_file".to_string(),
3322 status: ToolStatus::Success,
3323 input_summary: Some("path: foo.rs".to_string()),
3324 output: Some("first line\nsecond line\nthird line".to_string()),
3325 prompts: None,
3326 spillover_path: None,
3327 };
3328 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
3329 assert!(
3330 lines.len() > 1,
3331 "non-spawn tools should keep their full block"
3332 );
3333 }
3334
3335 // ---- #403 concise todo / checklist update rendering ----
3336 //
3337 // The tool emits an "Updated todo #N to STATUS" leading line plus a
3338 // JSON snapshot. The renderer should detect the prefix and produce
3339 // a compact one-line state-change card instead of dumping the full
3340 // item list every time.
3341
3342 #[test]
3343 fn parse_update_prefix_recognises_todo_form() {
3344 let parsed =
3345 super::parse_update_prefix("Updated todo #3 to in_progress\n{ \"items\": [...] }");
3346 assert_eq!(
3347 parsed,
3348 Some(super::ChecklistChange {
3349 id: 3,
3350 status: "in_progress".to_string(),
3351 }),
3352 );
3353 }
3354
3355 #[test]
3356 fn parse_update_prefix_recognises_checklist_form() {
3357 let parsed =
3358 super::parse_update_prefix("Updated checklist #7 to completed\n{ \"items\": [] }");
3359 assert_eq!(
3360 parsed,
3361 Some(super::ChecklistChange {
3362 id: 7,
3363 status: "completed".to_string(),
3364 }),
3365 );
3366 }
3367
3368 #[test]
3369 fn parse_update_prefix_returns_none_for_writes() {
3370 // `todo_write` / `checklist_write` outputs don't start with
3371 // "Updated …" — they should fall through to the full-card path.
3372 assert!(super::parse_update_prefix("{ \"items\": [] }").is_none());
3373 assert!(super::parse_update_prefix("Wrote 5 todos\n{}").is_none());
3374 }
3375
3376 #[test]
3377 fn parse_update_prefix_returns_none_for_malformed() {
3378 // Missing arrow/status → fall through.
3379 assert!(super::parse_update_prefix("Updated todo #3\n").is_none());
3380 // Non-numeric id → fall through.
3381 assert!(super::parse_update_prefix("Updated todo #foo to done\n").is_none());
3382 }
3383
3384 #[test]
3385 fn render_checklist_change_card_shows_only_changed_item() {
3386 // Build a snapshot with three items; render the change for #2.
3387 let snapshot = super::ChecklistSnapshot {
3388 items: vec![
3389 super::ChecklistItemSnapshot {
3390 content: "Read the spec".to_string(),
3391 status: "completed".to_string(),
3392 },
3393 super::ChecklistItemSnapshot {
3394 content: "Write the test".to_string(),
3395 status: "in_progress".to_string(),
3396 },
3397 super::ChecklistItemSnapshot {
3398 content: "Land the PR".to_string(),
3399 status: "pending".to_string(),
3400 },
3401 ],
3402 completion_pct: 33,
3403 completed: 1,
3404 total: 3,
3405 };
3406 let change = super::ChecklistChange {
3407 id: 2,
3408 status: "in_progress".to_string(),
3409 };
3410 let lines = super::render_checklist_change_card(
3411 "todo_update",
3412 ToolStatus::Success,
3413 &snapshot,
3414 &change,
3415 80,
3416 true,
3417 );
3418 // Header + change line + summary affordance = 3 lines.
3419 assert!(lines.len() >= 3, "expected ≥3 lines, got {}", lines.len());
3420
3421 // The change line should mention the title and the new status,
3422 // and should NOT include the other two item titles (that's the
3423 // whole point — concise rendering).
3424 let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
3425 assert!(change_line.contains("#2"), "missing id: {change_line:?}");
3426 assert!(
3427 change_line.contains("Write the test"),
3428 "missing title: {change_line:?}"
3429 );
3430 assert!(
3431 change_line.contains("in_progress"),
3432 "missing status: {change_line:?}"
3433 );
3434 assert!(
3435 !change_line.contains("Land the PR"),
3436 "should not show other items: {change_line:?}"
3437 );
3438 assert!(
3439 !change_line.contains("Read the spec"),
3440 "should not show other items: {change_line:?}"
3441 );
3442
3443 // The summary line carries the count + Alt+V hint.
3444 let summary_line: String = lines
3445 .last()
3446 .unwrap()
3447 .spans
3448 .iter()
3449 .map(|s| s.content.as_ref())
3450 .collect();
3451 assert!(summary_line.contains("3 items"), "{summary_line:?}");
3452 assert!(summary_line.contains("Alt+V"), "{summary_line:?}");
3453 }
3454
3455 #[test]
3456 fn render_checklist_change_card_handles_missing_title_gracefully() {
3457 // If the change targets an out-of-range id, the title falls
3458 // back to a placeholder rather than crashing.
3459 let snapshot = super::ChecklistSnapshot {
3460 items: vec![super::ChecklistItemSnapshot {
3461 content: "only item".to_string(),
3462 status: "pending".to_string(),
3463 }],
3464 completion_pct: 0,
3465 completed: 0,
3466 total: 1,
3467 };
3468 let change = super::ChecklistChange {
3469 id: 99,
3470 status: "completed".to_string(),
3471 };
3472 let lines = super::render_checklist_change_card(
3473 "todo_update",
3474 ToolStatus::Success,
3475 &snapshot,
3476 &change,
3477 80,
3478 true,
3479 );
3480 let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
3481 assert!(change_line.contains("#99"));
3482 assert!(change_line.contains("(missing title)"));
3483 }
3484
3485 #[test]
3486 fn running_status_label_omits_elapsed_below_threshold() {
3487 assert_eq!(running_status_label_with_elapsed(0), "running");
3488 assert_eq!(running_status_label_with_elapsed(1), "running");
3489 assert_eq!(running_status_label_with_elapsed(2), "running");
3490 }
3491
3492 #[test]
3493 fn running_status_label_appends_elapsed_at_three_seconds() {
3494 assert_eq!(running_status_label_with_elapsed(3), "running (3s)");
3495 assert_eq!(running_status_label_with_elapsed(7), "running (7s)");
3496 assert_eq!(running_status_label_with_elapsed(120), "running (120s)");
3497 }
3498
3499 #[test]
3500 fn extract_reasoning_summary_prefers_summary_block() {
3501 let text = "Thinking...\nSummary: First line\nSecond line\n\nTail";
3502 let summary = extract_reasoning_summary(text).expect("summary should exist");
3503 assert_eq!(summary, "First line\nSecond line");
3504 }
3505
3506 #[test]
3507 fn extract_reasoning_summary_falls_back_to_full_text() {
3508 let text = "Line one\nLine two";
3509 let summary = extract_reasoning_summary(text).expect("summary should exist");
3510 assert_eq!(summary, "Line one\nLine two");
3511 }
3512
3513 #[test]
3514 fn archived_context_metadata_preserves_spaces_in_attributes() {
3515 let msg = Message {
3516 role: "assistant".to_string(),
3517 content: vec![ContentBlock::Text {
3518 text: "<archived_context level=\"1\" range=\"msg 0-128\" tokens=\"2499\" density=\"~2,500 tokens\" model=\"deepseek-v4-flash\" timestamp=\"2026-04-28T00:00:00Z\">\nSummary body\n</archived_context>".to_string(),
3519 cache_control: None,
3520 }],
3521 };
3522
3523 let cells = super::history_cells_from_message(&msg);
3524 assert_eq!(cells.len(), 1);
3525 let HistoryCell::ArchivedContext {
3526 level,
3527 range,
3528 tokens,
3529 density,
3530 model,
3531 timestamp,
3532 summary,
3533 } = &cells[0]
3534 else {
3535 panic!("expected archived context cell");
3536 };
3537
3538 assert_eq!(*level, 1);
3539 assert_eq!(range, "msg 0-128");
3540 assert_eq!(tokens, "2499");
3541 assert_eq!(density, "~2,500 tokens");
3542 assert_eq!(model, "deepseek-v4-flash");
3543 assert_eq!(timestamp, "2026-04-28T00:00:00Z");
3544 assert_eq!(summary, "Summary body");
3545 }
3546
3547 #[test]
3548 fn render_thinking_collapsed_shows_details_affordance() {
3549 let lines = render_thinking(
3550 "Summary: First line\nSecond line\nThird line\nFourth line\nFifth line",
3551 80,
3552 false,
3553 Some(2.0),
3554 true,
3555 false,
3556 );
3557 let text = lines
3558 .iter()
3559 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
3560 .collect::<String>();
3561 assert!(text.contains("thinking collapsed; press Ctrl+O for full text"));
3562 assert!(text.contains("thinking"));
3563 }
3564
3565 #[test]
3566 fn tool_lines_with_options_respects_low_motion_in_default_path() {
3567 // Use a 2× cycle offset so the animated frame lands on index 2,
3568 // which is maximally far from index 0. This avoids flaky failures on
3569 // platforms with coarse timer resolution (Windows ≈ 15.6 ms) and
3570 // gives 3600 ms of headroom before the index could wrap back to 0
3571 // (indices 2 → 3 → 0 requires two more full cycles).
3572 let started_at = Some(Instant::now() - Duration::from_millis(TOOL_STATUS_SYMBOL_MS * 2));
3573 let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
3574 command: "echo hi".to_string(),
3575 status: ToolStatus::Running,
3576 output: None,
3577 started_at,
3578 duration_ms: None,
3579 source: ExecSource::Assistant,
3580 interaction: None,
3581 }));
3582
3583 let animated = cell.lines_with_options(80, TranscriptRenderOptions::default());
3584 let low_motion = cell.lines_with_options(
3585 80,
3586 TranscriptRenderOptions {
3587 low_motion: true,
3588 ..TranscriptRenderOptions::default()
3589 },
3590 );
3591
3592 let animated_symbol = animated[0].spans[0].content.trim();
3593 let low_motion_symbol = low_motion[0].spans[0].content.trim();
3594
3595 // low_motion always pins to the first (static) frame.
3596 assert_eq!(low_motion_symbol, TOOL_RUNNING_SYMBOLS[0]);
3597 // The animated path should be on a different frame (index 2).
3598 assert_ne!(animated_symbol, TOOL_RUNNING_SYMBOLS[0]);
3599 }
3600
3601 // === Speaker glyph tests (v0.6.6 UI redesign) ===
3602 //
3603 // The literal "Assistant" / "You" labels are replaced by the calmer
3604 // bullet/bar glyphs (`●` / `▎`). Only the assistant glyph pulses, and
3605 // only while the cell is streaming — finished turns sit at the source
3606 // sky color so the transcript reads as solid history.
3607
3608 #[test]
3609 fn user_cell_renders_with_bar_glyph_not_literal_label() {
3610 let cell = HistoryCell::User {
3611 content: "hello".to_string(),
3612 };
3613 let lines = cell.lines(80);
3614 let head = &lines[0];
3615 assert_eq!(head.spans[0].content.as_ref(), USER_GLYPH);
3616 // No "You" literal anywhere in the rendered head line.
3617 let visible: String = head
3618 .spans
3619 .iter()
3620 .map(|s| s.content.as_ref())
3621 .collect::<String>();
3622 assert!(!visible.contains("You"), "user label dropped: {visible:?}");
3623 assert!(visible.contains("hello"));
3624 }
3625
3626 #[test]
3627 fn assistant_cell_renders_with_bullet_glyph_not_literal_label() {
3628 let cell = HistoryCell::Assistant {
3629 content: "ready".to_string(),
3630 streaming: false,
3631 };
3632 let lines = cell.lines(80);
3633 let head = &lines[0];
3634 assert_eq!(head.spans[0].content.as_ref(), ASSISTANT_GLYPH);
3635 let visible: String = head
3636 .spans
3637 .iter()
3638 .map(|s| s.content.as_ref())
3639 .collect::<String>();
3640 assert!(
3641 !visible.contains("Assistant"),
3642 "assistant label dropped: {visible:?}"
3643 );
3644 assert!(visible.contains("ready"));
3645 }
3646
3647 #[test]
3648 fn assistant_glyph_holds_full_brightness_when_idle() {
3649 // Idle (streaming=false) and low_motion both pin the colour to the
3650 // source sky — pulse only fires when actively streaming.
3651 let idle = assistant_label_style_for(false, false);
3652 let low_motion = assistant_label_style_for(true, true);
3653 assert_eq!(idle.fg, Some(palette::DEEPSEEK_SKY));
3654 assert_eq!(low_motion.fg, Some(palette::DEEPSEEK_SKY));
3655 }
3656
3657 #[test]
3658 fn assistant_glyph_pulses_when_streaming_and_motion_allowed() {
3659 // The streaming path runs through `pulse_brightness`, which yields
3660 // an RGB colour scaled within 30%..100% of the source. Sample twice
3661 // — at least one of the samples must fall below 100% brightness, or
3662 // the test wouldn't be exercising the pulse at all. (We can't pin
3663 // the value because the function reads SystemTime::now().)
3664 use ratatui::style::Color;
3665 let mut saw_dimmed = false;
3666 for _ in 0..50 {
3667 if let Some(Color::Rgb(_, _, b)) = assistant_label_style_for(true, false).fg {
3668 let Color::Rgb(_, _, src_b) = palette::DEEPSEEK_SKY else {
3669 panic!("DEEPSEEK_SKY must be RGB");
3670 };
3671 if b < src_b {
3672 saw_dimmed = true;
3673 break;
3674 }
3675 }
3676 std::thread::sleep(std::time::Duration::from_millis(20));
3677 }
3678 assert!(
3679 saw_dimmed,
3680 "expected the streaming pulse to dip below source brightness at least once",
3681 );
3682 }
3683
3684 // === Tool-card verb-glyph tests (v0.6.6 UI redesign) ===
3685
3686 #[test]
3687 fn exec_cell_header_uses_run_verb_glyph_and_label() {
3688 let cell = ExecCell {
3689 command: "ls".to_string(),
3690 status: ToolStatus::Success,
3691 output: Some("a\nb\n".to_string()),
3692 started_at: None,
3693 duration_ms: Some(10),
3694 source: ExecSource::Assistant,
3695 interaction: None,
3696 };
3697 let header = &cell.lines_with_motion(80, true)[0];
3698 let visible: String = header
3699 .spans
3700 .iter()
3701 .map(|s| s.content.as_ref())
3702 .collect::<String>();
3703 assert!(
3704 visible.contains('\u{25B6}'),
3705 "Run glyph `▶` present: {visible:?}"
3706 );
3707 assert!(visible.contains(" run "), "verb label `run`: {visible:?}");
3708 // Old literal title must be gone.
3709 assert!(
3710 !visible.contains("Shell"),
3711 "old `Shell` literal is gone: {visible:?}"
3712 );
3713 }
3714
3715 #[test]
3716 fn exec_cell_header_includes_compact_command_summary() {
3717 let cell = ExecCell {
3718 command: "cargo test --workspace --all-features".to_string(),
3719 status: ToolStatus::Running,
3720 output: None,
3721 started_at: None,
3722 duration_ms: None,
3723 source: ExecSource::Assistant,
3724 interaction: None,
3725 };
3726
3727 let header = &cell.lines_with_motion(80, true)[0];
3728 let visible: String = header
3729 .spans
3730 .iter()
3731 .map(|s| s.content.as_ref())
3732 .collect::<String>();
3733 assert!(visible.contains("run running"));
3734 assert!(
3735 visible.contains("cargo test --workspace --all-features"),
3736 "header should expose command target: {visible:?}"
3737 );
3738 }
3739
3740 #[test]
3741 fn generic_tool_cell_picks_family_from_tool_name() {
3742 let cell = GenericToolCell {
3743 name: "agent_spawn".to_string(),
3744 status: ToolStatus::Running,
3745 input_summary: Some("foo".to_string()),
3746 output: None,
3747 prompts: None,
3748 spillover_path: None,
3749 };
3750 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
3751 let header_visible: String = lines[0]
3752 .spans
3753 .iter()
3754 .map(|s| s.content.as_ref())
3755 .collect::<String>();
3756 // agent_spawn → Delegate family (◐ delegate).
3757 assert!(
3758 header_visible.contains('\u{25D0}'),
3759 "Delegate glyph `◐`: {header_visible:?}"
3760 );
3761 assert!(
3762 header_visible.contains(" delegate "),
3763 "verb label `delegate`: {header_visible:?}"
3764 );
3765 }
3766
3767 #[test]
3768 fn generic_tool_cell_renders_rlm_with_rlm_label_not_swarm() {
3769 let cell = GenericToolCell {
3770 name: "rlm".to_string(),
3771 status: ToolStatus::Running,
3772 input_summary: Some("task: compare source trees".to_string()),
3773 output: None,
3774 prompts: None,
3775 spillover_path: None,
3776 };
3777 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
3778 let header_visible: String = lines[0]
3779 .spans
3780 .iter()
3781 .map(|s| s.content.as_ref())
3782 .collect::<String>();
3783
3784 assert!(
3785 header_visible.contains(" rlm "),
3786 "RLM card should identify RLM work: {header_visible:?}"
3787 );
3788 assert!(
3789 !header_visible.contains("swarm"),
3790 "RLM card must not use removed swarm wording: {header_visible:?}"
3791 );
3792 }
3793
3794 // === Reasoning treatment tests (v0.6.6 UI redesign) ===
3795
3796 #[test]
3797 fn render_thinking_uses_dotted_opener_in_header() {
3798 let lines = render_thinking("Step one\nStep two", 80, false, Some(2.0), false, true);
3799 let header = &lines[0];
3800 // First span carries `…` followed by a space.
3801 assert!(
3802 header.spans[0].content.starts_with(REASONING_OPENER),
3803 "header opener: {:?}",
3804 header.spans[0].content
3805 );
3806 }
3807
3808 #[test]
3809 fn render_thinking_body_lines_use_dashed_rail_and_italic() {
3810 let lines = render_thinking(
3811 "concrete reasoning content",
3812 80,
3813 /*streaming*/ false,
3814 Some(1.0),
3815 /*collapsed*/ false,
3816 /*low_motion*/ true,
3817 );
3818 // Header is index 0; first body line is index 1.
3819 assert!(lines.len() >= 2, "expected at least one body line");
3820 let body = &lines[1];
3821 assert_eq!(
3822 body.spans[0].content.as_ref(),
3823 REASONING_RAIL,
3824 "body rail must be the dashed `╎ ` glyph"
3825 );
3826 // The body span should carry italic.
3827 let italic_seen = body
3828 .spans
3829 .iter()
3830 .skip(1)
3831 .any(|span| span.style.add_modifier.contains(Modifier::ITALIC));
3832 assert!(italic_seen, "body content should carry italic modifier");
3833 }
3834
3835 #[test]
3836 fn render_thinking_streaming_appends_cursor_when_motion_allowed() {
3837 let lines = render_thinking(
3838 "ongoing reasoning...",
3839 80,
3840 /*streaming*/ true,
3841 None,
3842 /*collapsed*/ false,
3843 /*low_motion*/ false,
3844 );
3845 // Last line is the most recent body line — cursor lives there.
3846 let last = lines.last().expect("body line present");
3847 let last_span = last.spans.last().expect("trailing span present");
3848 assert!(
3849 last_span.content.contains(REASONING_CURSOR),
3850 "expected trailing cursor `▎` on last streaming body line, got {:?}",
3851 last_span.content
3852 );
3853 }
3854
3855 #[test]
3856 fn render_thinking_streaming_omits_cursor_when_low_motion() {
3857 let lines = render_thinking(
3858 "ongoing reasoning...",
3859 80,
3860 /*streaming*/ true,
3861 None,
3862 /*collapsed*/ false,
3863 /*low_motion*/ true,
3864 );
3865 let last = lines.last().expect("body line present");
3866 let visible: String = last
3867 .spans
3868 .iter()
3869 .map(|s| s.content.as_ref())
3870 .collect::<String>();
3871 assert!(
3872 !visible.contains(REASONING_CURSOR),
3873 "low_motion must suppress the streaming cursor: {visible:?}"
3874 );
3875 }
3876
3877 // === Theme parity tests ===
3878 //
3879 // These lock the visible color/style choices for one plan cell and one
3880 // tool cell against `deepseek_theme::Theme::dark()`. The render path is
3881 // unchanged in shape; the assertions just guarantee a future skin swap
3882 // (or accidental drift) is caught here instead of at runtime.
3883
3884 #[test]
3885 fn plan_update_cell_renders_with_dark_theme_tokens() {
3886 let theme = Theme::dark();
3887 let cell = PlanUpdateCell {
3888 explanation: None,
3889 steps: vec![
3890 PlanStep {
3891 step: "scan repo".to_string(),
3892 status: "completed".to_string(),
3893 },
3894 PlanStep {
3895 step: "extract theme".to_string(),
3896 status: "in_progress".to_string(),
3897 },
3898 PlanStep {
3899 step: "land tests".to_string(),
3900 status: "pending".to_string(),
3901 },
3902 ],
3903 status: ToolStatus::Running,
3904 };
3905
3906 let lines = cell.lines_with_motion(80, true);
3907
3908 // Header: "<spinner> <family-glyph> <verb> <state>" (v0.6.6 layout).
3909 // PlanUpdate has no canonical family yet, so it falls into the
3910 // Generic bullet glyph + "tool" verb. The shape and colour wiring
3911 // is what matters for the theme parity; the verb text moves with
3912 // the redesign.
3913 let header = &lines[0];
3914 let symbol_span = &header.spans[0];
3915 let glyph_span = &header.spans[1];
3916 let title_span = &header.spans[2];
3917 let state_span = &header.spans[4];
3918
3919 assert_eq!(
3920 symbol_span.style.fg,
3921 Some(theme.tool_running_accent),
3922 "running header symbol should use the dark theme running accent"
3923 );
3924 assert_eq!(
3925 glyph_span.style.fg,
3926 Some(theme.tool_running_accent),
3927 "family glyph rides the same status colour as the spinner"
3928 );
3929 assert_eq!(
3930 title_span.content.as_ref(),
3931 "tool",
3932 "PlanUpdate routes to Generic family → 'tool' verb",
3933 );
3934 assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
3935 assert!(
3936 title_span.style.add_modifier.contains(Modifier::BOLD),
3937 "tool title should be bold"
3938 );
3939 assert_eq!(
3940 state_span.content.as_ref(),
3941 "running",
3942 "running PlanUpdate should label state as 'running'"
3943 );
3944 assert_eq!(state_span.style.fg, Some(theme.tool_running_accent));
3945
3946 // Each step row: ["▏ ", "<marker>:", " ", "<step>"]
3947 let step_line = &lines[1];
3948 let label_span = &step_line.spans[1];
3949 let value_span = &step_line.spans[3];
3950 assert_eq!(
3951 label_span.style.fg,
3952 Some(theme.tool_label_color),
3953 "step label should use theme.tool_label_color"
3954 );
3955 assert_eq!(
3956 value_span.style.fg,
3957 Some(theme.tool_value_color),
3958 "step value should use theme.tool_value_color"
3959 );
3960
3961 // Plain content stays identical so visible output does not move.
3962 let visible = lines
3963 .iter()
3964 .map(|l| {
3965 l.spans
3966 .iter()
3967 .map(|s| s.content.as_ref())
3968 .collect::<String>()
3969 })
3970 .collect::<Vec<_>>();
3971 assert_eq!(visible[1].trim_end(), "▏ done: scan repo");
3972 assert_eq!(visible[2].trim_end(), "▏ live: extract theme");
3973 assert_eq!(visible[3].trim_end(), "▏ next: land tests");
3974 }
3975
3976 #[test]
3977 fn exec_cell_failed_status_renders_with_dark_theme_tokens() {
3978 let theme = Theme::dark();
3979 let cell = ExecCell {
3980 command: "false".to_string(),
3981 status: ToolStatus::Failed,
3982 output: Some("boom".to_string()),
3983 started_at: None,
3984 duration_ms: Some(42),
3985 source: ExecSource::Assistant,
3986 interaction: None,
3987 };
3988
3989 let lines = cell.lines_with_motion(80, true);
3990
3991 let header = &lines[0];
3992 let symbol_span = &header.spans[0];
3993 let glyph_span = &header.spans[1];
3994 let title_span = &header.spans[2];
3995 let state_span = &header.spans[4];
3996
3997 assert_eq!(
3998 symbol_span.style.fg,
3999 Some(theme.tool_failed_accent),
4000 "failed exec header symbol should use the dark theme failed accent"
4001 );
4002 // ExecCell is family Run → glyph `▶ ` and verb `run`.
4003 assert!(
4004 glyph_span.content.starts_with('\u{25B6}'),
4005 "Run family glyph: {:?}",
4006 glyph_span.content
4007 );
4008 assert_eq!(
4009 title_span.content.as_ref(),
4010 "run",
4011 "ExecCell routes to Run family → 'run' verb",
4012 );
4013 assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
4014 assert!(title_span.style.add_modifier.contains(Modifier::BOLD));
4015 assert_eq!(state_span.content.as_ref(), "issue");
4016 assert_eq!(state_span.style.fg, Some(theme.tool_failed_accent));
4017 }
4018
4019 // === display_lines (lines_with_options) vs transcript_lines parity ===
4020 //
4021 // These lock the contract for CX#8: live view compresses thinking and
4022 // caps tool output, transcript view shows the full body. Both surfaces
4023 // must contain the first paragraph / first line of the underlying
4024 // content so users never lose the lede.
4025
4026 fn line_text(line: &ratatui::text::Line<'static>) -> String {
4027 line.spans
4028 .iter()
4029 .map(|span| span.content.as_ref())
4030 .collect()
4031 }
4032
4033 fn lines_text(lines: &[ratatui::text::Line<'static>]) -> String {
4034 lines.iter().map(line_text).collect::<Vec<_>>().join("\n")
4035 }
4036
4037 #[test]
4038 fn long_thinking_display_is_shorter_than_transcript() {
4039 // Build a multi-paragraph thinking body so the live view has
4040 // something to compress. The first paragraph is the lede; both
4041 // surfaces must keep it.
4042 let body = "First paragraph lede.\n\
4043 Second sentence of the first paragraph.\n\n\
4044 Second paragraph: deeper analysis follows.\n\
4045 More detail in paragraph two.\n\n\
4046 Third paragraph: even more reasoning.\n\
4047 With another line.\n\n\
4048 Fourth paragraph: the conclusion.\n\
4049 And one more line for good measure.";
4050 let cell = HistoryCell::Thinking {
4051 content: body.to_string(),
4052 streaming: false,
4053 duration_secs: Some(3.2),
4054 };
4055
4056 let live = cell.lines_with_options(
4057 80,
4058 TranscriptRenderOptions {
4059 low_motion: true,
4060 ..TranscriptRenderOptions::default()
4061 },
4062 );
4063 let transcript = cell.transcript_lines(80);
4064
4065 assert!(
4066 live.len() < transcript.len(),
4067 "live thinking should compress (live = {} lines, transcript = {} lines)",
4068 live.len(),
4069 transcript.len()
4070 );
4071
4072 let live_text = lines_text(&live);
4073 let transcript_text = lines_text(&transcript);
4074
4075 assert!(
4076 live_text.contains("First paragraph lede"),
4077 "live thinking must keep the lede: {live_text}"
4078 );
4079 assert!(
4080 transcript_text.contains("First paragraph lede"),
4081 "transcript thinking must keep the lede"
4082 );
4083 assert!(
4084 transcript_text.contains("Fourth paragraph"),
4085 "transcript thinking must keep the full body"
4086 );
4087 assert!(
4088 !live_text.contains("Fourth paragraph"),
4089 "live thinking must drop the tail when collapsed"
4090 );
4091 assert!(
4092 live_text.contains("press Ctrl+O for full text"),
4093 "live thinking must offer the pager affordance"
4094 );
4095 assert!(
4096 !transcript_text.contains("press Ctrl+O for full text"),
4097 "transcript thinking must not include the live affordance"
4098 );
4099 }
4100
4101 #[test]
4102 fn short_thinking_display_equals_transcript() {
4103 // A single-line thinking body has nothing to compress; live and
4104 // transcript surfaces should agree.
4105 let cell = HistoryCell::Thinking {
4106 content: "One brief reasoning step.".to_string(),
4107 streaming: false,
4108 duration_secs: Some(0.4),
4109 };
4110
4111 let live = cell.lines_with_options(
4112 80,
4113 TranscriptRenderOptions {
4114 low_motion: true,
4115 ..TranscriptRenderOptions::default()
4116 },
4117 );
4118 let transcript = cell.transcript_lines(80);
4119
4120 let live_text = lines_text(&live);
4121 let transcript_text = lines_text(&transcript);
4122
4123 assert_eq!(
4124 live_text, transcript_text,
4125 "short thinking must render identically on both surfaces"
4126 );
4127 assert!(
4128 !live_text.contains("press Ctrl+O for full text"),
4129 "short thinking must not show the collapse affordance"
4130 );
4131 }
4132
4133 #[test]
4134 fn tool_exec_live_caps_output_transcript_does_not() {
4135 // Synthesize an exec output that comfortably exceeds the live cap
4136 // (TOOL_OUTPUT_LINE_LIMIT = 6). The live view should hit the cap
4137 // and emit a "+N more lines; press v for details" affordance; the
4138 // transcript view should emit every wrapped line uncapped.
4139 let total_output_lines = 30usize;
4140 let output = (0..total_output_lines)
4141 .map(|i| format!("output line {i:02}"))
4142 .collect::<Vec<_>>()
4143 .join("\n");
4144
4145 let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
4146 command: "noisy_script.sh".to_string(),
4147 status: ToolStatus::Success,
4148 output: Some(output),
4149 started_at: None,
4150 duration_ms: Some(120),
4151 source: ExecSource::Assistant,
4152 interaction: None,
4153 }));
4154
4155 let live = cell.lines_with_options(
4156 80,
4157 TranscriptRenderOptions {
4158 low_motion: true,
4159 ..TranscriptRenderOptions::default()
4160 },
4161 );
4162 let transcript = cell.transcript_lines(80);
4163
4164 let live_text = lines_text(&live);
4165 let transcript_text = lines_text(&transcript);
4166
4167 assert!(
4168 live.len() < transcript.len(),
4169 "live exec output must be shorter than transcript exec output (live={}, transcript={})",
4170 live.len(),
4171 transcript.len()
4172 );
4173 assert!(
4174 live_text.contains("Alt+V for details"),
4175 "live exec output must surface the pager affordance: {live_text}"
4176 );
4177 assert!(
4178 !transcript_text.contains("Alt+V for details"),
4179 "transcript exec output must not include the pager affordance"
4180 );
4181 // First line is always emitted on both surfaces.
4182 assert!(live_text.contains("output line 00"));
4183 assert!(transcript_text.contains("output line 00"));
4184 // The middle should only appear in the transcript, since the live
4185 // view truncates the head/tail around the cap.
4186 assert!(
4187 transcript_text.contains("output line 15"),
4188 "transcript must include the middle of the exec output"
4189 );
4190 // Last line should appear in both because the live view shows
4191 // head + tail around an omission marker.
4192 let last = format!("output line {:02}", total_output_lines - 1);
4193 assert!(transcript_text.contains(&last));
4194 }
4195
4196 #[test]
4197 fn generic_tool_cell_renders_prompts_as_indexed_rows() {
4198 // When prompts are populated by a fan-out tool, each child shows on
4199 // its own row instead of the inline `args:` summary so the user can
4200 // read what each child was asked.
4201 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
4202 name: "future_fanout_tool".to_string(),
4203 status: ToolStatus::Running,
4204 input_summary: Some("prompts: <3 items>".to_string()),
4205 output: None,
4206 prompts: Some(vec![
4207 "Summarize the README".to_string(),
4208 "List the public types in client.rs".to_string(),
4209 "Diff this commit against main".to_string(),
4210 ]),
4211 spillover_path: None,
4212 }));
4213 let text = lines_text(&cell.lines(80));
4214
4215 assert!(text.contains("[0] Summarize the README"));
4216 assert!(text.contains("[1] List the public types in client.rs"));
4217 assert!(text.contains("[2] Diff this commit against main"));
4218 // The inline args summary must not also be emitted — we replaced it
4219 // with the per-child rows.
4220 assert!(
4221 !text.contains("args: prompts:"),
4222 "inline `args:` summary must be suppressed when per-prompt rows render"
4223 );
4224 }
4225
4226 #[test]
4227 fn generic_tool_cell_falls_back_to_args_when_prompts_none() {
4228 // Non-fan-out tools keep the existing `args:` summary so behavior
4229 // doesn't drift for everything else.
4230 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
4231 name: "file_search".to_string(),
4232 status: ToolStatus::Running,
4233 input_summary: Some("query: foo".to_string()),
4234 output: None,
4235 prompts: None,
4236 spillover_path: None,
4237 }));
4238 let text = lines_text(&cell.lines(80));
4239 assert!(text.contains("query: foo"));
4240 }
4241
4242 #[test]
4243 fn generic_tool_cell_preserves_multi_line_output_in_transcript() {
4244 // Repro for #80: a `git diff --stat`-shaped tool result should keep
4245 // its newlines on the transcript surface — one file per row, not
4246 // squashed into a single line.
4247 let diff_stat = "Cargo.lock | 1 +\n\
4248 crates/cli/Cargo.toml | 1 +\n\
4249 crates/cli/src/main.rs | 47 ++++++\n\
4250 crates/config/src/lib.rs | 27 ++++\n\
4251 crates/tui/src/mcp.rs | 384 +++++";
4252
4253 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
4254 name: "exec_shell".to_string(),
4255 status: ToolStatus::Success,
4256 input_summary: Some("command: git diff --stat".to_string()),
4257 output: Some(diff_stat.to_string()),
4258 prompts: None,
4259 spillover_path: None,
4260 }));
4261
4262 let transcript_text = lines_text(&cell.transcript_lines(80));
4263
4264 // Each file path must appear on its own row in the transcript.
4265 for needle in [
4266 "Cargo.lock",
4267 "crates/cli/Cargo.toml",
4268 "crates/cli/src/main.rs",
4269 "crates/config/src/lib.rs",
4270 "crates/tui/src/mcp.rs",
4271 ] {
4272 assert!(
4273 transcript_text.contains(needle),
4274 "transcript missing '{needle}': {transcript_text}"
4275 );
4276 }
4277 // The pre-fix bug: result line containing
4278 // "Cargo.lock | 1 + crates/cli/Cargo.toml" — joined into one row.
4279 // With the fix, the diff-stat pipes are still present per-line, but
4280 // adjacent file paths are on separate rendered rows. Assert that the
4281 // first file's line ends before the second begins.
4282 let lines: Vec<&str> = transcript_text.lines().collect();
4283 let cargo_lock_line = lines
4284 .iter()
4285 .find(|l| l.contains("Cargo.lock"))
4286 .expect("Cargo.lock row must exist");
4287 assert!(
4288 !cargo_lock_line.contains("crates/cli/Cargo.toml"),
4289 "Cargo.lock row must not also contain the second file: {cargo_lock_line}"
4290 );
4291 }
4292
4293 #[test]
4294 fn generic_tool_cell_caps_multi_line_output_in_live_with_affordance() {
4295 // Live (in-progress / active-cell) view caps long output at
4296 // TOOL_OUTPUT_LINE_LIMIT (=6) and shows a "+N more lines" affordance.
4297 let total = 30usize;
4298 let output = (0..total)
4299 .map(|i| format!("row {i:02}: payload"))
4300 .collect::<Vec<_>>()
4301 .join("\n");
4302
4303 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
4304 name: "exec_shell".to_string(),
4305 status: ToolStatus::Success,
4306 input_summary: Some("command: ls".to_string()),
4307 output: Some(output),
4308 prompts: None,
4309 spillover_path: None,
4310 }));
4311
4312 let live = cell.lines_with_options(80, TranscriptRenderOptions::default());
4313 let transcript = cell.transcript_lines(80);
4314
4315 assert!(
4316 live.len() < transcript.len(),
4317 "live generic-tool output must be shorter than transcript (live={}, transcript={})",
4318 live.len(),
4319 transcript.len(),
4320 );
4321 let live_text = lines_text(&live);
4322 assert!(
4323 live_text.contains("Alt+V for details"),
4324 "live view must show pager affordance: {live_text}"
4325 );
4326 // First line shows up in both; later rows only in transcript.
4327 assert!(live_text.contains("row 00"));
4328 let transcript_text = lines_text(&transcript);
4329 assert!(transcript_text.contains("row 29"));
4330 }
4331
4332 #[test]
4333 fn generic_tool_output_live_keeps_tail_and_omitted_count() {
4334 let output = (0..24usize)
4335 .map(|i| format!("line {i:02}"))
4336 .collect::<Vec<_>>()
4337 .join("\n");
4338 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
4339 name: "exec_shell".to_string(),
4340 status: ToolStatus::Success,
4341 input_summary: Some("command: noisy".to_string()),
4342 output: Some(output),
4343 prompts: None,
4344 spillover_path: None,
4345 }));
4346
4347 let live_text =
4348 lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default()));
4349
4350 assert!(live_text.contains("line 00"));
4351 assert!(live_text.contains("line 23"));
4352 assert!(live_text.contains("lines omitted; Alt+V for details"));
4353 assert!(
4354 !live_text.contains("line 12"),
4355 "middle plain output should stay omitted in live view: {live_text}"
4356 );
4357 }
4358
4359 #[test]
4360 fn tool_output_live_preserves_error_and_path_lines_from_middle() {
4361 let output = [
4362 "start",
4363 "still starting",
4364 "middle noise 1",
4365 "fatal: failed to read /tmp/deepseek/config.toml",
4366 "middle noise 2",
4367 "see https://example.test/build/log for details",
4368 "middle noise 3",
4369 "almost done",
4370 "final line",
4371 ]
4372 .join("\n");
4373 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
4374 name: "exec_shell".to_string(),
4375 status: ToolStatus::Failed,
4376 input_summary: Some("command: tool".to_string()),
4377 output: Some(output),
4378 prompts: None,
4379 spillover_path: None,
4380 }));
4381
4382 let live_text =
4383 lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default()));
4384
4385 assert!(live_text.contains("fatal: failed to read /tmp/deepseek/config.toml"));
4386 assert!(live_text.contains("https://example.test/build/log"));
4387 assert!(live_text.contains("final line"));
4388 assert!(live_text.contains("lines omitted; Alt+V for details"));
4389 }
4390
4391 // === ErrorEnvelope severity → cell color tests (#66) ===
4392
4393 /// Snapshot: an `Error`-severity cell uses the red status palette token
4394 /// for both the leading "Error" label glyph and the body. This is the
4395 /// load-bearing visual signal that distinguishes an error cell from a
4396 /// neutral system note.
4397 #[test]
4398 fn error_severity_cell_renders_in_red() {
4399 let cell = HistoryCell::Error {
4400 message: "Authentication failed: invalid API key".to_string(),
4401 severity: crate::error_taxonomy::ErrorSeverity::Error,
4402 };
4403 let lines = cell.lines(80);
4404 assert!(
4405 !lines.is_empty(),
4406 "error cell must render at least one line"
4407 );
4408
4409 let head = &lines[0];
4410 let label_span = &head.spans[0];
4411 assert_eq!(label_span.content.as_ref(), "Error");
4412 assert_eq!(label_span.style.fg, Some(palette::STATUS_ERROR));
4413 assert!(label_span.style.add_modifier.contains(Modifier::BOLD));
4414
4415 // The body carries the error message and is rendered in the same red.
4416 let body_text = lines
4417 .iter()
4418 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
4419 .collect::<String>();
4420 assert!(body_text.contains("Authentication failed"));
4421 // Find a span whose text contains "Authentication" and verify its color.
4422 let body_span = lines
4423 .iter()
4424 .flat_map(|line| line.spans.iter())
4425 .find(|span| span.content.contains("Authentication"))
4426 .expect("error body span must exist");
4427 assert_eq!(body_span.style.fg, Some(palette::STATUS_ERROR));
4428 }
4429
4430 /// `Warning`-severity uses amber, not red — distinguishes a transient
4431 /// retry hiccup from a hard failure.
4432 #[test]
4433 fn warning_severity_cell_renders_in_amber() {
4434 let cell = HistoryCell::Error {
4435 message: "Stream stalled: no data received for 60s, closing stream".to_string(),
4436 severity: crate::error_taxonomy::ErrorSeverity::Warning,
4437 };
4438 let lines = cell.lines(80);
4439 let label_span = &lines[0].spans[0];
4440 assert_eq!(label_span.content.as_ref(), "Warn");
4441 assert_eq!(label_span.style.fg, Some(palette::STATUS_WARNING));
4442 }
4443
4444 /// `Critical` severity collapses to the same red as `Error` — both flip
4445 /// offline mode and both should read as the loudest signal in the
4446 /// transcript.
4447 #[test]
4448 fn critical_severity_cell_renders_in_red() {
4449 let cell = HistoryCell::Error {
4450 message: "API key expired".to_string(),
4451 severity: crate::error_taxonomy::ErrorSeverity::Critical,
4452 };
4453 let lines = cell.lines(80);
4454 let label_span = &lines[0].spans[0];
4455 assert_eq!(label_span.content.as_ref(), "Error");
4456 assert_eq!(label_span.style.fg, Some(palette::STATUS_ERROR));
4457 }
4458
4459 /// `Info` severity stays neutral / dim so it doesn't draw the eye away
4460 /// from real failures sitting alongside it in the transcript.
4461 #[test]
4462 fn info_severity_cell_renders_in_dim() {
4463 let cell = HistoryCell::Error {
4464 message: "Reconnected".to_string(),
4465 severity: crate::error_taxonomy::ErrorSeverity::Info,
4466 };
4467 let lines = cell.lines(80);
4468 let label_span = &lines[0].spans[0];
4469 assert_eq!(label_span.content.as_ref(), "Info");
4470 assert_eq!(label_span.style.fg, Some(palette::TEXT_DIM));
4471 }
4472 }
4473
4473 lines RUST