use std::path::{Path, PathBuf};
use std::time::Instant;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use serde_json::Value;
use unicode_width::UnicodeWidthStr;
use crate::deepseek_theme::active_theme;
use crate::models::{ContentBlock, Message};
use crate::palette;
use crate::tools::review::ReviewOutput;
use crate::tui::app::TranscriptSpacing;
use crate::tui::diff_render;
use crate::tui::markdown_render;
use std::process::Command;
const TOOL_COMMAND_LINE_LIMIT: usize = 3;
const TOOL_OUTPUT_LINE_LIMIT: usize = 6;
const TOOL_TEXT_LIMIT: usize = 180;
const TOOL_HEADER_SUMMARY_LIMIT: usize = 56;
const TOOL_OUTPUT_HEAD_LINES: usize = 2;
const TOOL_OUTPUT_TAIL_LINES: usize = 2;
const TOOL_RUNNING_SYMBOLS: [&str; 4] = ["·", "◦", "•", "◦"];
const TOOL_STATUS_SYMBOL_MS: u64 = 720;
const USER_GLYPH: &str = "\u{258E}"; const ASSISTANT_GLYPH: &str = "\u{25CF}"; const TRANSCRIPT_RAIL: &str = "\u{258F} "; const REASONING_OPENER: &str = "\u{2026}"; const REASONING_RAIL: &str = "\u{254E} "; const REASONING_CURSOR: &str = "\u{258E}"; const TOOL_CARD_SUMMARY_LINES: usize = 4;
const THINKING_SUMMARY_LINE_LIMIT: usize = 4;
const TOOL_DONE_SYMBOL: &str = "•";
const TOOL_FAILED_SYMBOL: &str = "•";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderMode {
Live,
Transcript,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThinkingVisualState {
Live,
Done,
Idle,
}
#[derive(Debug, Clone)]
pub enum HistoryCell {
User {
content: String,
},
Assistant {
content: String,
streaming: bool,
},
System {
content: String,
},
Error {
message: String,
severity: crate::error_taxonomy::ErrorSeverity,
},
Thinking {
content: String,
streaming: bool,
duration_secs: Option<f32>,
},
ArchivedContext {
level: u8,
range: String,
tokens: String,
density: String,
model: String,
timestamp: String,
summary: String,
},
Tool(ToolCell),
SubAgent(SubAgentCell),
}
#[derive(Debug, Clone)]
pub enum SubAgentCell {
Delegate(crate::tui::widgets::agent_card::DelegateCard),
Fanout(crate::tui::widgets::agent_card::FanoutCard),
}
impl SubAgentCell {
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
match self {
SubAgentCell::Delegate(card) => card.render_lines(width),
SubAgentCell::Fanout(card) => card.render_lines(width),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TranscriptRenderOptions {
pub show_thinking: bool,
pub verbose: bool,
pub show_tool_details: bool,
pub calm_mode: bool,
pub low_motion: bool,
pub spacing: TranscriptSpacing,
}
impl Default for TranscriptRenderOptions {
fn default() -> Self {
Self {
show_thinking: true,
verbose: false,
show_tool_details: true,
calm_mode: false,
low_motion: false,
spacing: TranscriptSpacing::Comfortable,
}
}
}
impl HistoryCell {
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
match self {
HistoryCell::User { content } => render_message(
USER_GLYPH,
user_label_style(),
user_body_style(),
content,
width,
),
HistoryCell::Assistant { content, streaming } => render_message(
ASSISTANT_GLYPH,
assistant_label_style_for(*streaming, false),
message_body_style(),
content,
width,
),
HistoryCell::System { content } => {
if is_cycle_boundary(content) {
render_cycle_boundary(content, width)
} else {
render_message(
"Note",
system_label_style(),
system_body_style(),
content,
width,
)
}
}
HistoryCell::Error { message, severity } => render_message(
error_label_text(*severity),
error_label_style(*severity),
error_body_style(*severity),
message,
width,
),
HistoryCell::Thinking {
content,
streaming,
duration_secs,
} => render_thinking(content, width, *streaming, *duration_secs, false, false),
HistoryCell::Tool(cell) => cell.lines_with_motion(width, false),
HistoryCell::SubAgent(cell) => cell.lines(width),
HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, false),
}
}
pub fn lines_with_options(
&self,
width: u16,
options: TranscriptRenderOptions,
) -> Vec<Line<'static>> {
match self {
HistoryCell::Thinking { .. } if !options.show_thinking => Vec::new(),
HistoryCell::Thinking {
content,
streaming,
duration_secs,
} => render_thinking(
content,
width,
*streaming,
*duration_secs,
!options.verbose,
options.low_motion,
),
HistoryCell::Tool(cell) if !options.show_tool_details => {
let mut lines = cell.lines_with_motion(width, options.low_motion);
if lines.len() > 2 {
lines.truncate(2);
lines.push(details_affordance_line(
"details hidden",
Style::default().fg(palette::TEXT_MUTED).italic(),
));
}
lines
}
HistoryCell::Tool(cell) if options.calm_mode => {
let mut lines = cell.lines_with_motion(width, options.low_motion);
if lines.len() > TOOL_CARD_SUMMARY_LINES {
lines.truncate(TOOL_CARD_SUMMARY_LINES);
lines.push(details_affordance_line(
"Alt+V for details",
Style::default().fg(palette::TEXT_MUTED).italic(),
));
}
lines
}
HistoryCell::Tool(cell) => cell.lines_with_motion(width, options.low_motion),
HistoryCell::User { content } => render_message(
USER_GLYPH,
user_label_style(),
user_body_style(),
content,
width,
),
HistoryCell::Assistant { content, streaming } => render_message(
ASSISTANT_GLYPH,
assistant_label_style_for(*streaming, options.low_motion),
message_body_style(),
content,
width,
),
HistoryCell::System { .. } | HistoryCell::Error { .. } => self.lines(width),
HistoryCell::SubAgent(cell) => cell.lines(width),
HistoryCell::ArchivedContext { .. } => {
render_archived_context(self, width, options.low_motion)
}
}
}
pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
match self {
HistoryCell::User { content } => render_message(
USER_GLYPH,
user_label_style(),
user_body_style(),
content,
width,
),
HistoryCell::Assistant { content, streaming } => render_message(
ASSISTANT_GLYPH,
assistant_label_style_for(*streaming, true),
message_body_style(),
content,
width,
),
HistoryCell::System { .. } | HistoryCell::Error { .. } => self.lines(width),
HistoryCell::Thinking {
content,
streaming,
duration_secs,
} => render_thinking(
content,
width,
*streaming,
*duration_secs,
false,
false,
),
HistoryCell::Tool(cell) => cell.transcript_lines(width),
HistoryCell::SubAgent(cell) => cell.lines(width),
HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, true),
}
}
#[must_use]
pub fn is_stream_continuation(&self) -> bool {
matches!(
self,
HistoryCell::Assistant {
streaming: true,
..
}
)
}
#[must_use]
pub fn is_conversational(&self) -> bool {
matches!(
self,
HistoryCell::User { .. } | HistoryCell::Assistant { .. } | HistoryCell::Thinking { .. }
)
}
}
fn parse_archived_context(text: &str) -> Option<HistoryCell> {
let text = text.trim();
if !text.starts_with("<archived_context") || !text.ends_with("</archived_context>") {
return None;
}
let tag_end = text.find('>')?;
let tag = &text[..tag_end];
let level = archived_context_attr(tag, "level")
.and_then(|v| v.parse::<u8>().ok())
.unwrap_or(0);
let range = archived_context_attr(tag, "range").unwrap_or_default();
let tokens = archived_context_attr(tag, "tokens").unwrap_or_default();
let density = archived_context_attr(tag, "density").unwrap_or_default();
let model = archived_context_attr(tag, "model").unwrap_or_default();
let timestamp = archived_context_attr(tag, "timestamp").unwrap_or_default();
let close_tag = text.rfind("</archived_context>")?;
let summary_start = tag_end + 1;
let summary = text[summary_start..close_tag].trim().to_string();
Some(HistoryCell::ArchivedContext {
level,
range,
tokens,
density,
model,
timestamp,
summary,
})
}
fn archived_context_attr(tag: &str, name: &str) -> Option<String> {
let needle = format!("{name}=\"");
let start = tag.find(&needle)? + needle.len();
let rest = &tag[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn render_archived_context(
cell: &HistoryCell,
width: u16,
_low_motion: bool,
) -> Vec<Line<'static>> {
let HistoryCell::ArchivedContext {
level,
range,
tokens,
density,
model,
timestamp,
summary,
} = cell
else {
return Vec::new();
};
let body = if summary.is_empty() {
"(no summary)".to_string()
} else {
summary.clone()
};
let label = format!("Context L{level}");
let label_style = Style::default()
.fg(palette::TEXT_DIM)
.add_modifier(Modifier::BOLD);
let body_style = Style::default().fg(palette::TEXT_DIM).italic();
let content_width = width.saturating_sub(4).max(1);
let mut lines = Vec::new();
let range_display = if range.is_empty() {
String::new()
} else {
range.to_string()
};
let mut header = format!("{label} {range_display}");
if !tokens.is_empty() {
header.push_str(&format!(" {tokens}"));
}
if !density.is_empty() && density != tokens {
header.push_str(&format!(" {density}"));
}
lines.push(Line::from(Span::styled(header, label_style)));
let model_display = if model.is_empty() {
String::new()
} else {
format!("via {model}")
};
let ts_display = if timestamp.is_empty() {
String::new()
} else {
timestamp.clone()
};
let mut sub = String::new();
if !model_display.is_empty() {
sub.push_str(&model_display);
}
if !ts_display.is_empty() {
if !sub.is_empty() {
sub.push_str(" · ");
}
sub.push_str(&ts_display);
}
if !sub.is_empty() {
lines.push(Line::from(Span::styled(
sub,
Style::default().fg(palette::TEXT_MUTED),
)));
}
let rendered = crate::tui::markdown_render::render_markdown(&body, content_width, body_style);
for (idx, line) in rendered.into_iter().enumerate() {
if idx == 0 {
let mut spans = vec![Span::styled(
TRANSCRIPT_RAIL.to_string(),
Style::default().fg(palette::TEXT_DIM),
)];
spans.extend(line.spans);
lines.push(Line::from(spans));
} else {
let mut spans = vec![Span::raw(" ")];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
}
lines.push(Line::from(""));
lines
}
#[must_use]
pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
let mut cells = Vec::new();
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
if msg.role == "assistant"
&& let Some(archived) = parse_archived_context(text)
{
cells.push(archived);
continue;
}
match msg.role.as_str() {
"user" => {
if let Some(HistoryCell::User { content }) = cells.last_mut() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(text);
} else {
cells.push(HistoryCell::User {
content: text.clone(),
});
}
}
"assistant" => {
if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(text);
} else {
cells.push(HistoryCell::Assistant {
content: text.clone(),
streaming: false,
});
}
}
"system" => {
if let Some(HistoryCell::System { content }) = cells.last_mut() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(text);
} else {
cells.push(HistoryCell::System {
content: text.clone(),
});
}
}
_ => {}
}
}
ContentBlock::Thinking { thinking } => {
if let Some(HistoryCell::Thinking { content, .. }) = cells.last_mut() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(thinking);
} else {
cells.push(HistoryCell::Thinking {
content: thinking.clone(),
streaming: false,
duration_secs: None,
});
}
}
_ => {}
}
}
cells
}
#[derive(Debug, Clone)]
pub enum ToolCell {
Exec(ExecCell),
Exploring(ExploringCell),
PlanUpdate(PlanUpdateCell),
PatchSummary(PatchSummaryCell),
Review(ReviewCell),
DiffPreview(DiffPreviewCell),
Mcp(McpToolCell),
ViewImage(ViewImageCell),
WebSearch(WebSearchCell),
Generic(GenericToolCell),
}
impl ToolCell {
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
self.lines_with_motion(width, false)
}
pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
self.render(width, low_motion, RenderMode::Live)
}
pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.render(width, false, RenderMode::Transcript)
}
fn render(&self, width: u16, low_motion: bool, mode: RenderMode) -> Vec<Line<'static>> {
match self {
ToolCell::Exec(cell) => cell.render(width, low_motion, mode),
ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion),
ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion),
ToolCell::PatchSummary(cell) => cell.render(width, low_motion, mode),
ToolCell::Review(cell) => cell.render(width, low_motion, mode),
ToolCell::DiffPreview(cell) => cell.lines_with_motion(width, low_motion),
ToolCell::Mcp(cell) => cell.render(width, low_motion, mode),
ToolCell::ViewImage(cell) => cell.lines_with_motion(width, low_motion),
ToolCell::WebSearch(cell) => cell.lines_with_motion(width, low_motion),
ToolCell::Generic(cell) => cell.lines_with_mode(width, low_motion, mode),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolStatus {
Running,
Success,
Failed,
}
#[derive(Debug, Clone)]
pub struct ExecCell {
pub command: String,
pub status: ToolStatus,
pub output: Option<String>,
pub started_at: Option<Instant>,
pub duration_ms: Option<u64>,
pub source: ExecSource,
pub interaction: Option<String>,
}
impl ExecCell {
#[cfg(test)]
pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
self.render(width, low_motion, RenderMode::Live)
}
pub(super) fn render(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let command_summary = command_header_summary(&self.command);
let header_summary = self
.interaction
.as_deref()
.or(Some(command_summary.as_str()));
lines.push(render_tool_header_with_summary(
"Shell",
header_summary,
tool_status_label(self.status),
self.status,
self.started_at,
low_motion,
));
if self.status == ToolStatus::Success && self.source == ExecSource::User {
lines.extend(render_compact_kv(
"source",
"started by you",
Style::default().fg(palette::TEXT_MUTED),
width,
));
}
if let Some(interaction) = self.interaction.as_ref() {
lines.extend(wrap_plain_line(
&format!(" {interaction}"),
Style::default().fg(palette::TEXT_MUTED),
width,
));
} else {
lines.extend(render_command_mode(&self.command, width, mode));
}
if self.interaction.is_none() {
if let Some(output) = self.output.as_ref() {
lines.extend(render_exec_output_mode(
output,
width,
TOOL_OUTPUT_LINE_LIMIT,
mode,
));
} else if self.status == ToolStatus::Running && self.source == ExecSource::Assistant {
lines.extend(wrap_plain_line(
" Ctrl+B opens shell controls.",
Style::default().fg(palette::TEXT_MUTED),
width,
));
} else if self.status != ToolStatus::Running {
lines.push(Line::from(Span::styled(
" (no output)",
Style::default().fg(palette::TEXT_MUTED).italic(),
)));
}
}
if let Some(duration_ms) = self.duration_ms {
let seconds = f64::from(u32::try_from(duration_ms).unwrap_or(u32::MAX)) / 1000.0;
lines.extend(render_compact_kv(
"time",
&format!("{seconds:.2}s"),
Style::default().fg(palette::TEXT_DIM),
width,
));
}
lines
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecSource {
User,
Assistant,
}
#[derive(Debug, Clone)]
pub struct ExploringCell {
pub entries: Vec<ExploringEntry>,
}
impl ExploringCell {
pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let all_done = self
.entries
.iter()
.all(|entry| entry.status != ToolStatus::Running);
let status = if all_done {
ToolStatus::Success
} else {
ToolStatus::Running
};
let header_summary = exploring_header_summary(&self.entries);
lines.push(render_tool_header_with_summary(
"Workspace",
header_summary.as_deref(),
if all_done { "done" } else { "running" },
status,
None,
low_motion,
));
for entry in &self.entries {
let prefix = match entry.status {
ToolStatus::Running => "live",
ToolStatus::Success => "done",
ToolStatus::Failed => "issue",
};
lines.extend(render_compact_kv(
prefix,
&entry.label,
tool_value_style(),
width,
));
}
lines
}
#[must_use]
pub fn insert_entry(&mut self, entry: ExploringEntry) -> usize {
self.entries.push(entry);
self.entries.len().saturating_sub(1)
}
}
#[derive(Debug, Clone)]
pub struct ExploringEntry {
pub label: String,
pub status: ToolStatus,
}
#[derive(Debug, Clone)]
pub struct PlanUpdateCell {
pub explanation: Option<String>,
pub steps: Vec<PlanStep>,
pub status: ToolStatus,
}
impl PlanUpdateCell {
pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(render_tool_header(
"Plan",
tool_status_label(self.status),
self.status,
None,
low_motion,
));
if let Some(explanation) = self.explanation.as_ref() {
lines.extend(render_message(
"",
system_label_style(),
system_body_style(),
explanation,
width,
));
}
for step in &self.steps {
let marker = match step.status.as_str() {
"completed" => "done",
"in_progress" => "live",
_ => "next",
};
lines.extend(render_compact_kv(
marker,
&step.step,
tool_value_style(),
width,
));
}
lines
}
}
#[derive(Debug, Clone)]
pub struct PlanStep {
pub step: String,
pub status: String,
}
#[derive(Debug, Clone)]
pub struct PatchSummaryCell {
pub path: String,
pub summary: String,
pub status: ToolStatus,
pub error: Option<String>,
}
impl PatchSummaryCell {
pub(super) fn render(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(render_tool_header_with_summary(
"Patch",
Some(&self.path),
tool_status_label(self.status),
self.status,
None,
low_motion,
));
lines.extend(render_compact_kv(
"file",
&self.path,
tool_value_style(),
width,
));
lines.extend(render_tool_output_mode(
&self.summary,
width,
TOOL_COMMAND_LINE_LIMIT,
mode,
));
if let Some(error) = self.error.as_ref() {
lines.extend(render_tool_output_mode(
error,
width,
TOOL_COMMAND_LINE_LIMIT,
mode,
));
}
lines
}
}
#[derive(Debug, Clone)]
pub struct ReviewCell {
pub target: String,
pub status: ToolStatus,
pub output: Option<ReviewOutput>,
pub error: Option<String>,
}
impl ReviewCell {
pub(super) fn render(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(render_tool_header(
"Review",
tool_status_label(self.status),
self.status,
None,
low_motion,
));
if !self.target.trim().is_empty() {
lines.extend(render_compact_kv(
"target",
self.target.trim(),
tool_value_style(),
width,
));
}
if self.status == ToolStatus::Running {
return lines;
}
if let Some(error) = self.error.as_ref() {
lines.extend(render_tool_output_mode(
error,
width,
TOOL_COMMAND_LINE_LIMIT,
mode,
));
return lines;
}
let Some(output) = self.output.as_ref() else {
return lines;
};
if !output.summary.trim().is_empty() {
lines.extend(wrap_plain_line(
&format!("Summary: {}", output.summary.trim()),
Style::default().fg(palette::TEXT_PRIMARY),
width,
));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Issues",
Style::default()
.fg(palette::DEEPSEEK_BLUE)
.add_modifier(Modifier::BOLD),
)));
if output.issues.is_empty() {
lines.extend(wrap_plain_line(
" (none)",
Style::default().fg(palette::TEXT_MUTED),
width,
));
} else {
for issue in &output.issues {
let severity = issue.severity.trim().to_ascii_lowercase();
let color = review_severity_color(&severity);
let location = format_review_location(issue.path.as_ref(), issue.line);
let label = if location.is_empty() {
format!(" - [{}] {}", severity, issue.title.trim())
} else {
format!(" - [{}] {} ({})", severity, issue.title.trim(), location)
};
lines.extend(wrap_plain_line(&label, Style::default().fg(color), width));
if !issue.description.trim().is_empty() {
lines.extend(wrap_plain_line(
&format!(" {}", issue.description.trim()),
Style::default().fg(palette::TEXT_MUTED),
width,
));
}
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Suggestions",
Style::default()
.fg(palette::DEEPSEEK_BLUE)
.add_modifier(Modifier::BOLD),
)));
if output.suggestions.is_empty() {
lines.extend(wrap_plain_line(
" (none)",
Style::default().fg(palette::TEXT_MUTED),
width,
));
} else {
for suggestion in &output.suggestions {
let location = format_review_location(suggestion.path.as_ref(), suggestion.line);
let label = if location.is_empty() {
format!(" - {}", suggestion.suggestion.trim())
} else {
format!(" - {} ({})", suggestion.suggestion.trim(), location)
};
lines.extend(wrap_plain_line(
&label,
Style::default().fg(palette::TEXT_PRIMARY),
width,
));
}
}
if !output.overall_assessment.trim().is_empty() {
lines.push(Line::from(""));
lines.extend(wrap_plain_line(
&format!("Overall: {}", output.overall_assessment.trim()),
Style::default().fg(palette::TEXT_PRIMARY),
width,
));
}
lines
}
}
#[derive(Debug, Clone)]
pub struct DiffPreviewCell {
pub title: String,
pub diff: String,
}
impl DiffPreviewCell {
pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let diff_summary = diff_render::diff_summary_label(&self.diff);
lines.push(render_tool_header_with_summary(
"Diff",
diff_summary.as_deref(),
"done",
ToolStatus::Success,
None,
low_motion,
));
lines.extend(render_compact_kv(
"title",
&self.title,
tool_value_style(),
width,
));
lines.extend(diff_render::render_diff(&self.diff, width));
lines
}
}
#[derive(Debug, Clone)]
pub struct McpToolCell {
pub tool: String,
pub status: ToolStatus,
pub content: Option<String>,
pub is_image: bool,
}
impl McpToolCell {
pub(super) fn render(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(render_tool_header_with_summary(
"Tool",
Some(&self.tool),
tool_status_label(self.status),
self.status,
None,
low_motion,
));
lines.extend(render_compact_kv(
"name",
&self.tool,
tool_value_style(),
width,
));
if self.is_image {
lines.extend(render_compact_kv(
"result",
"image",
tool_value_style(),
width,
));
}
if let Some(content) = self.content.as_ref() {
lines.extend(render_tool_output_mode(
content,
width,
TOOL_COMMAND_LINE_LIMIT,
mode,
));
}
lines
}
}
#[derive(Debug, Clone)]
pub struct ViewImageCell {
pub path: PathBuf,
}
impl ViewImageCell {
pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
let path = self.path.display().to_string();
let mut lines = vec![render_tool_header_with_summary(
"Image",
Some(&path),
"done",
ToolStatus::Success,
None,
low_motion,
)];
lines.extend(render_compact_kv("path", &path, tool_value_style(), width));
lines
}
}
#[derive(Debug, Clone)]
pub struct WebSearchCell {
pub query: String,
pub status: ToolStatus,
pub summary: Option<String>,
}
impl WebSearchCell {
pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(render_tool_header_with_summary(
"Search",
Some(&self.query),
tool_status_label(self.status),
self.status,
None,
low_motion,
));
lines.extend(render_compact_kv(
"query",
&self.query,
tool_value_style(),
width,
));
if let Some(summary) = self.summary.as_ref() {
lines.extend(render_compact_kv(
"result",
summary,
tool_value_style(),
width,
));
}
lines
}
}
#[derive(Debug, Clone)]
pub struct GenericToolCell {
pub name: String,
pub status: ToolStatus,
pub input_summary: Option<String>,
pub output: Option<String>,
pub prompts: Option<Vec<String>>,
pub spillover_path: Option<std::path::PathBuf>,
}
impl GenericToolCell {
pub fn lines_with_mode(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
if let Some(lines) = self.try_render_as_checklist(width, low_motion, mode) {
return lines;
}
if matches!(mode, RenderMode::Live) && self.name == "agent_spawn" {
return self.render_agent_spawn_compact(low_motion);
}
let mut lines = Vec::new();
let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
let header_summary = crate::tui::widgets::tool_card::tool_header_summary_for_name(
&self.name,
self.input_summary.as_deref(),
);
lines.push(render_tool_header_with_family_and_summary(
family,
header_summary.as_deref(),
tool_status_label(self.status),
self.status,
None,
low_motion,
));
lines.extend(render_compact_kv(
"name",
&self.name,
tool_value_style(),
width,
));
let show_prompts = matches!(self.status, ToolStatus::Running) || self.output.is_none();
if show_prompts
&& let Some(prompts) = self.prompts.as_ref()
&& !prompts.is_empty()
{
for (idx, prompt) in prompts.iter().enumerate() {
let label = if idx == 0 { "prompts" } else { "" };
let value = format!("[{idx}] {}", truncate_text(prompt.trim(), 200));
lines.extend(render_card_detail_line(
if label.is_empty() { None } else { Some(label) },
&value,
tool_value_style(),
width,
));
}
} else {
let show_args = matches!(self.status, ToolStatus::Running) || self.output.is_none();
if show_args && let Some(summary) = self.input_summary.as_ref() {
lines.extend(render_compact_kv(
"args",
summary,
tool_value_style(),
width,
));
}
}
if let Some(output) = self.output.as_ref() {
if output_looks_like_diff(output) {
let diff_summary = diff_render::diff_summary_label(output);
lines.push(render_tool_header_with_summary(
"Diff",
diff_summary.as_deref(),
tool_status_label(self.status),
self.status,
None,
low_motion,
));
lines.extend(diff_render::render_diff(output, width));
} else {
lines.extend(render_tool_output_mode(
output,
width,
TOOL_OUTPUT_LINE_LIMIT,
mode,
));
}
if matches!(mode, RenderMode::Live)
&& let Some(path) = self.spillover_path.as_ref()
{
lines.push(render_spillover_annotation(path, width));
}
}
lines
}
fn render_agent_spawn_compact(&self, low_motion: bool) -> Vec<Line<'static>> {
let family = crate::tui::widgets::tool_card::ToolFamily::Delegate;
let agent_id = self
.output
.as_deref()
.and_then(extract_agent_id)
.unwrap_or("…");
vec![render_tool_header_with_family_and_summary(
family,
Some(agent_id),
tool_status_label(self.status),
self.status,
None,
low_motion,
)]
}
fn try_render_as_checklist(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Option<Vec<Line<'static>>> {
if !is_checklist_tool_name(&self.name) {
return None;
}
let output = self.output.as_ref()?;
let snapshot = parse_checklist_snapshot(output)?;
if matches!(mode, RenderMode::Live)
&& let Some(change) = parse_update_prefix(output)
{
return Some(render_checklist_change_card(
&self.name,
self.status,
&snapshot,
&change,
width,
low_motion,
));
}
Some(render_checklist_card(
&self.name,
self.status,
&snapshot,
width,
low_motion,
mode,
))
}
}
fn render_spillover_annotation(path: &std::path::Path, width: u16) -> Line<'static> {
let display = path.display().to_string();
let prefix = " full output: ";
let budget = usize::from(width).saturating_sub(prefix.len()).max(8);
let truncated = truncate_text(&display, budget);
Line::from(vec![
Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)),
Span::styled(truncated, Style::default().fg(palette::TEXT_MUTED).italic()),
])
}
fn extract_agent_id(output: &str) -> Option<&str> {
let key = "\"agent_id\"";
let key_idx = output.find(key)?;
let rest = &output[key_idx + key.len()..];
let colon = rest.find(':')?;
let after_colon = rest[colon + 1..].trim_start();
let after_colon = after_colon.strip_prefix('"')?;
let end = after_colon.find('"')?;
let id = &after_colon[..end];
(!id.is_empty()).then_some(id)
}
fn is_checklist_tool_name(name: &str) -> bool {
matches!(
name,
"checklist_write"
| "checklist_add"
| "checklist_update"
| "todo_write"
| "todo_add"
| "todo_update"
)
}
fn output_looks_like_diff(output: &str) -> bool {
let mut lines = output.lines();
for _ in 0..5 {
let Some(line) = lines.next() else { break };
let trimmed = line.trim();
if trimmed.starts_with("@@") || trimmed.starts_with("diff --git") {
return true;
}
}
false
}
#[derive(Debug, Clone)]
struct ChecklistItemSnapshot {
content: String,
status: String,
}
#[derive(Debug, Clone, Default)]
struct ChecklistSnapshot {
items: Vec<ChecklistItemSnapshot>,
completion_pct: u8,
completed: usize,
total: usize,
}
fn parse_checklist_snapshot(output: &str) -> Option<ChecklistSnapshot> {
let json_start = output.find('{')?;
let parsed: Value = serde_json::from_str(&output[json_start..]).ok()?;
let items_value = parsed.get("items")?.as_array()?;
let items: Vec<ChecklistItemSnapshot> = items_value
.iter()
.map(|item| ChecklistItemSnapshot {
content: item
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
status: item
.get("status")
.and_then(Value::as_str)
.unwrap_or("pending")
.to_string(),
})
.collect();
if items.is_empty() {
return None;
}
let completed = items
.iter()
.filter(|item| item.status.eq_ignore_ascii_case("completed"))
.count();
let total = items.len();
let completion_pct = parsed
.get("completion_pct")
.and_then(Value::as_u64)
.map(|pct| u8::try_from(pct.min(100)).unwrap_or(100))
.unwrap_or_else(|| {
(completed * 100)
.checked_div(total)
.and_then(|pct| u8::try_from(pct).ok())
.unwrap_or(0)
});
Some(ChecklistSnapshot {
items,
completion_pct,
completed,
total,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ChecklistChange {
id: u32,
status: String,
}
fn parse_update_prefix(output: &str) -> Option<ChecklistChange> {
let first = output.lines().next()?.trim();
let rest = first
.strip_prefix("Updated todo #")
.or_else(|| first.strip_prefix("Updated checklist #"))?;
let (id_str, after) = rest.split_once(' ')?;
let id: u32 = id_str.parse().ok()?;
let status = after.strip_prefix("to ")?.trim().to_string();
if status.is_empty() {
return None;
}
Some(ChecklistChange { id, status })
}
fn render_checklist_change_card(
name: &str,
status: ToolStatus,
snapshot: &ChecklistSnapshot,
change: &ChecklistChange,
width: u16,
low_motion: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let header_summary = format!(
"{}/{} \u{00B7} {}%",
snapshot.completed, snapshot.total, snapshot.completion_pct
);
let family = crate::tui::widgets::tool_card::tool_family_for_name(name);
lines.push(render_tool_header_with_family_and_summary(
family,
Some(&header_summary),
tool_status_label(status),
status,
None,
low_motion,
));
let item = (change.id as usize)
.checked_sub(1)
.and_then(|idx| snapshot.items.get(idx));
let title = item
.map(|i| i.content.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "(missing title)".to_string());
let (marker, marker_color) = checklist_status_marker(&change.status);
let prefix = format!("{marker} ");
let prefix_width =
UnicodeWidthStr::width(TRANSCRIPT_RAIL) + UnicodeWidthStr::width(prefix.as_str());
let id_label = format!("Todo #{}", change.id);
let arrow = " \u{2192} ";
let status_label = change.status.clone();
let title_budget = usize::from(width)
.saturating_sub(prefix_width)
.saturating_sub(UnicodeWidthStr::width(id_label.as_str()))
.saturating_sub(UnicodeWidthStr::width(arrow))
.saturating_sub(UnicodeWidthStr::width(status_label.as_str()))
.saturating_sub(2)
.max(8);
let title_truncated = truncate_text(title.as_str(), title_budget);
let spans = vec![
Span::styled(
"\u{258F} ".to_string(),
Style::default().fg(palette::TEXT_DIM),
),
Span::styled(prefix, Style::default().fg(marker_color)),
Span::styled(id_label, Style::default().fg(palette::TEXT_DIM)),
Span::styled(": ".to_string(), Style::default().fg(palette::TEXT_DIM)),
Span::styled(title_truncated, tool_value_style()),
Span::styled(arrow.to_string(), Style::default().fg(palette::TEXT_DIM)),
Span::styled(status_label, Style::default().fg(marker_color)),
];
lines.push(Line::from(spans));
lines.push(render_card_detail_line_single(
None,
&format!(
"{} item{} (Alt+V for full list)",
snapshot.total,
if snapshot.total == 1 { "" } else { "s" }
),
Style::default().fg(palette::TEXT_MUTED),
));
lines
}
fn checklist_status_marker(status: &str) -> (&'static str, Color) {
match status.to_ascii_lowercase().as_str() {
"completed" | "done" => ("\u{2611}", palette::STATUS_SUCCESS), "in_progress" | "inprogress" | "running" => ("\u{25D0}", palette::DEEPSEEK_SKY), "blocked" | "failed" => ("\u{2717}", palette::STATUS_ERROR), "cancelled" | "canceled" | "skipped" => ("\u{2298}", palette::TEXT_MUTED), _ => ("\u{2610}", palette::TEXT_MUTED), }
}
const CHECKLIST_LIVE_ITEM_LIMIT: usize = 8;
fn render_checklist_card(
name: &str,
status: ToolStatus,
snapshot: &ChecklistSnapshot,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let header_summary = format!(
"{}/{} \u{00B7} {}%",
snapshot.completed, snapshot.total, snapshot.completion_pct
);
let family = crate::tui::widgets::tool_card::tool_family_for_name(name);
lines.push(render_tool_header_with_family_and_summary(
family,
Some(&header_summary),
tool_status_label(status),
status,
None,
low_motion,
));
lines.extend(render_compact_kv(
"checklist",
name,
tool_value_style(),
width,
));
let cap = match mode {
RenderMode::Live => CHECKLIST_LIVE_ITEM_LIMIT,
RenderMode::Transcript => snapshot.items.len(),
};
let visible: Vec<&ChecklistItemSnapshot> = snapshot.items.iter().take(cap).collect();
let omitted = snapshot.items.len().saturating_sub(visible.len());
for item in visible {
let (marker, color) = checklist_status_marker(&item.status);
let prefix = format!("{marker} ");
let prefix_width =
UnicodeWidthStr::width(TRANSCRIPT_RAIL) + UnicodeWidthStr::width(prefix.as_str());
let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
for (idx, part) in wrap_text(item.content.trim(), content_width)
.into_iter()
.enumerate()
{
let mut spans = vec![Span::styled(
"\u{258F} ".to_string(),
Style::default().fg(palette::TEXT_DIM),
)];
if idx == 0 {
spans.push(Span::styled(prefix.clone(), Style::default().fg(color)));
} else {
spans.push(Span::raw(
" ".repeat(UnicodeWidthStr::width(prefix.as_str())),
));
}
spans.push(Span::styled(part, tool_value_style()));
lines.push(Line::from(spans));
}
}
if omitted > 0 {
lines.push(render_card_detail_line_single(
None,
&format!("+{omitted} more (Alt+V for full list)"),
Style::default().fg(palette::TEXT_DIM),
));
}
lines
}
fn summarize_string_value(text: &str, max_len: usize, count_only: bool) -> String {
let trimmed = text.trim();
let len = trimmed.chars().count();
if count_only || len > max_len {
return format!("<{len} chars>");
}
truncate_text(trimmed, max_len)
}
fn summarize_inline_value(value: &Value, max_len: usize, count_only: bool) -> String {
match value {
Value::String(s) => summarize_string_value(s, max_len, count_only),
Value::Array(items) => format!("<{} items>", items.len()),
Value::Object(map) => format!("<{} keys>", map.len()),
Value::Bool(b) => b.to_string(),
Value::Number(num) => num.to_string(),
Value::Null => "null".to_string(),
}
}
#[must_use]
pub fn summarize_tool_args(input: &Value) -> Option<String> {
let obj = input.as_object()?;
if obj.is_empty() {
return None;
}
let mut parts = Vec::new();
if let Some(value) = obj.get("path") {
parts.push(format!(
"path: {}",
summarize_inline_value(value, 80, false)
));
}
if let Some(value) = obj.get("command") {
parts.push(format!(
"command: {}",
summarize_inline_value(value, 80, false)
));
}
if let Some(value) = obj.get("query") {
parts.push(format!(
"query: {}",
summarize_inline_value(value, 80, false)
));
}
if let Some(value) = obj.get("prompt") {
parts.push(format!(
"prompt: {}",
summarize_inline_value(value, 80, false)
));
}
if let Some(value) = obj.get("text") {
parts.push(format!(
"text: {}",
summarize_inline_value(value, 80, false)
));
}
if let Some(value) = obj.get("pattern") {
parts.push(format!(
"pattern: {}",
summarize_inline_value(value, 80, false)
));
}
if let Some(value) = obj.get("model") {
parts.push(format!(
"model: {}",
summarize_inline_value(value, 40, false)
));
}
if let Some(value) = obj.get("file_id") {
parts.push(format!(
"file_id: {}",
summarize_inline_value(value, 40, false)
));
}
if let Some(value) = obj.get("task_id") {
parts.push(format!(
"task_id: {}",
summarize_inline_value(value, 40, false)
));
}
if let Some(value) = obj.get("voice_id") {
parts.push(format!(
"voice_id: {}",
summarize_inline_value(value, 40, false)
));
}
if let Some(value) = obj.get("content") {
parts.push(format!(
"content: {}",
summarize_inline_value(value, 0, true)
));
}
if parts.is_empty()
&& let Some((key, value)) = obj.iter().next()
{
return Some(format!(
"{}: {}",
key,
summarize_inline_value(value, 80, false)
));
}
if parts.is_empty() {
None
} else {
Some(parts.join(", "))
}
}
#[must_use]
pub fn summarize_tool_output(output: &str) -> String {
if let Ok(json) = serde_json::from_str::<Value>(output) {
if let Some(obj) = json.as_object() {
if let Some(error) = obj.get("error").or(obj.get("status_msg")) {
return format!("Error: {}", summarize_inline_value(error, 120, false));
}
let mut parts = Vec::new();
if let Some(status) = obj.get("status").and_then(|v| v.as_str()) {
parts.push(format!("status: {status}"));
}
if let Some(message) = obj.get("message").and_then(|v| v.as_str()) {
parts.push(truncate_text(message, TOOL_TEXT_LIMIT));
}
if let Some(task_id) = obj.get("task_id").and_then(|v| v.as_str()) {
parts.push(format!("task_id: {task_id}"));
}
if let Some(file_id) = obj.get("file_id").and_then(|v| v.as_str()) {
parts.push(format!("file_id: {file_id}"));
}
if let Some(url) = obj
.get("file_url")
.or_else(|| obj.get("url"))
.and_then(|v| v.as_str())
{
parts.push(format!("url: {}", truncate_text(url, 120)));
}
if let Some(data) = obj.get("data") {
parts.push(format!("data: {}", summarize_inline_value(data, 80, true)));
}
if !parts.is_empty() {
return parts.join(" | ");
}
if let Some(content) = obj
.get("content")
.or(obj.get("result"))
.or(obj.get("output"))
{
return summarize_inline_value(content, TOOL_TEXT_LIMIT, false);
}
}
return summarize_inline_value(&json, TOOL_TEXT_LIMIT, true);
}
truncate_text(output, TOOL_TEXT_LIMIT)
}
pub struct McpOutputSummary {
pub content: Option<String>,
pub is_image: bool,
pub is_error: Option<bool>,
}
#[must_use]
pub fn summarize_mcp_output(output: &str) -> McpOutputSummary {
if let Ok(json) = serde_json::from_str::<Value>(output) {
let is_error = json
.get("isError")
.and_then(serde_json::Value::as_bool)
.or_else(|| json.get("is_error").and_then(serde_json::Value::as_bool));
if let Some(blocks) = json.get("content").and_then(|v| v.as_array()) {
let mut lines = Vec::new();
let mut is_image = false;
for block in blocks {
let block_type = block
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
match block_type {
"text" => {
let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
if !text.is_empty() {
lines.push(format!("- text: {}", truncate_text(text, 200)));
}
}
"image" | "image_url" => {
is_image = true;
let url = block
.get("url")
.or_else(|| block.get("image_url"))
.and_then(|v| v.as_str());
if let Some(url) = url {
lines.push(format!("- image: {}", truncate_text(url, 200)));
} else {
lines.push("- image".to_string());
}
}
"resource" | "resource_link" => {
let uri = block
.get("uri")
.or_else(|| block.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("<resource>");
lines.push(format!("- resource: {}", truncate_text(uri, 200)));
}
other => {
lines.push(format!("- {other} content"));
}
}
}
return McpOutputSummary {
content: if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
},
is_image,
is_error,
};
}
}
McpOutputSummary {
content: Some(summarize_tool_output(output)),
is_image: output_is_image(output),
is_error: None,
}
}
#[must_use]
pub fn output_is_image(output: &str) -> bool {
let lower = output.to_lowercase();
[
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ppm",
]
.iter()
.any(|ext| lower.contains(ext))
}
#[must_use]
pub fn extract_reasoning_summary(text: &str) -> Option<String> {
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim();
if trimmed.to_lowercase().starts_with("summary") {
let mut summary = String::new();
if let Some((_, rest)) = trimmed.split_once(':')
&& !rest.trim().is_empty()
{
summary.push_str(rest.trim());
summary.push('\n');
}
while let Some(next) = lines.peek() {
let next_trimmed = next.trim();
if next_trimmed.is_empty() {
break;
}
if next_trimmed.starts_with('#') || next_trimmed.starts_with("**") {
break;
}
summary.push_str(next_trimmed);
summary.push('\n');
lines.next();
}
let summary = summary.trim().to_string();
return if summary.is_empty() {
None
} else {
Some(summary)
};
}
}
let fallback = text.trim();
if fallback.is_empty() {
None
} else {
Some(fallback.to_string())
}
}
fn render_thinking(
content: &str,
width: u16,
streaming: bool,
duration_secs: Option<f32>,
collapsed: bool,
low_motion: bool,
) -> Vec<Line<'static>> {
let state = thinking_visual_state(streaming, duration_secs);
let style = thinking_style();
let depth = cached_color_depth();
let body_bg = palette::reasoning_surface_tint(depth);
let body_style = match body_bg {
Some(bg) => style.italic().bg(bg),
None => style.italic(),
};
let mut lines = Vec::new();
let mut header_spans = vec![
Span::styled(
format!("{REASONING_OPENER} "),
Style::default().fg(thinking_state_accent(state)),
),
Span::styled("thinking", thinking_title_style()),
];
header_spans.push(Span::styled(" ", Style::default()));
header_spans.push(Span::styled(
thinking_status_label(state),
thinking_status_style(state),
));
if let Some(dur) = duration_secs {
header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
header_spans.push(Span::styled(format!("{dur:.1}s"), thinking_meta_style()));
}
lines.push(Line::from(header_spans));
let content_width = width.saturating_sub(3).max(1);
let body_text = if collapsed && streaming {
String::new()
} else if collapsed {
extract_reasoning_summary(content).unwrap_or_else(|| content.trim().to_string())
} else {
content.to_string()
};
let mut rendered = if body_text.trim().is_empty() {
Vec::new()
} else {
markdown_render::render_markdown(&body_text, content_width, body_style)
};
let mut truncated = false;
if collapsed && rendered.len() > THINKING_SUMMARY_LINE_LIMIT {
rendered.truncate(THINKING_SUMMARY_LINE_LIMIT);
truncated = true;
}
let rail_style = Style::default().fg(thinking_state_accent(state));
let cursor_style = Style::default().fg(palette::ACCENT_REASONING_LIVE);
if rendered.is_empty() && streaming {
let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
spans.push(Span::styled("thinking...", body_style.italic()));
if !low_motion {
spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
}
lines.push(Line::from(spans));
}
let last_idx = rendered.len().saturating_sub(1);
for (idx, line) in rendered.into_iter().enumerate() {
let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
spans.extend(line.spans);
if streaming && !low_motion && idx == last_idx {
spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
}
lines.push(Line::from(spans));
}
if collapsed && (!streaming && (truncated || body_text.trim() != content.trim())) {
lines.push(Line::from(vec![
Span::styled(REASONING_RAIL.to_string(), rail_style),
Span::styled(
"thinking collapsed; press Ctrl+O for full text",
Style::default().fg(palette::TEXT_MUTED).italic(),
),
]));
}
lines
}
fn render_message(
prefix: &str,
label_style: Style,
body_style: Style,
content: &str,
width: u16,
) -> Vec<Line<'static>> {
let prefix_width = UnicodeWidthStr::width(prefix);
let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
let content_width = usize::from(width.saturating_sub(prefix_width_u16).max(1));
let mut lines = Vec::new();
let rendered =
markdown_render::render_markdown_tagged(content, content_width as u16, body_style);
for (idx, rendered_line) in rendered.into_iter().enumerate() {
if idx == 0 {
let mut spans = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(
prefix.to_string(),
label_style.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
}
spans.extend(rendered_line.line.spans);
lines.push(Line::from(spans));
} else {
let indent = if prefix.is_empty() {
String::new()
} else if rendered_line.is_code {
" ".repeat(prefix_width + 1)
} else {
let mut s = String::with_capacity(prefix_width + 1);
s.push('\u{258F}');
s.extend(std::iter::repeat_n(' ', prefix_width));
s
};
let rail_style = Style::default().fg(palette::TEXT_DIM);
let mut spans = vec![Span::styled(indent, rail_style)];
spans.extend(rendered_line.line.spans);
lines.push(Line::from(spans));
}
}
if lines.is_empty() {
lines.push(Line::from(""));
}
lines
}
fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let cap = match mode {
RenderMode::Live => TOOL_COMMAND_LINE_LIMIT,
RenderMode::Transcript => usize::MAX,
};
for (count, chunk) in wrap_text(command, width.saturating_sub(4).max(1) as usize)
.into_iter()
.enumerate()
{
if count >= cap {
lines.push(details_affordance_line(
"command clipped; Alt+V for details",
Style::default().fg(palette::TEXT_MUTED),
));
break;
}
lines.extend(render_card_detail_line(
if count == 0 { Some("command") } else { None },
chunk.as_str(),
tool_value_style(),
width,
));
}
lines
}
fn command_header_summary(command: &str) -> String {
command
.lines()
.next()
.unwrap_or(command)
.trim_start_matches("$ ")
.trim()
.to_string()
}
fn exploring_header_summary(entries: &[ExploringEntry]) -> Option<String> {
match entries {
[] => None,
[entry] => Some(entry.label.clone()),
entries => Some(format!("{} items", entries.len())),
}
}
fn render_compact_kv(label: &str, value: &str, style: Style, width: u16) -> Vec<Line<'static>> {
render_card_detail_line(Some(label.trim_end_matches(':')), value, style, width)
}
fn render_tool_output_mode(
output: &str,
width: u16,
line_limit: usize,
mode: RenderMode,
) -> Vec<Line<'static>> {
render_preserved_output_mode(output, width, line_limit, mode, "result")
}
fn review_severity_color(severity: &str) -> Color {
match severity {
"error" => palette::STATUS_ERROR,
"warning" => palette::STATUS_WARNING,
_ => palette::STATUS_INFO,
}
}
fn format_review_location(path: Option<&String>, line: Option<u32>) -> String {
let path = path.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
match (path, line) {
(Some(path), Some(line)) => format!("{path}:{line}"),
(Some(path), None) => path,
(None, Some(line)) => format!("line {line}"),
(None, None) => String::new(),
}
}
fn render_exec_output_mode(
output: &str,
width: u16,
line_limit: usize,
mode: RenderMode,
) -> Vec<Line<'static>> {
render_preserved_output_mode(output, width, line_limit, mode, "output")
}
#[derive(Debug, Clone)]
struct OutputRow {
text: String,
intact: bool,
}
fn render_preserved_output_mode(
output: &str,
width: u16,
line_limit: usize,
mode: RenderMode,
first_label: &str,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if output.trim().is_empty() {
lines.push(Line::from(Span::styled(
" (no output)",
Style::default().fg(palette::TEXT_MUTED).italic(),
)));
return lines;
}
let all_lines = output_rows(output, width);
if matches!(mode, RenderMode::Transcript) {
for (idx, row) in all_lines.iter().enumerate() {
render_output_row(
&mut lines,
if idx == 0 { Some(first_label) } else { None },
row,
width,
);
}
return lines;
}
let selected = selected_output_indices(&all_lines, line_limit);
let mut previous: Option<usize> = None;
for (rendered_idx, idx) in selected.iter().copied().enumerate() {
if let Some(prev) = previous {
let omitted = idx.saturating_sub(prev + 1);
if omitted > 0 {
lines.push(details_affordance_line(
&format!("{omitted} lines omitted; Alt+V for details"),
Style::default().fg(palette::TEXT_MUTED),
));
}
}
let row = &all_lines[idx];
render_output_row(
&mut lines,
if rendered_idx == 0 {
Some(first_label)
} else {
None
},
row,
width,
);
previous = Some(idx);
}
lines
}
fn output_rows(output: &str, width: u16) -> Vec<OutputRow> {
let wrap_width = width.saturating_sub(4).max(1) as usize;
let mut rows = Vec::new();
let mut sanitized = String::with_capacity(output.len());
for line in output.lines() {
sanitized.clear();
crate::tui::osc8::strip_ansi_into(line, &mut sanitized);
let intact = is_path_or_url_like(&sanitized);
if intact {
rows.push(OutputRow {
text: sanitized.clone(),
intact: true,
});
} else {
for wrapped in wrap_text(&sanitized, wrap_width) {
rows.push(OutputRow {
text: wrapped,
intact: false,
});
}
}
}
if rows.is_empty() {
rows.push(OutputRow {
text: String::new(),
intact: false,
});
}
rows
}
fn selected_output_indices(rows: &[OutputRow], line_limit: usize) -> Vec<usize> {
let total = rows.len();
if total <= line_limit || line_limit == 0 {
return (0..total).collect();
}
let head = TOOL_OUTPUT_HEAD_LINES.min(line_limit).min(total);
let tail = TOOL_OUTPUT_TAIL_LINES
.min(line_limit.saturating_sub(head))
.min(total.saturating_sub(head));
let mut selected = std::collections::BTreeSet::new();
selected.extend(0..head);
selected.extend(total.saturating_sub(tail)..total);
let budget = line_limit.saturating_sub(selected.len());
if budget > 0 {
let mut important: Vec<(usize, usize)> = rows
.iter()
.enumerate()
.skip(head)
.take(total.saturating_sub(head + tail))
.filter_map(|(idx, row)| output_importance_rank(&row.text).map(|rank| (idx, rank)))
.collect();
important.sort_by_key(|(idx, rank)| (*rank, *idx));
for (idx, _) in important.into_iter().take(budget) {
selected.insert(idx);
}
}
selected.into_iter().collect()
}
fn output_importance_rank(line: &str) -> Option<usize> {
let lower = line.to_ascii_lowercase();
if [
"error",
"failed",
"failure",
"fatal",
"panic",
"exception",
"traceback",
"denied",
"not found",
"no such file",
"cannot",
"can't",
]
.iter()
.any(|needle| lower.contains(needle))
{
return Some(0);
}
if lower.contains("warning") || lower.contains("warn") {
return Some(1);
}
if is_path_or_url_like(line) {
return Some(2);
}
None
}
fn is_path_or_url_like(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.contains("://") || trimmed.starts_with("file:") {
return true;
}
let has_separator = trimmed.contains('/') || trimmed.contains('\\');
let has_extension = trimmed
.split_whitespace()
.any(|part| part.rsplit_once('.').is_some_and(|(_, ext)| ext.len() <= 8));
has_separator && has_extension
}
fn is_cycle_boundary(content: &str) -> bool {
content.contains("cycle")
}
fn render_cycle_boundary(content: &str, width: u16) -> Vec<Line<'static>> {
let style = Style::default()
.fg(palette::DEEPSEEK_BLUE)
.add_modifier(Modifier::BOLD);
let rule_style = Style::default().fg(palette::TEXT_DIM);
let content_width = usize::from(width.saturating_sub(2).max(1));
let mut lines = Vec::new();
if width >= 4 {
let rule = "\u{2500}".repeat(content_width);
lines.push(Line::from(Span::styled(format!(" {rule}"), rule_style)));
}
let rendered =
crate::tui::markdown_render::render_markdown(content, content_width as u16, style);
for line in rendered {
let mut spans = vec![Span::raw(" ")];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
if lines.len() == 1 && width >= 4 {
lines.push(Line::from(""));
}
lines
}
fn file_line_style(text: &str) -> Option<Style> {
let trimmed = text.trim();
if let Some((before, after)) = trimmed.rsplit_once(':')
&& !before.is_empty()
&& after.chars().all(|c| c.is_ascii_digit())
&& looks_like_file_path(before)
{
Some(
Style::default()
.fg(palette::DEEPSEEK_SKY)
.add_modifier(Modifier::UNDERLINED),
)
} else {
None
}
}
fn diff_line_style(text: &str) -> Option<Style> {
let trimmed = text.trim_start();
if trimmed.starts_with("@@") {
Some(Style::default().fg(palette::DEEPSEEK_BLUE))
} else if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
Some(Style::default().fg(palette::DIFF_ADDED))
} else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
Some(Style::default().fg(palette::STATUS_ERROR))
} else {
None
}
}
fn render_output_row(
lines: &mut Vec<Line<'static>>,
label: Option<&str>,
row: &OutputRow,
width: u16,
) {
let diff_style = diff_line_style(&row.text);
let file_style = file_line_style(&row.text);
let value_style = diff_style.or(file_style).unwrap_or_else(tool_value_style);
if row.intact {
lines.push(render_card_detail_line_single(
label,
&row.text,
value_style,
));
} else {
lines.extend(render_card_detail_line(
label,
&row.text,
value_style,
width,
));
}
}
fn wrap_plain_line(line: &str, style: Style, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for part in wrap_text(line, width.max(1) as usize) {
lines.push(Line::from(Span::styled(part, style)));
}
lines
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
if text.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
for ch in text.chars() {
let tentative = if current.is_empty() {
ch.to_string()
} else {
let mut t = current.clone();
t.push(ch);
t
};
if UnicodeWidthStr::width(tentative.as_str()) > width && !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
current.push(ch);
}
lines.push(current);
if lines.is_empty() {
vec![String::new()]
} else {
lines
}
}
fn status_symbol(started_at: Option<Instant>, status: ToolStatus, low_motion: bool) -> String {
match status {
ToolStatus::Running => {
if low_motion {
return TOOL_RUNNING_SYMBOLS[0].to_string();
}
let elapsed_ms = started_at.map_or_else(
|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_millis())
},
|t| t.elapsed().as_millis(),
);
let cycle = u128::from(TOOL_STATUS_SYMBOL_MS);
let idx = elapsed_ms
.checked_div(cycle)
.map_or(0, |d| d % (TOOL_RUNNING_SYMBOLS.len() as u128));
TOOL_RUNNING_SYMBOLS[usize::try_from(idx).unwrap_or_default()].to_string()
}
ToolStatus::Success => TOOL_DONE_SYMBOL.to_string(),
ToolStatus::Failed => TOOL_FAILED_SYMBOL.to_string(),
}
}
fn details_affordance_line(text: &str, style: Style) -> Line<'static> {
Line::from(vec![
Span::styled(
TRANSCRIPT_RAIL.to_string(),
Style::default().fg(palette::TEXT_DIM),
),
Span::styled(text.to_string(), style),
])
}
fn truncate_text(text: &str, max_len: usize) -> String {
if text.chars().count() <= max_len {
return text.to_string();
}
let mut out = String::new();
for ch in text.chars().take(max_len.saturating_sub(3)) {
out.push(ch);
}
out.push_str("...");
out
}
fn user_label_style() -> Style {
Style::default().fg(palette::TEXT_MUTED)
}
fn user_body_style() -> Style {
Style::default().fg(palette::USER_BODY)
}
fn assistant_label_style_for(streaming: bool, low_motion: bool) -> Style {
let color = if streaming && !low_motion {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
palette::pulse_brightness(palette::DEEPSEEK_SKY, now_ms)
} else {
palette::DEEPSEEK_SKY
};
Style::default().fg(color)
}
fn system_label_style() -> Style {
Style::default().fg(palette::TEXT_DIM)
}
fn message_body_style() -> Style {
Style::default().fg(palette::TEXT_PRIMARY)
}
fn system_body_style() -> Style {
Style::default().fg(palette::TEXT_MUTED).italic()
}
fn error_label_text(severity: crate::error_taxonomy::ErrorSeverity) -> &'static str {
match severity {
crate::error_taxonomy::ErrorSeverity::Critical
| crate::error_taxonomy::ErrorSeverity::Error => "Error",
crate::error_taxonomy::ErrorSeverity::Warning => "Warn",
crate::error_taxonomy::ErrorSeverity::Info => "Info",
}
}
fn error_label_style(severity: crate::error_taxonomy::ErrorSeverity) -> Style {
let color = match severity {
crate::error_taxonomy::ErrorSeverity::Critical
| crate::error_taxonomy::ErrorSeverity::Error => palette::STATUS_ERROR,
crate::error_taxonomy::ErrorSeverity::Warning => palette::STATUS_WARNING,
crate::error_taxonomy::ErrorSeverity::Info => palette::TEXT_DIM,
};
Style::default().fg(color).add_modifier(Modifier::BOLD)
}
fn error_body_style(severity: crate::error_taxonomy::ErrorSeverity) -> Style {
let color = match severity {
crate::error_taxonomy::ErrorSeverity::Critical
| crate::error_taxonomy::ErrorSeverity::Error => palette::STATUS_ERROR,
crate::error_taxonomy::ErrorSeverity::Warning => palette::STATUS_WARNING,
crate::error_taxonomy::ErrorSeverity::Info => palette::TEXT_MUTED,
};
Style::default().fg(color)
}
fn thinking_style() -> Style {
Style::default().fg(palette::TEXT_REASONING)
}
fn render_tool_header(
title: &str,
state: &str,
status: ToolStatus,
started_at: Option<Instant>,
low_motion: bool,
) -> Line<'static> {
let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
render_tool_header_with_family(family, state, status, started_at, low_motion)
}
fn render_tool_header_with_summary(
title: &str,
summary: Option<&str>,
state: &str,
status: ToolStatus,
started_at: Option<Instant>,
low_motion: bool,
) -> Line<'static> {
let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
render_tool_header_with_family_and_summary(
family, summary, state, status, started_at, low_motion,
)
}
fn render_tool_header_with_family(
family: crate::tui::widgets::tool_card::ToolFamily,
state: &str,
status: ToolStatus,
started_at: Option<Instant>,
low_motion: bool,
) -> Line<'static> {
render_tool_header_with_family_and_summary(family, None, state, status, started_at, low_motion)
}
fn render_tool_header_with_family_and_summary(
family: crate::tui::widgets::tool_card::ToolFamily,
summary: Option<&str>,
state: &str,
status: ToolStatus,
started_at: Option<Instant>,
low_motion: bool,
) -> Line<'static> {
let state_owned: String = if state == "running"
&& status == ToolStatus::Running
&& let Some(started) = started_at
{
running_status_label_with_elapsed(started.elapsed().as_secs())
} else {
state.to_string()
};
let glyph = crate::tui::widgets::tool_card::family_glyph(family);
let verb = crate::tui::widgets::tool_card::family_label(family);
let mut spans = vec![
Span::styled(
format!("{} ", status_symbol(started_at, status, low_motion)),
Style::default().fg(tool_state_color(status)),
),
Span::styled(
format!("{glyph} "),
Style::default().fg(tool_state_color(status)),
),
Span::styled(verb.to_string(), tool_title_style()),
Span::styled(" ", Style::default()),
Span::styled(state_owned, tool_status_style(status)),
];
if let Some(summary) = summary.and_then(normalize_header_summary) {
spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
spans.push(Span::styled(
truncate_text(&summary, TOOL_HEADER_SUMMARY_LIMIT),
Style::default().fg(palette::TEXT_MUTED),
));
}
Line::from(spans)
}
fn normalize_header_summary(summary: &str) -> Option<String> {
let normalized = summary
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
pub(crate) fn running_status_label_with_elapsed(elapsed_secs: u64) -> String {
if elapsed_secs < 3 {
"running".to_string()
} else {
format!("running ({elapsed_secs}s)")
}
}
fn render_card_detail_line(
label: Option<&str>,
value: &str,
value_style: Style,
width: u16,
) -> Vec<Line<'static>> {
let label_text = label.map(|text| format!("{text}:"));
let prefix_width = UnicodeWidthStr::width(TRANSCRIPT_RAIL)
+ label_text.as_deref().map_or(0, UnicodeWidthStr::width)
+ usize::from(label.is_some());
let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
let mut lines = Vec::new();
for (idx, part) in wrap_text(value, content_width).into_iter().enumerate() {
let mut spans = vec![Span::styled(
TRANSCRIPT_RAIL.to_string(),
Style::default().fg(palette::TEXT_DIM),
)];
if idx == 0 {
if let Some(label_text) = label_text.as_deref() {
spans.push(Span::styled(
label_text.to_string(),
tool_detail_label_style(),
));
spans.push(Span::raw(" "));
}
} else if let Some(label_text) = label_text.as_deref() {
spans.push(Span::raw(
" ".repeat(UnicodeWidthStr::width(label_text) + 1),
));
}
spans.push(Span::styled(part, value_style));
lines.push(Line::from(spans));
}
lines
}
fn render_card_detail_line_single(
label: Option<&str>,
value: &str,
value_style: Style,
) -> Line<'static> {
let label_text = label.map(|text| format!("{text}:"));
let mut spans = vec![Span::styled(
TRANSCRIPT_RAIL.to_string(),
Style::default().fg(palette::TEXT_DIM),
)];
if let Some(label_text) = label_text {
spans.push(Span::styled(label_text, tool_detail_label_style()));
spans.push(Span::raw(" "));
}
spans.push(Span::styled(value.to_string(), value_style));
Line::from(spans)
}
fn tool_title_style() -> Style {
active_theme().tool_title_style()
}
fn tool_status_style(status: ToolStatus) -> Style {
active_theme().tool_status_style(status)
}
fn tool_detail_label_style() -> Style {
active_theme().tool_label_style()
}
fn tool_state_color(status: ToolStatus) -> Color {
active_theme().tool_status_color(status)
}
fn tool_status_label(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Running => "running",
ToolStatus::Success => "done",
ToolStatus::Failed => "issue",
}
}
fn tool_value_style() -> Style {
active_theme().tool_value_style()
}
fn thinking_visual_state(streaming: bool, duration_secs: Option<f32>) -> ThinkingVisualState {
if streaming {
ThinkingVisualState::Live
} else if duration_secs.is_some() {
ThinkingVisualState::Done
} else {
ThinkingVisualState::Idle
}
}
fn thinking_status_label(state: ThinkingVisualState) -> &'static str {
match state {
ThinkingVisualState::Live => "live",
ThinkingVisualState::Done => "done",
ThinkingVisualState::Idle => "idle",
}
}
fn thinking_title_style() -> Style {
Style::default()
.fg(palette::TEXT_SOFT)
.add_modifier(Modifier::BOLD)
}
fn thinking_status_style(state: ThinkingVisualState) -> Style {
Style::default().fg(match state {
ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
ThinkingVisualState::Done => palette::TEXT_DIM,
ThinkingVisualState::Idle => palette::TEXT_DIM,
})
}
fn thinking_meta_style() -> Style {
Style::default().fg(palette::TEXT_DIM)
}
fn thinking_state_accent(state: ThinkingVisualState) -> Color {
match state {
ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
ThinkingVisualState::Done => palette::TEXT_DIM,
ThinkingVisualState::Idle => palette::TEXT_DIM,
}
}
static COLOR_DEPTH: std::sync::OnceLock<palette::ColorDepth> = std::sync::OnceLock::new();
fn cached_color_depth() -> palette::ColorDepth {
*COLOR_DEPTH.get_or_init(palette::ColorDepth::detect)
}
pub fn try_open_file_at_line(text: &str, workspace: &Path) -> bool {
let editor = std::env::var("VISUAL")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
std::env::var("EDITOR")
.ok()
.filter(|s| !s.trim().is_empty())
})
.unwrap_or_else(|| "vim".to_string());
let mut any_opened = false;
for line in text.lines() {
let trimmed = line.trim();
if let Some((before, after)) = trimmed.rsplit_once(':')
&& after.chars().all(|c| c.is_ascii_digit())
{
let line_num: u32 = after.parse().unwrap_or(1);
let path_str = before.trim();
if !path_str.is_empty() && looks_like_file_path(path_str) {
let abs_path = if Path::new(path_str).is_absolute() {
PathBuf::from(path_str)
} else {
workspace.join(path_str)
};
if abs_path.is_file()
&& Command::new(&editor)
.arg(format!("+{line_num}"))
.arg(&abs_path)
.spawn()
.is_ok()
{
any_opened = true;
}
}
}
}
any_opened
}
fn looks_like_file_path(s: &str) -> bool {
if s.contains('/') || s.contains('\\') {
return true;
}
if let Some((_, ext)) = s.rsplit_once('.') {
let ext = ext.trim();
matches!(
ext,
"rs" | "toml"
| "md"
| "sh"
| "py"
| "js"
| "ts"
| "json"
| "yaml"
| "yml"
| "css"
| "html"
| "go"
| "c"
| "h"
| "cpp"
| "hpp"
| "java"
| "kt"
| "swift"
| "rb"
| "php"
| "lua"
| "zig"
| "mod"
| "sum"
| "lock"
| "txt"
| "ini"
| "cfg"
| "conf"
| "env"
| "gitignore"
| "dockerfile"
| "sql"
| "r"
| "ex"
| "exs"
| "vue"
| "svelte"
| "tsx"
| "jsx"
| "scss"
| "sass"
| "less"
| "gradle"
| "properties"
| "xml"
| "proto"
| "nix"
)
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::{
ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanStep,
PlanUpdateCell, REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS,
TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions, USER_GLYPH,
assistant_label_style_for, extract_reasoning_summary, render_thinking,
running_status_label_with_elapsed,
};
use crate::deepseek_theme::Theme;
use crate::models::{ContentBlock, Message};
use crate::palette;
use ratatui::style::Modifier;
use std::time::{Duration, Instant};
#[test]
fn render_spillover_annotation_shows_path() {
use std::path::PathBuf;
let cell = GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Success,
input_summary: Some("cmd: cargo build --release".to_string()),
output: Some("very large output...".to_string()),
prompts: None,
spillover_path: Some(PathBuf::from(
"/Users/dev/.deepseek/tool_outputs/call-abc12.txt",
)),
};
let lines = cell.lines_with_mode(120, true, super::RenderMode::Live);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
joined.contains("full output:"),
"expected annotation prefix: {joined:?}"
);
assert!(
joined.contains("/Users/dev/.deepseek/tool_outputs/call-abc12.txt"),
"expected the spillover path: {joined:?}"
);
}
#[test]
fn render_spillover_annotation_omitted_in_transcript_mode() {
use std::path::PathBuf;
let cell = GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Success,
input_summary: None,
output: Some("output".to_string()),
prompts: None,
spillover_path: Some(PathBuf::from("/tmp/spill.txt")),
};
let lines = cell.lines_with_mode(120, true, super::RenderMode::Transcript);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
!joined.contains("full output:"),
"annotation should be omitted in transcript mode: {joined:?}"
);
}
#[test]
fn render_spillover_annotation_omitted_when_no_path_set() {
let cell = GenericToolCell {
name: "read_file".to_string(),
status: ToolStatus::Success,
input_summary: None,
output: Some("contents".to_string()),
prompts: None,
spillover_path: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(!joined.contains("full output:"), "{joined:?}");
}
#[test]
fn render_spillover_annotation_truncates_to_width() {
use std::path::PathBuf;
let long_path = "/Users/dev/.deepseek/tool_outputs/this-is-a-very-long-tool-call-id-that-will-not-fit-in-narrow-widths.txt";
let cell = GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Success,
input_summary: None,
output: Some("output".to_string()),
prompts: None,
spillover_path: Some(PathBuf::from(long_path)),
};
let lines = cell.lines_with_mode(40, true, super::RenderMode::Live);
let annotation_line = lines
.iter()
.find(|l| {
l.spans
.iter()
.any(|s| s.content.as_ref().contains("full output:"))
})
.expect("annotation line present");
let rendered: String = annotation_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
rendered.chars().count() <= 60,
"annotation overflowed at width 40: {} chars: {rendered:?}",
rendered.chars().count()
);
}
#[test]
fn extract_agent_id_pulls_id_from_json_output() {
let output =
r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#;
assert_eq!(super::extract_agent_id(output), Some("agent-abc12"));
}
#[test]
fn extract_agent_id_handles_extra_whitespace() {
let output = r#"{
"agent_id" : "agent-xyz",
"model": "x"
}"#;
assert_eq!(super::extract_agent_id(output), Some("agent-xyz"));
}
#[test]
fn extract_agent_id_returns_none_when_missing() {
let output = r#"{"nickname": "Orca", "model": "x"}"#;
assert!(super::extract_agent_id(output).is_none());
assert!(super::extract_agent_id("(not json)").is_none());
assert!(super::extract_agent_id("").is_none());
}
#[test]
fn extract_agent_id_returns_none_for_empty_id() {
let output = r#"{"agent_id": "", "model": "x"}"#;
assert!(super::extract_agent_id(output).is_none());
}
#[test]
fn agent_spawn_renders_single_compact_line_in_live_mode() {
let cell = GenericToolCell {
name: "agent_spawn".to_string(),
status: ToolStatus::Running,
input_summary: Some("prompt: do thing".to_string()),
output: Some(
r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#
.to_string(),
),
prompts: None,
spillover_path: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
assert_eq!(lines.len(), 1, "expected exactly 1 line, got {:?}", lines);
let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rendered.contains("agent-abc12"),
"expected agent id in header: {rendered:?}"
);
assert!(
rendered.contains("running"),
"expected status in header: {rendered:?}"
);
assert!(
!rendered.contains("args"),
"args should be hidden: {rendered:?}"
);
}
#[test]
fn agent_spawn_pending_render_uses_placeholder_id() {
let cell = GenericToolCell {
name: "agent_spawn".to_string(),
status: ToolStatus::Running,
input_summary: Some("prompt: do thing".to_string()),
output: None,
prompts: None,
spillover_path: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
assert_eq!(lines.len(), 1);
let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains('\u{2026}'), "{rendered:?}"); }
#[test]
fn agent_spawn_transcript_mode_keeps_full_block() {
let cell = GenericToolCell {
name: "agent_spawn".to_string(),
status: ToolStatus::Success,
input_summary: Some("prompt: do thing".to_string()),
output: Some(
r#"{"agent_id": "agent-abc12", "model": "deepseek-v4-flash"}"#.to_string(),
),
prompts: None,
spillover_path: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Transcript);
assert!(lines.len() > 1, "expected verbose transcript render");
}
#[test]
fn other_tools_are_unaffected_by_agent_spawn_compact_path() {
let cell = GenericToolCell {
name: "read_file".to_string(),
status: ToolStatus::Success,
input_summary: Some("path: foo.rs".to_string()),
output: Some("first line\nsecond line\nthird line".to_string()),
prompts: None,
spillover_path: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
assert!(
lines.len() > 1,
"non-spawn tools should keep their full block"
);
}
#[test]
fn parse_update_prefix_recognises_todo_form() {
let parsed =
super::parse_update_prefix("Updated todo #3 to in_progress\n{ \"items\": [...] }");
assert_eq!(
parsed,
Some(super::ChecklistChange {
id: 3,
status: "in_progress".to_string(),
}),
);
}
#[test]
fn parse_update_prefix_recognises_checklist_form() {
let parsed =
super::parse_update_prefix("Updated checklist #7 to completed\n{ \"items\": [] }");
assert_eq!(
parsed,
Some(super::ChecklistChange {
id: 7,
status: "completed".to_string(),
}),
);
}
#[test]
fn parse_update_prefix_returns_none_for_writes() {
assert!(super::parse_update_prefix("{ \"items\": [] }").is_none());
assert!(super::parse_update_prefix("Wrote 5 todos\n{}").is_none());
}
#[test]
fn parse_update_prefix_returns_none_for_malformed() {
assert!(super::parse_update_prefix("Updated todo #3\n").is_none());
assert!(super::parse_update_prefix("Updated todo #foo to done\n").is_none());
}
#[test]
fn render_checklist_change_card_shows_only_changed_item() {
let snapshot = super::ChecklistSnapshot {
items: vec![
super::ChecklistItemSnapshot {
content: "Read the spec".to_string(),
status: "completed".to_string(),
},
super::ChecklistItemSnapshot {
content: "Write the test".to_string(),
status: "in_progress".to_string(),
},
super::ChecklistItemSnapshot {
content: "Land the PR".to_string(),
status: "pending".to_string(),
},
],
completion_pct: 33,
completed: 1,
total: 3,
};
let change = super::ChecklistChange {
id: 2,
status: "in_progress".to_string(),
};
let lines = super::render_checklist_change_card(
"todo_update",
ToolStatus::Success,
&snapshot,
&change,
80,
true,
);
assert!(lines.len() >= 3, "expected ≥3 lines, got {}", lines.len());
let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(change_line.contains("#2"), "missing id: {change_line:?}");
assert!(
change_line.contains("Write the test"),
"missing title: {change_line:?}"
);
assert!(
change_line.contains("in_progress"),
"missing status: {change_line:?}"
);
assert!(
!change_line.contains("Land the PR"),
"should not show other items: {change_line:?}"
);
assert!(
!change_line.contains("Read the spec"),
"should not show other items: {change_line:?}"
);
let summary_line: String = lines
.last()
.unwrap()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(summary_line.contains("3 items"), "{summary_line:?}");
assert!(summary_line.contains("Alt+V"), "{summary_line:?}");
}
#[test]
fn render_checklist_change_card_handles_missing_title_gracefully() {
let snapshot = super::ChecklistSnapshot {
items: vec![super::ChecklistItemSnapshot {
content: "only item".to_string(),
status: "pending".to_string(),
}],
completion_pct: 0,
completed: 0,
total: 1,
};
let change = super::ChecklistChange {
id: 99,
status: "completed".to_string(),
};
let lines = super::render_checklist_change_card(
"todo_update",
ToolStatus::Success,
&snapshot,
&change,
80,
true,
);
let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(change_line.contains("#99"));
assert!(change_line.contains("(missing title)"));
}
#[test]
fn running_status_label_omits_elapsed_below_threshold() {
assert_eq!(running_status_label_with_elapsed(0), "running");
assert_eq!(running_status_label_with_elapsed(1), "running");
assert_eq!(running_status_label_with_elapsed(2), "running");
}
#[test]
fn running_status_label_appends_elapsed_at_three_seconds() {
assert_eq!(running_status_label_with_elapsed(3), "running (3s)");
assert_eq!(running_status_label_with_elapsed(7), "running (7s)");
assert_eq!(running_status_label_with_elapsed(120), "running (120s)");
}
#[test]
fn extract_reasoning_summary_prefers_summary_block() {
let text = "Thinking...\nSummary: First line\nSecond line\n\nTail";
let summary = extract_reasoning_summary(text).expect("summary should exist");
assert_eq!(summary, "First line\nSecond line");
}
#[test]
fn extract_reasoning_summary_falls_back_to_full_text() {
let text = "Line one\nLine two";
let summary = extract_reasoning_summary(text).expect("summary should exist");
assert_eq!(summary, "Line one\nLine two");
}
#[test]
fn archived_context_metadata_preserves_spaces_in_attributes() {
let msg = Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
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(),
cache_control: None,
}],
};
let cells = super::history_cells_from_message(&msg);
assert_eq!(cells.len(), 1);
let HistoryCell::ArchivedContext {
level,
range,
tokens,
density,
model,
timestamp,
summary,
} = &cells[0]
else {
panic!("expected archived context cell");
};
assert_eq!(*level, 1);
assert_eq!(range, "msg 0-128");
assert_eq!(tokens, "2499");
assert_eq!(density, "~2,500 tokens");
assert_eq!(model, "deepseek-v4-flash");
assert_eq!(timestamp, "2026-04-28T00:00:00Z");
assert_eq!(summary, "Summary body");
}
#[test]
fn render_thinking_collapsed_shows_details_affordance() {
let lines = render_thinking(
"Summary: First line\nSecond line\nThird line\nFourth line\nFifth line",
80,
false,
Some(2.0),
true,
false,
);
let text = lines
.iter()
.flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
.collect::<String>();
assert!(text.contains("thinking collapsed; press Ctrl+O for full text"));
assert!(text.contains("thinking"));
}
#[test]
fn tool_lines_with_options_respects_low_motion_in_default_path() {
let started_at = Some(Instant::now() - Duration::from_millis(TOOL_STATUS_SYMBOL_MS * 2));
let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "echo hi".to_string(),
status: ToolStatus::Running,
output: None,
started_at,
duration_ms: None,
source: ExecSource::Assistant,
interaction: None,
}));
let animated = cell.lines_with_options(80, TranscriptRenderOptions::default());
let low_motion = cell.lines_with_options(
80,
TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
},
);
let animated_symbol = animated[0].spans[0].content.trim();
let low_motion_symbol = low_motion[0].spans[0].content.trim();
assert_eq!(low_motion_symbol, TOOL_RUNNING_SYMBOLS[0]);
assert_ne!(animated_symbol, TOOL_RUNNING_SYMBOLS[0]);
}
#[test]
fn user_cell_renders_with_bar_glyph_not_literal_label() {
let cell = HistoryCell::User {
content: "hello".to_string(),
};
let lines = cell.lines(80);
let head = &lines[0];
assert_eq!(head.spans[0].content.as_ref(), USER_GLYPH);
let visible: String = head
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(!visible.contains("You"), "user label dropped: {visible:?}");
assert!(visible.contains("hello"));
}
#[test]
fn assistant_cell_renders_with_bullet_glyph_not_literal_label() {
let cell = HistoryCell::Assistant {
content: "ready".to_string(),
streaming: false,
};
let lines = cell.lines(80);
let head = &lines[0];
assert_eq!(head.spans[0].content.as_ref(), ASSISTANT_GLYPH);
let visible: String = head
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(
!visible.contains("Assistant"),
"assistant label dropped: {visible:?}"
);
assert!(visible.contains("ready"));
}
#[test]
fn assistant_code_block_lines_do_not_get_transcript_rail() {
let cell = HistoryCell::Assistant {
content: "SQL:\n```sql\nSELECT\nFROM customers\n```".to_string(),
streaming: false,
};
let visible: Vec<String> = cell
.lines(80)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect();
assert_eq!(visible[0], format!("{ASSISTANT_GLYPH} SQL:"));
for line in visible
.iter()
.filter(|line| line.contains("SELECT") || line.contains("FROM customers"))
{
assert!(
!line.contains('\u{258F}'),
"code block line should not inherit the transcript rail: {line:?}"
);
}
}
#[test]
fn assistant_glyph_holds_full_brightness_when_idle() {
let idle = assistant_label_style_for(false, false);
let low_motion = assistant_label_style_for(true, true);
assert_eq!(idle.fg, Some(palette::DEEPSEEK_SKY));
assert_eq!(low_motion.fg, Some(palette::DEEPSEEK_SKY));
}
#[test]
fn assistant_glyph_pulses_when_streaming_and_motion_allowed() {
use ratatui::style::Color;
let mut saw_dimmed = false;
for _ in 0..50 {
if let Some(Color::Rgb(_, _, b)) = assistant_label_style_for(true, false).fg {
let Color::Rgb(_, _, src_b) = palette::DEEPSEEK_SKY else {
panic!("DEEPSEEK_SKY must be RGB");
};
if b < src_b {
saw_dimmed = true;
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(
saw_dimmed,
"expected the streaming pulse to dip below source brightness at least once",
);
}
#[test]
fn exec_cell_header_uses_run_verb_glyph_and_label() {
let cell = ExecCell {
command: "ls".to_string(),
status: ToolStatus::Success,
output: Some("a\nb\n".to_string()),
started_at: None,
duration_ms: Some(10),
source: ExecSource::Assistant,
interaction: None,
};
let header = &cell.lines_with_motion(80, true)[0];
let visible: String = header
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(
visible.contains('\u{25B6}'),
"Run glyph `▶` present: {visible:?}"
);
assert!(visible.contains(" run "), "verb label `run`: {visible:?}");
assert!(
!visible.contains("Shell"),
"old `Shell` literal is gone: {visible:?}"
);
}
#[test]
fn exec_cell_header_includes_compact_command_summary() {
let cell = ExecCell {
command: "cargo test --workspace --all-features".to_string(),
status: ToolStatus::Running,
output: None,
started_at: None,
duration_ms: None,
source: ExecSource::Assistant,
interaction: None,
};
let header = &cell.lines_with_motion(80, true)[0];
let visible: String = header
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(visible.contains("run running"));
assert!(
visible.contains("cargo test --workspace --all-features"),
"header should expose command target: {visible:?}"
);
}
#[test]
fn generic_tool_cell_picks_family_from_tool_name() {
let cell = GenericToolCell {
name: "agent_spawn".to_string(),
status: ToolStatus::Running,
input_summary: Some("foo".to_string()),
output: None,
prompts: None,
spillover_path: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
let header_visible: String = lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(
header_visible.contains('\u{25D0}'),
"Delegate glyph `◐`: {header_visible:?}"
);
assert!(
header_visible.contains(" delegate "),
"verb label `delegate`: {header_visible:?}"
);
}
#[test]
fn generic_tool_cell_renders_rlm_with_rlm_label_not_swarm() {
let cell = GenericToolCell {
name: "rlm".to_string(),
status: ToolStatus::Running,
input_summary: Some("task: compare source trees".to_string()),
output: None,
prompts: None,
spillover_path: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
let header_visible: String = lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(
header_visible.contains(" rlm "),
"RLM card should identify RLM work: {header_visible:?}"
);
assert!(
!header_visible.contains("swarm"),
"RLM card must not use removed swarm wording: {header_visible:?}"
);
}
#[test]
fn render_thinking_uses_dotted_opener_in_header() {
let lines = render_thinking("Step one\nStep two", 80, false, Some(2.0), false, true);
let header = &lines[0];
assert!(
header.spans[0].content.starts_with(REASONING_OPENER),
"header opener: {:?}",
header.spans[0].content
);
}
#[test]
fn render_thinking_body_lines_use_dashed_rail_and_italic() {
let lines = render_thinking(
"concrete reasoning content",
80,
false,
Some(1.0),
false,
true,
);
assert!(lines.len() >= 2, "expected at least one body line");
let body = &lines[1];
assert_eq!(
body.spans[0].content.as_ref(),
REASONING_RAIL,
"body rail must be the dashed `╎ ` glyph"
);
let italic_seen = body
.spans
.iter()
.skip(1)
.any(|span| span.style.add_modifier.contains(Modifier::ITALIC));
assert!(italic_seen, "body content should carry italic modifier");
}
#[test]
fn render_thinking_streaming_appends_cursor_when_motion_allowed() {
let lines = render_thinking(
"ongoing reasoning...",
80,
true,
None,
false,
false,
);
let last = lines.last().expect("body line present");
let last_span = last.spans.last().expect("trailing span present");
assert!(
last_span.content.contains(REASONING_CURSOR),
"expected trailing cursor `▎` on last streaming body line, got {:?}",
last_span.content
);
}
#[test]
fn render_thinking_streaming_omits_cursor_when_low_motion() {
let lines = render_thinking(
"ongoing reasoning...",
80,
true,
None,
false,
true,
);
let last = lines.last().expect("body line present");
let visible: String = last
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(
!visible.contains(REASONING_CURSOR),
"low_motion must suppress the streaming cursor: {visible:?}"
);
}
#[test]
fn streaming_thinking_live_collapses_unless_verbose() {
let cell = HistoryCell::Thinking {
content: "private step one\nprivate step two".to_string(),
streaming: true,
duration_secs: None,
};
let compact = cell.lines_with_options(
80,
TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
},
);
let compact_text = lines_text(&compact);
assert!(compact_text.contains("thinking..."));
assert!(!compact_text.contains("private step one"));
let verbose = cell.lines_with_options(
80,
TranscriptRenderOptions {
verbose: true,
low_motion: true,
..TranscriptRenderOptions::default()
},
);
let verbose_text = lines_text(&verbose);
assert!(verbose_text.contains("private step one"));
assert!(verbose_text.contains("private step two"));
}
#[test]
fn plan_update_cell_renders_with_dark_theme_tokens() {
let theme = Theme::dark();
let cell = PlanUpdateCell {
explanation: None,
steps: vec![
PlanStep {
step: "scan repo".to_string(),
status: "completed".to_string(),
},
PlanStep {
step: "extract theme".to_string(),
status: "in_progress".to_string(),
},
PlanStep {
step: "land tests".to_string(),
status: "pending".to_string(),
},
],
status: ToolStatus::Running,
};
let lines = cell.lines_with_motion(80, true);
let header = &lines[0];
let symbol_span = &header.spans[0];
let glyph_span = &header.spans[1];
let title_span = &header.spans[2];
let state_span = &header.spans[4];
assert_eq!(
symbol_span.style.fg,
Some(theme.tool_running_accent),
"running header symbol should use the dark theme running accent"
);
assert_eq!(
glyph_span.style.fg,
Some(theme.tool_running_accent),
"family glyph rides the same status colour as the spinner"
);
assert_eq!(
title_span.content.as_ref(),
"tool",
"PlanUpdate routes to Generic family → 'tool' verb",
);
assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
assert!(
title_span.style.add_modifier.contains(Modifier::BOLD),
"tool title should be bold"
);
assert_eq!(
state_span.content.as_ref(),
"running",
"running PlanUpdate should label state as 'running'"
);
assert_eq!(state_span.style.fg, Some(theme.tool_running_accent));
let step_line = &lines[1];
let label_span = &step_line.spans[1];
let value_span = &step_line.spans[3];
assert_eq!(
label_span.style.fg,
Some(theme.tool_label_color),
"step label should use theme.tool_label_color"
);
assert_eq!(
value_span.style.fg,
Some(theme.tool_value_color),
"step value should use theme.tool_value_color"
);
let visible = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert_eq!(visible[1].trim_end(), "▏ done: scan repo");
assert_eq!(visible[2].trim_end(), "▏ live: extract theme");
assert_eq!(visible[3].trim_end(), "▏ next: land tests");
}
#[test]
fn exec_cell_failed_status_renders_with_dark_theme_tokens() {
let theme = Theme::dark();
let cell = ExecCell {
command: "false".to_string(),
status: ToolStatus::Failed,
output: Some("boom".to_string()),
started_at: None,
duration_ms: Some(42),
source: ExecSource::Assistant,
interaction: None,
};
let lines = cell.lines_with_motion(80, true);
let header = &lines[0];
let symbol_span = &header.spans[0];
let glyph_span = &header.spans[1];
let title_span = &header.spans[2];
let state_span = &header.spans[4];
assert_eq!(
symbol_span.style.fg,
Some(theme.tool_failed_accent),
"failed exec header symbol should use the dark theme failed accent"
);
assert!(
glyph_span.content.starts_with('\u{25B6}'),
"Run family glyph: {:?}",
glyph_span.content
);
assert_eq!(
title_span.content.as_ref(),
"run",
"ExecCell routes to Run family → 'run' verb",
);
assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
assert!(title_span.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(state_span.content.as_ref(), "issue");
assert_eq!(state_span.style.fg, Some(theme.tool_failed_accent));
}
fn line_text(line: &ratatui::text::Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn lines_text(lines: &[ratatui::text::Line<'static>]) -> String {
lines.iter().map(line_text).collect::<Vec<_>>().join("\n")
}
#[test]
fn long_thinking_display_is_shorter_than_transcript() {
let body = "First paragraph lede.\n\
Second sentence of the first paragraph.\n\n\
Second paragraph: deeper analysis follows.\n\
More detail in paragraph two.\n\n\
Third paragraph: even more reasoning.\n\
With another line.\n\n\
Fourth paragraph: the conclusion.\n\
And one more line for good measure.";
let cell = HistoryCell::Thinking {
content: body.to_string(),
streaming: false,
duration_secs: Some(3.2),
};
let live = cell.lines_with_options(
80,
TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
},
);
let transcript = cell.transcript_lines(80);
assert!(
live.len() < transcript.len(),
"live thinking should compress (live = {} lines, transcript = {} lines)",
live.len(),
transcript.len()
);
let live_text = lines_text(&live);
let transcript_text = lines_text(&transcript);
assert!(
live_text.contains("First paragraph lede"),
"live thinking must keep the lede: {live_text}"
);
assert!(
transcript_text.contains("First paragraph lede"),
"transcript thinking must keep the lede"
);
assert!(
transcript_text.contains("Fourth paragraph"),
"transcript thinking must keep the full body"
);
assert!(
!live_text.contains("Fourth paragraph"),
"live thinking must drop the tail when collapsed"
);
assert!(
live_text.contains("press Ctrl+O for full text"),
"live thinking must offer the pager affordance"
);
assert!(
!transcript_text.contains("press Ctrl+O for full text"),
"transcript thinking must not include the live affordance"
);
}
#[test]
fn short_thinking_display_equals_transcript() {
let cell = HistoryCell::Thinking {
content: "One brief reasoning step.".to_string(),
streaming: false,
duration_secs: Some(0.4),
};
let live = cell.lines_with_options(
80,
TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
},
);
let transcript = cell.transcript_lines(80);
let live_text = lines_text(&live);
let transcript_text = lines_text(&transcript);
assert_eq!(
live_text, transcript_text,
"short thinking must render identically on both surfaces"
);
assert!(
!live_text.contains("press Ctrl+O for full text"),
"short thinking must not show the collapse affordance"
);
}
#[test]
fn tool_exec_live_caps_output_transcript_does_not() {
let total_output_lines = 30usize;
let output = (0..total_output_lines)
.map(|i| format!("output line {i:02}"))
.collect::<Vec<_>>()
.join("\n");
let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "noisy_script.sh".to_string(),
status: ToolStatus::Success,
output: Some(output),
started_at: None,
duration_ms: Some(120),
source: ExecSource::Assistant,
interaction: None,
}));
let live = cell.lines_with_options(
80,
TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
},
);
let transcript = cell.transcript_lines(80);
let live_text = lines_text(&live);
let transcript_text = lines_text(&transcript);
assert!(
live.len() < transcript.len(),
"live exec output must be shorter than transcript exec output (live={}, transcript={})",
live.len(),
transcript.len()
);
assert!(
live_text.contains("Alt+V for details"),
"live exec output must surface the pager affordance: {live_text}"
);
assert!(
!transcript_text.contains("Alt+V for details"),
"transcript exec output must not include the pager affordance"
);
assert!(live_text.contains("output line 00"));
assert!(transcript_text.contains("output line 00"));
assert!(
transcript_text.contains("output line 15"),
"transcript must include the middle of the exec output"
);
let last = format!("output line {:02}", total_output_lines - 1);
assert!(transcript_text.contains(&last));
}
#[test]
fn generic_tool_cell_renders_prompts_as_indexed_rows() {
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "future_fanout_tool".to_string(),
status: ToolStatus::Running,
input_summary: Some("prompts: <3 items>".to_string()),
output: None,
prompts: Some(vec![
"Summarize the README".to_string(),
"List the public types in client.rs".to_string(),
"Diff this commit against main".to_string(),
]),
spillover_path: None,
}));
let text = lines_text(&cell.lines(80));
assert!(text.contains("[0] Summarize the README"));
assert!(text.contains("[1] List the public types in client.rs"));
assert!(text.contains("[2] Diff this commit against main"));
assert!(
!text.contains("args: prompts:"),
"inline `args:` summary must be suppressed when per-prompt rows render"
);
}
#[test]
fn generic_tool_cell_falls_back_to_args_when_prompts_none() {
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "file_search".to_string(),
status: ToolStatus::Running,
input_summary: Some("query: foo".to_string()),
output: None,
prompts: None,
spillover_path: None,
}));
let text = lines_text(&cell.lines(80));
assert!(text.contains("query: foo"));
}
#[test]
fn generic_tool_cell_preserves_multi_line_output_in_transcript() {
let diff_stat = "Cargo.lock | 1 +\n\
crates/cli/Cargo.toml | 1 +\n\
crates/cli/src/main.rs | 47 ++++++\n\
crates/config/src/lib.rs | 27 ++++\n\
crates/tui/src/mcp.rs | 384 +++++";
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Success,
input_summary: Some("command: git diff --stat".to_string()),
output: Some(diff_stat.to_string()),
prompts: None,
spillover_path: None,
}));
let transcript_text = lines_text(&cell.transcript_lines(80));
for needle in [
"Cargo.lock",
"crates/cli/Cargo.toml",
"crates/cli/src/main.rs",
"crates/config/src/lib.rs",
"crates/tui/src/mcp.rs",
] {
assert!(
transcript_text.contains(needle),
"transcript missing '{needle}': {transcript_text}"
);
}
let lines: Vec<&str> = transcript_text.lines().collect();
let cargo_lock_line = lines
.iter()
.find(|l| l.contains("Cargo.lock"))
.expect("Cargo.lock row must exist");
assert!(
!cargo_lock_line.contains("crates/cli/Cargo.toml"),
"Cargo.lock row must not also contain the second file: {cargo_lock_line}"
);
}
#[test]
fn generic_tool_cell_caps_multi_line_output_in_live_with_affordance() {
let total = 30usize;
let output = (0..total)
.map(|i| format!("row {i:02}: payload"))
.collect::<Vec<_>>()
.join("\n");
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Success,
input_summary: Some("command: ls".to_string()),
output: Some(output),
prompts: None,
spillover_path: None,
}));
let live = cell.lines_with_options(80, TranscriptRenderOptions::default());
let transcript = cell.transcript_lines(80);
assert!(
live.len() < transcript.len(),
"live generic-tool output must be shorter than transcript (live={}, transcript={})",
live.len(),
transcript.len(),
);
let live_text = lines_text(&live);
assert!(
live_text.contains("Alt+V for details"),
"live view must show pager affordance: {live_text}"
);
assert!(live_text.contains("row 00"));
let transcript_text = lines_text(&transcript);
assert!(transcript_text.contains("row 29"));
}
#[test]
fn generic_tool_output_live_keeps_tail_and_omitted_count() {
let output = (0..24usize)
.map(|i| format!("line {i:02}"))
.collect::<Vec<_>>()
.join("\n");
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Success,
input_summary: Some("command: noisy".to_string()),
output: Some(output),
prompts: None,
spillover_path: None,
}));
let live_text =
lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default()));
assert!(live_text.contains("line 00"));
assert!(live_text.contains("line 23"));
assert!(live_text.contains("lines omitted; Alt+V for details"));
assert!(
!live_text.contains("line 12"),
"middle plain output should stay omitted in live view: {live_text}"
);
}
#[test]
fn tool_output_live_preserves_error_and_path_lines_from_middle() {
let output = [
"start",
"still starting",
"middle noise 1",
"fatal: failed to read /tmp/deepseek/config.toml",
"middle noise 2",
"see https://example.test/build/log for details",
"middle noise 3",
"almost done",
"final line",
]
.join("\n");
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Failed,
input_summary: Some("command: tool".to_string()),
output: Some(output),
prompts: None,
spillover_path: None,
}));
let live_text =
lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default()));
assert!(live_text.contains("fatal: failed to read /tmp/deepseek/config.toml"));
assert!(live_text.contains("https://example.test/build/log"));
assert!(live_text.contains("final line"));
assert!(live_text.contains("lines omitted; Alt+V for details"));
}
#[test]
fn error_severity_cell_renders_in_red() {
let cell = HistoryCell::Error {
message: "Authentication failed: invalid API key".to_string(),
severity: crate::error_taxonomy::ErrorSeverity::Error,
};
let lines = cell.lines(80);
assert!(
!lines.is_empty(),
"error cell must render at least one line"
);
let head = &lines[0];
let label_span = &head.spans[0];
assert_eq!(label_span.content.as_ref(), "Error");
assert_eq!(label_span.style.fg, Some(palette::STATUS_ERROR));
assert!(label_span.style.add_modifier.contains(Modifier::BOLD));
let body_text = lines
.iter()
.flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
.collect::<String>();
assert!(body_text.contains("Authentication failed"));
let body_span = lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content.contains("Authentication"))
.expect("error body span must exist");
assert_eq!(body_span.style.fg, Some(palette::STATUS_ERROR));
}
#[test]
fn warning_severity_cell_renders_in_amber() {
let cell = HistoryCell::Error {
message: "Stream stalled: no data received for 60s, closing stream".to_string(),
severity: crate::error_taxonomy::ErrorSeverity::Warning,
};
let lines = cell.lines(80);
let label_span = &lines[0].spans[0];
assert_eq!(label_span.content.as_ref(), "Warn");
assert_eq!(label_span.style.fg, Some(palette::STATUS_WARNING));
}
#[test]
fn critical_severity_cell_renders_in_red() {
let cell = HistoryCell::Error {
message: "API key expired".to_string(),
severity: crate::error_taxonomy::ErrorSeverity::Critical,
};
let lines = cell.lines(80);
let label_span = &lines[0].spans[0];
assert_eq!(label_span.content.as_ref(), "Error");
assert_eq!(label_span.style.fg, Some(palette::STATUS_ERROR));
}
#[test]
fn info_severity_cell_renders_in_dim() {
let cell = HistoryCell::Error {
message: "Reconnected".to_string(),
severity: crate::error_taxonomy::ErrorSeverity::Info,
};
let lines = cell.lines(80);
let label_span = &lines[0].spans[0];
assert_eq!(label_span.content.as_ref(), "Info");
assert_eq!(label_span.style.fg, Some(palette::TEXT_DIM));
}
}