use super::{bordered_block, pane_text_area, render_scrollbar_with_theme};
use crate::tui::theme::MissionControlTheme;
use crate::{
output::{ActivityKind, ActivityStatus, redact_sensitive_text},
rendering::DisplayLine,
tui::{
PADDED_INLINE_SEPARATOR,
activity::{
compact_activity_tree_metadata, disclosure_marker, sanitize_read_activity_label,
},
normalize_inline_separators,
state::{MissionControlState, activity_status_mark},
},
};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
text::{Line, Span},
widgets::{Block, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
const ACTIVITY_TREE_PREVIEW_MAX_DISPLAY_WIDTH: usize = 80;
fn activity_tree_projection<'a>(
visible: &'a [(usize, crate::output::ActivityId)],
render_scroll: usize,
viewport_rows: usize,
) -> impl Iterator<Item = (usize, &'a (usize, crate::output::ActivityId))> + 'a {
let start = render_scroll.min(visible.len());
visible[start..]
.iter()
.take(viewport_rows)
.enumerate()
.map(move |(relative_index, row)| (start + relative_index, row))
}
fn activity_tree_text_area(area: Rect) -> Rect {
pane_text_area(bordered_block().inner(area))
}
pub(super) fn draw_tree(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
let activity_focused = state.is_activity_tree_focused();
let visible = state.cached_visible_nodes();
let block = super::modals::header_modal_block(
state,
crate::tui::state::ModalKind::ActivityTree,
"Activity Tree",
"[Enter] detail · [←/→] collapse/expand",
);
frame.render_widget(ratatui::widgets::Clear, area);
frame.render_widget(block, area);
let text_area = activity_tree_text_area(area);
let visible_rows = text_area.height;
let visible_row_count = usize::from(visible_rows);
let render_scroll = state.activity_tree_render_scroll(visible_rows);
let content_width = super::scroll_view_content_width(text_area);
let row_width = usize::from(content_width);
let start = render_scroll.min(visible.len());
let projected_row_count = visible.len().saturating_sub(start).min(visible_row_count);
let mut lines = Vec::with_capacity(projected_row_count);
for (index, (depth, id)) in activity_tree_projection(&visible, render_scroll, visible_row_count)
{
if let Some(node) = state.nodes.get(id) {
let marker = if !state.has_activity_children(id) {
"•"
} else if state.expanded.contains(id) {
disclosure_marker(true)
} else {
disclosure_marker(false)
};
let prefix = " ".repeat(*depth);
let selected = index == state.selected;
let base_style = theme.label(selected, activity_focused);
let hash_edit_summary = node.hash_edit_tree_summary.as_ref();
let label = activity_tree_label(node, hash_edit_summary);
let metadata = hash_edit_summary
.map(|summary| summary.metadata())
.unwrap_or_else(|| compact_activity_tree_metadata(node));
let timestamp = format!("{} ", node.started_at.format("%H:%M:%S"));
let separator_width = UnicodeWidthStr::width(PADDED_INLINE_SEPARATOR);
let fixed_width = UnicodeWidthStr::width(timestamp.as_str())
.saturating_add(UnicodeWidthStr::width(prefix.as_str()))
.saturating_add(UnicodeWidthStr::width(marker))
.saturating_add(UnicodeWidthStr::width(" "))
.saturating_add(UnicodeWidthStr::width(activity_status_mark(node.status)))
.saturating_add(UnicodeWidthStr::width(" "))
.saturating_add(UnicodeWidthStr::width(label.as_str()));
let metadata = if metadata.is_empty() {
metadata
} else {
let metadata_budget = row_width
.saturating_sub(fixed_width)
.saturating_sub(separator_width);
crate::tui::activity::truncate_display_width(&metadata, metadata_budget)
};
let metadata_width = if metadata.is_empty() {
0
} else {
separator_width.saturating_add(UnicodeWidthStr::width(metadata.as_str()))
};
let preview = activity_tree_preview(node).and_then(|preview| {
let budget = row_width
.saturating_sub(fixed_width)
.saturating_sub(metadata_width)
.saturating_sub(separator_width);
(budget > 0).then(|| {
crate::tui::activity::truncate_display_width(
&preview,
budget.min(ACTIVITY_TREE_PREVIEW_MAX_DISPLAY_WIDTH),
)
})
});
let mut spans = vec![
Span::styled(timestamp, theme.dim(activity_focused)),
Span::styled(prefix, base_style),
Span::styled(
marker,
theme
.dim(activity_focused)
.bg(base_style.bg.unwrap_or(theme.surface_background())),
),
Span::styled(" ", base_style),
Span::styled(
activity_status_mark(node.status),
theme.status(node.status, selected, activity_focused),
),
Span::styled(" ", base_style),
Span::styled(label, base_style),
];
if let Some(preview) = preview {
spans.push(Span::styled(
PADDED_INLINE_SEPARATOR,
theme.dim(activity_focused),
));
spans.push(Span::styled(preview, theme.muted(activity_focused)));
}
if !metadata.is_empty() {
spans.push(Span::styled(
PADDED_INLINE_SEPARATOR,
theme.dim(activity_focused),
));
spans.push(Span::styled(metadata, theme.muted(activity_focused)));
}
lines.push(Line::from(spans));
}
}
if lines.is_empty() && visible.is_empty() {
lines.push(Line::from(Span::styled(
"No activity yet. Type a prompt below.",
theme.dim(activity_focused),
)));
}
if content_width > 0 {
frame.render_widget(
Paragraph::new(lines).style(theme.pane()),
Rect::new(text_area.x, text_area.y, content_width, text_area.height),
);
}
if content_width < text_area.width {
frame.render_widget(
Block::default().style(theme.surface_background()),
Rect::new(
text_area.x.saturating_add(content_width),
text_area.y,
text_area.width.saturating_sub(content_width),
text_area.height,
),
);
}
render_scrollbar_with_theme(
frame,
text_area,
visible
.len()
.saturating_sub(visible_row_count)
.saturating_add(1),
render_scroll,
visible_row_count,
theme,
);
}
pub(super) fn draw_detail(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
let inner = crate::tui::modal_container::draw_modal_container(
frame,
area,
" Selected Activity Detail ",
" Up/Down · PageUp/PageDown scroll ",
theme,
Some("[Esc] close"),
);
let text_area = pane_text_area(inner);
if text_area.height == 0 || text_area.width == 0 {
return;
}
let wrap_width = super::scroll_view_content_width(text_area).max(1);
let summary_display_lines = state.selected_activity_summary_lines();
let summary_height = text_area
.height
.saturating_sub(state.detail_body_viewport_rows(text_area.height, wrap_width));
let summary_lines = summary_display_lines
.into_iter()
.map(|line| display_line_to_tui_line_with_theme(theme, line))
.collect::<Vec<_>>();
let body_height = text_area.height.saturating_sub(summary_height);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(summary_height), Constraint::Min(0)])
.split(text_area);
frame.render_widget(
Paragraph::new(summary_lines)
.style(theme.pane())
.wrap(Wrap { trim: false }),
Rect::new(chunks[0].x, chunks[0].y, wrap_width, chunks[0].height),
);
if body_height == 0 {
return;
}
let content_height = state.detail_body_visual_rows(wrap_width);
let body_offset = state
.detail_offset()
.min(content_height.saturating_sub(usize::from(body_height)));
let body_lines = state
.visible_activity_body_lines(body_offset, usize::from(body_height), wrap_width)
.into_iter()
.map(|line| {
if line.plain_text() == "─" {
DisplayLine::plain("─".repeat(usize::from(wrap_width)))
} else {
line
}
})
.map(|line| display_line_to_tui_line_with_theme(theme, line))
.collect::<Vec<_>>();
frame.render_widget(
Paragraph::new(body_lines)
.style(theme.pane())
.wrap(Wrap { trim: false }),
Rect::new(chunks[1].x, chunks[1].y, wrap_width, chunks[1].height),
);
super::render_scrollbar_with_theme(
frame,
chunks[1],
content_height
.saturating_sub(usize::from(body_height))
.saturating_add(1),
body_offset,
usize::from(body_height),
theme,
);
}
pub(in crate::tui::render) fn display_line_to_tui_line_with_theme(
theme: MissionControlTheme,
line: DisplayLine,
) -> Line<'static> {
Line::from(
line.spans
.into_iter()
.map(|span| Span::styled(span.text, theme.display_role(span.role)))
.collect::<Vec<_>>(),
)
}
fn activity_tree_preview(node: &crate::tui::activity::ActivityNode) -> Option<String> {
if node.parent_id.is_some()
|| node.kind != ActivityKind::Assistant
|| node.status != ActivityStatus::Success
{
return None;
}
let label = normalize_inline_separators(&redact_sensitive_text(&node.metadata.label));
if label.starts_with("reasoning") {
return None;
}
let sanitized = crate::tui::transcript::sanitize_preview(&node.preview);
let line = sanitized
.lines()
.map(str::trim)
.find(|line| !line.is_empty())?;
let line = line.split_whitespace().collect::<Vec<_>>().join(" ");
let line = normalize_inline_separators(&line);
if line.is_empty() {
return None;
}
Some(line)
}
fn activity_tree_label(
node: &crate::tui::activity::ActivityNode,
hash_edit_summary: Option<&crate::tui::activity::HashEditActivityTreeSummary>,
) -> String {
if node.kind == ActivityKind::Tool
&& let Some(summary) = hash_edit_summary
{
return summary.label();
}
let is_read = node.kind == ActivityKind::Tool
&& (node
.tool_detail
.as_ref()
.is_some_and(|detail| detail.tool_name.as_ref() == "read")
|| node.metadata.label.starts_with("read "));
let metadata = if is_read {
sanitize_read_activity_label(&node.metadata.label)
} else {
crate::output::sanitize_display_text(&node.metadata.label)
};
let metadata = crate::tui::normalize_inline_separators(&metadata);
if node.kind == ActivityKind::Assistant && metadata.starts_with("reasoning") {
return metadata;
}
if node.kind == ActivityKind::Tool
&& !metadata.is_empty()
&& !metadata.eq_ignore_ascii_case("tool")
{
return metadata;
}
let kind = match node.kind {
ActivityKind::Tool => "tool",
ActivityKind::Assistant | ActivityKind::SubagentTask => "agent",
ActivityKind::Hook => "hook",
ActivityKind::ProviderContextInjection => "provider context injection",
ActivityKind::SubagentBatch => "subagents",
ActivityKind::Diagnostic => "diagnostic",
ActivityKind::Compaction => "compaction",
};
if metadata.is_empty()
|| metadata.eq_ignore_ascii_case(kind)
|| (kind == "agent" && metadata.eq_ignore_ascii_case("assistant"))
{
kind.to_string()
} else {
format!("{kind}{PADDED_INLINE_SEPARATOR}{metadata}")
}
}
#[cfg(test)]
mod profiling {
use super::*;
fn tool_output_detail_body(content: String) -> Vec<crate::rendering::DisplayLine> {
let mut state = MissionControlState::default();
let call = crate::providers::ToolCall {
id: "large-output".to_string(),
name: "bash".to_string(),
arguments: serde_json::json!({"command": "seq"}),
};
let result = crate::tools::ToolResult {
tool_name: call.name.clone(),
success: true,
content,
metadata: serde_json::json!({}),
display: crate::tools::ToolResultDisplay::default(),
};
let summary = crate::output::tool_display_summary(&call, &result);
state.apply_output_event(&crate::output::OutputEvent::ToolResult {
changed_paths: Vec::new(),
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
});
let node = state
.nodes
.values()
.find(|node| node.tool_detail.is_some())
.expect("tool node");
crate::tui::activity::activity_detail_lines(node, false).body
}
fn large_tool_output(line_count: usize) -> String {
(0..line_count)
.map(|index| format!("detail line {index}"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
#[ignore = "diagnostic-only optimization measurement; run explicitly with --ignored --nocapture"]
fn optimization_harness_measures_large_activity_detail_render() {
let line_count = 5_000;
let output = large_tool_output(line_count);
let started = std::time::Instant::now();
let body = tool_output_detail_body(output);
let elapsed = started.elapsed();
println!(
"optimization_harness activity_detail lines={} rendered={} elapsed={elapsed:?}",
line_count,
body.len()
);
assert!(body.len() >= line_count);
}
}