#[cfg(test)]
use super::themed_block;
use super::{
bordered_block, hotkey_line_with_theme, pane_text_area, render_scrollbar_with_theme,
themed_block_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;
#[cfg(test)]
const ACTIVITY_ROW_MIN_WIDTH: u16 = 8;
const ACTIVITY_TREE_PREVIEW_MAX_DISPLAY_WIDTH: usize = 80;
#[cfg(test)]
pub(crate) fn activity_tree_row_area(
area: Rect,
state: &MissionControlState,
target_id: &crate::output::ActivityId,
) -> Option<Rect> {
let text_area = activity_tree_text_area(area);
if text_area.width < ACTIVITY_ROW_MIN_WIDTH || text_area.height == 0 {
return None;
}
let visible = state.cached_visible_nodes();
let render_scroll = state.activity_tree_render_scroll(text_area.height);
let row = activity_tree_projection(&visible, render_scroll, usize::from(text_area.height))
.find_map(|(index, (_, id))| {
(id == target_id).then_some(index.saturating_sub(render_scroll))
})?;
Some(Rect::new(
text_area.x,
text_area
.y
.saturating_add(super::usize_to_u16_saturating(row)),
super::scroll_view_content_width(text_area),
1,
))
}
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 = themed_block_with_theme(theme, "Activity Tree", activity_focused);
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 active = state.is_activity_detail_focused();
let block = themed_block_with_theme(theme, "", active).title(hotkey_line_with_theme(
theme,
" Selected Activity Detail ",
theme.title(active),
));
let inner = block.inner(area);
frame.render_widget(block, area);
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,
);
}
#[cfg(test)]
fn rendered_detail_lines(detail_text: &str) -> Vec<Line<'static>> {
crate::tui::state::render_activity_detail_display_lines(detail_text)
.into_iter()
.map(display_line_to_tui_line)
.collect()
}
#[cfg(test)]
pub(in crate::tui::render) fn display_line_to_tui_line(line: DisplayLine) -> Line<'static> {
display_line_to_tui_line_with_theme(MissionControlTheme::default(), line)
}
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 tests {
use super::*;
use crate::{output::ActivityId, rendering::DisplayRole, tui::render::test_support::find_text};
use ratatui::{Terminal, backend::TestBackend};
fn state_with_activity(count: usize) -> MissionControlState {
let mut state = MissionControlState::default();
for index in 0..count {
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: ActivityId::new(format!("tool-{index}")),
parent_id: None,
kind: ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new(format!("tool {index}")),
});
}
state
}
#[test]
fn activity_tree_preview_sanitizes_to_first_nonempty_line() {
let mut state = MissionControlState::default();
let id = ActivityId::new("assistant-preview");
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: ActivityKind::Assistant,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("assistant"),
});
assert!(
activity_tree_preview(state.nodes.get(&id).expect("empty assistant node")).is_none()
);
state.apply_activity_event(crate::output::ActivityEvent::FinalPreview {
id: id.clone(),
preview: "\n \u{1b}[31mfirst\tanswer\u{7}\u{1b}[0m\nsecond answer".to_string(),
metadata: None,
status: Some(crate::output::ActivityStatus::Success),
});
let preview = activity_tree_preview(state.nodes.get(&id).expect("assistant node"))
.expect("assistant preview");
assert_eq!(preview, "first answer");
assert!(!preview.contains(['\u{1b}', '\n', '\r', '\t']));
}
fn rendered_tree_buffer(state: &MissionControlState) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(80, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_tree(frame, Rect::new(0, 0, 80, 8), state))
.unwrap();
terminal.backend().buffer().clone()
}
fn rendered_activity_tree_thumb_rows(state: &MissionControlState, area: Rect) -> Vec<u16> {
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| draw_tree(frame, area, state))
.unwrap();
let buffer = terminal.backend().buffer();
let text_area = activity_tree_text_area(area);
let scrollbar_x = text_area.x + text_area.width - 1;
let thumb_fg = state.theme.border_active();
let thumb_bg = state.theme.surface_background();
(text_area.y..text_area.y + text_area.height)
.filter(|&y| {
buffer[(scrollbar_x, y)].fg == thumb_fg && buffer[(scrollbar_x, y)].bg == thumb_bg
})
.collect()
}
fn rendered_buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
(buffer.area.y..buffer.area.y + buffer.area.height)
.map(|y| {
(buffer.area.x..buffer.area.x + buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn activity_tree_header_omits_panel_toggle() {
let state = state_with_activity(2);
let buffer = rendered_tree_buffer(&state);
let title = find_text(&buffer, "Activity Tree").expect("activity title");
assert_eq!(title.1, 0);
assert!(find_text(&buffer, "[Alt-A]").is_none());
assert!(find_text(&buffer, "2 Activities").is_none());
}
#[test]
fn activity_tree_renders_scrolled_viewport_without_mutating_scroll_state() {
let mut state = state_with_activity(6);
state.selected = 3;
state.set_scroll_offset(&state.scroll_views.activity_tree, 2);
state.focus_activity_tree();
let before = *state.scroll_views.activity_tree.borrow();
let area = Rect::new(0, 0, 80, 6);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| draw_tree(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
assert!(find_text(buffer, "tool 1").is_none());
assert_eq!(find_text(buffer, "tool 2").expect("first visible row").1, 1);
let selected = find_text(buffer, "tool 3").expect("selected visible row");
assert_eq!(selected.1, 2);
assert_eq!(
buffer[selected].bg,
state.theme.label(true, true).bg.unwrap()
);
assert_eq!(find_text(buffer, "tool 5").expect("last visible row").1, 4);
assert_eq!(*state.scroll_views.activity_tree.borrow(), before);
}
#[test]
fn activity_tree_virtualization_bounds_large_projection_and_preserves_selection_index() {
let mut state = state_with_activity(512);
let area = Rect::new(0, 0, 80, 12);
let text_area = activity_tree_text_area(area);
let viewport_rows = usize::from(text_area.height);
let selected = 389;
let selected_id = ActivityId::new(format!("tool-{selected}"));
state.selected = selected;
state.set_scroll_offset(
&state.scroll_views.activity_tree,
selected.saturating_sub(viewport_rows / 2),
);
state.focus_activity_tree();
let (render_scroll, selected_relative_row) = {
let visible = state.cached_visible_nodes();
let projection = activity_tree_projection(
&visible,
state.activity_tree_render_scroll(text_area.height),
viewport_rows,
)
.collect::<Vec<_>>();
assert!(visible.len() > viewport_rows);
assert!(projection.len() <= viewport_rows);
assert_eq!(projection.len(), viewport_rows);
let selected_relative_row = projection
.iter()
.position(|(index, (_, id))| *index == selected && id == &selected_id)
.expect("selected activity should be projected");
assert_eq!(projection[selected_relative_row].0, selected);
(
state.activity_tree_render_scroll(text_area.height),
selected_relative_row,
)
};
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| draw_tree(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let selected_cell =
find_text(buffer, &format!("tool {selected}")).expect("selected activity row");
assert_eq!(
selected_cell.1,
text_area
.y
.saturating_add(super::super::usize_to_u16_saturating(selected_relative_row))
);
assert!(find_text(buffer, &format!("tool {}", render_scroll.saturating_sub(1))).is_none());
assert!(find_text(buffer, &format!("tool {}", render_scroll + viewport_rows)).is_none());
}
#[test]
fn activity_tree_scrollbar_thumb_tracks_start_middle_and_end() {
let state = state_with_activity(512);
let area = Rect::new(0, 0, 80, 24);
let text_area = activity_tree_text_area(area);
let overflow = state.activity_tree_scroll_overflow(text_area.height);
assert!(overflow > 2);
let mut thumb_rows = Vec::new();
for position in [0, overflow / 2, overflow] {
state.set_scroll_offset(&state.scroll_views.activity_tree, position);
thumb_rows.push(rendered_activity_tree_thumb_rows(&state, area));
}
let track_start = text_area.y + 1;
let track_end = text_area.y + text_area.height - 2;
for rows in &thumb_rows {
assert!(!rows.is_empty());
assert!(rows.windows(2).all(|pair| pair[1] == pair[0] + 1));
assert!(
rows.iter()
.all(|&row| (track_start..=track_end).contains(&row))
);
}
assert_eq!(thumb_rows[0].first().copied(), Some(track_start));
assert_eq!(thumb_rows[2].last().copied(), Some(track_end));
assert!(thumb_rows[0][0] < thumb_rows[1][0]);
assert!(thumb_rows[1][0] < thumb_rows[2][0]);
}
#[test]
fn selected_activity_detail_title_has_no_focus_shortcut_label() {
let state = state_with_activity(1);
let backend = TestBackend::new(80, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_detail(frame, Rect::new(0, 0, 80, 8), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let title = find_text(buffer, "Selected Activity Detail").expect("detail title");
assert_eq!(title.1, 0);
assert!(find_text(buffer, "Alt-2").is_none());
assert!(find_text(buffer, "Alt-1").is_none());
}
#[test]
fn selected_tool_detail_renders_structured_sanitized_content() {
let mut state = MissionControlState::default();
let call = crate::providers::ToolCall {
id: "rendered-detail".to_string(),
name: "mcp__demo__lookup".to_string(),
arguments: serde_json::json!({
"query": "release status",
"credential": "must-not-render"
}),
};
let result = crate::tools::ToolResult {
tool_name: call.name.clone(),
success: true,
content: "Complete locally available result: release is ready".to_string(),
metadata: serde_json::json!({"items": 3, "truncated": false}),
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 backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_detail(frame, Rect::new(0, 0, 100, 24), &state))
.unwrap();
let rendered = rendered_buffer_text(terminal.backend().buffer());
assert!(rendered.contains("Status: SUCCESS"), "{rendered}");
assert!(
rendered.contains("Tool • mcp__demo__lookup • mcp__demo__lookup"),
"{rendered}"
);
assert!(rendered.contains("▸ Metadata / Params"), "{rendered}");
assert!(
!rendered.contains("\"query\": \"release status\""),
"{rendered}"
);
assert!(
!rendered.contains("\"credential\": \"<redacted>\""),
"{rendered}"
);
assert!(!rendered.contains("\"truncated\": false"), "{rendered}");
assert!(rendered.contains("Sanitized Output:"), "{rendered}");
assert!(
rendered.contains("Complete locally available result: release is ready"),
"{rendered}"
);
assert!(!rendered.contains("must-not-render"), "{rendered}");
println!("{rendered}");
assert!(state.toggle_detail_metadata(20, 97));
terminal
.draw(|frame| draw_detail(frame, Rect::new(0, 0, 100, 24), &state))
.unwrap();
let rendered = rendered_buffer_text(terminal.backend().buffer());
assert!(rendered.contains("▾ Metadata / Params"), "{rendered}");
assert!(
rendered.contains("\"query\": \"release status\""),
"{rendered}"
);
assert!(
rendered.contains("\"credential\": \"<redacted>\""),
"{rendered}"
);
assert!(rendered.contains("\"truncated\": false"), "{rendered}");
assert!(!rendered.contains("must-not-render"), "{rendered}");
}
#[test]
fn selected_activity_detail_keeps_summary_visible_while_body_scrolls() {
let mut state = MissionControlState::default();
let id = ActivityId::new("tool-scroll");
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("long preview"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id,
preview: (0..20)
.map(|index| format!("body row {index}"))
.collect::<Vec<_>>()
.join("\n"),
});
state.set_scroll_offset(&state.scroll_views.detail, 8);
let backend = TestBackend::new(80, 12);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_detail(frame, Rect::new(0, 0, 80, 12), &state))
.unwrap();
let buffer = terminal.backend().buffer();
assert!(find_text(buffer, "status: ● RUNNING").is_some());
assert!(find_text(buffer, "label: long preview").is_some());
assert!(find_text(buffer, "body row 0").is_none());
assert!(find_text(buffer, "body row 8").is_some());
}
#[test]
fn round_nine_detail_scrollbar_stays_in_body_pane() {
let mut state = MissionControlState::default();
let id = ActivityId::new("tool-scrollbar");
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("scrollbar preview"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id,
preview: (0..20)
.map(|index| format!("body row {index}"))
.collect::<Vec<_>>()
.join("\n"),
});
let area = Rect::new(0, 0, 80, 12);
let text_area = pane_text_area(themed_block("", false).inner(area));
let wrap_width = super::super::scroll_view_content_width(text_area).max(1);
let summary_height = text_area
.height
.saturating_sub(state.detail_body_viewport_rows(text_area.height, wrap_width));
let gutter_x = text_area.x + text_area.width - 1;
let backend = TestBackend::new(area.width, area.height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_detail(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let scrollbar_symbols = ["│", "┃", "▲", "▼"];
assert!(
(text_area.y..text_area.y + summary_height)
.all(|y| !scrollbar_symbols.contains(&buffer[(gutter_x, y)].symbol()))
);
assert!(
(text_area.y + summary_height..text_area.y + text_area.height)
.any(|y| scrollbar_symbols.contains(&buffer[(gutter_x, y)].symbol()))
);
}
#[test]
fn activity_detail_scrollbar_reaches_bottom_with_final_content_row() {
let mut state = state_with_activity(1);
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: ActivityId::new("tool-0"),
preview: (0..40)
.map(|index| format!("body row {index}"))
.collect::<Vec<_>>()
.join("\n"),
});
let area = Rect::new(0, 0, 80, 20);
let text_area = pane_text_area(themed_block("", false).inner(area));
let wrap_width = super::super::scroll_view_content_width(text_area);
let body_height = state.detail_body_viewport_rows(text_area.height, wrap_width);
let max_offset = state
.detail_body_visual_rows(wrap_width)
.saturating_sub(usize::from(body_height));
assert!(max_offset > 0);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
for offset in [0, max_offset, max_offset + 100] {
state.set_scroll_offset(&state.scroll_views.detail, offset);
terminal
.draw(|frame| draw_detail(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let gutter_x = text_area.right() - 1;
let track_top = text_area.bottom() - body_height + 1;
let track_bottom = text_area.bottom() - 2;
let thumb_rows = (track_top..=track_bottom)
.filter(|&y| buffer[(gutter_x, y)].fg == state.theme.border_active())
.collect::<Vec<_>>();
assert!(!thumb_rows.is_empty());
if offset == 0 {
assert_eq!(thumb_rows.first(), Some(&track_top));
assert!(find_text(buffer, "body row 39").is_none());
} else {
assert_eq!(thumb_rows.last(), Some(&track_bottom));
assert_eq!(
find_text(buffer, "body row 39"),
Some((text_area.x, text_area.bottom() - 1))
);
}
}
}
#[test]
fn activity_detail_summary_wraps_before_scrollbar_gutter() {
let area = Rect::new(0, 0, 40, 20);
let text_area = pane_text_area(themed_block("", false).inner(area));
let wrap_width = super::super::scroll_view_content_width(text_area);
let mut state = MissionControlState::default();
let id = ActivityId::new("wrapped-summary");
let label = format!("{}Z", "x".repeat(usize::from(wrap_width) - "label: ".len()));
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new(label.clone()),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id,
preview: "body begins here".to_string(),
});
let summary_height =
text_area.height - state.detail_body_viewport_rows(text_area.height, wrap_width);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| draw_detail(frame, area, &state))
.unwrap();
let buffer = terminal.backend().buffer();
let (_, label_y) = find_text(buffer, "label: ").expect("summary label");
assert_eq!(find_text(buffer, &label), Some((text_area.x, label_y + 1)));
assert!(
(text_area.y..text_area.y + summary_height)
.all(|y| buffer[(text_area.right() - 1, y)].symbol() == " ")
);
assert_eq!(
find_text(buffer, "Sanitized output preview"),
Some((text_area.x, text_area.y + summary_height))
);
assert_eq!(
find_text(buffer, "body begins here"),
Some((text_area.x, text_area.y + summary_height + 1))
);
}
#[test]
fn regression_preserves_detail_content_after_width_expands() {
let mut state = MissionControlState::default();
let id = ActivityId::new("tool-resize");
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("resize preview"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id,
preview: "retained body content ".repeat(12),
});
state.set_scroll_offset(&state.scroll_views.detail, 20);
let backend = TestBackend::new(120, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_detail(frame, Rect::new(0, 0, 120, 20), &state))
.unwrap();
assert!(find_text(terminal.backend().buffer(), "retained body content").is_some());
}
#[test]
fn selected_activity_detail_tiny_pane_does_not_panic() {
let state = state_with_activity(1);
let backend = TestBackend::new(1, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw_detail(frame, Rect::new(0, 0, 1, 1), &state))
.unwrap();
}
#[test]
fn activity_tree_row_area_maps_visible_row() {
let state = state_with_activity(2);
let area = Rect::new(0, 0, 20, 6);
let row = activity_tree_row_area(area, &state, &ActivityId::new("tool-1")).unwrap();
assert_eq!(row, Rect::new(2, 2, 15, 1));
}
#[test]
fn activity_tree_row_area_accounts_for_render_scroll_and_skips_hidden_rows() {
let state = state_with_activity(6);
let area = Rect::new(0, 0, 20, 6);
let visible = activity_tree_row_area(area, &state, &ActivityId::new("tool-5")).unwrap();
let hidden = activity_tree_row_area(area, &state, &ActivityId::new("tool-0"));
assert_eq!(visible, Rect::new(2, 4, 15, 1));
assert_eq!(hidden, None);
}
#[test]
fn activity_tree_row_area_skips_tiny_targets() {
let state = state_with_activity(1);
assert_eq!(
activity_tree_row_area(Rect::new(0, 0, 8, 2), &state, &ActivityId::new("tool-0")),
None
);
}
#[test]
fn mixed_activity_detail_with_embedded_diff_uses_diff_styles() {
let detail = "Status: finished\nOutput preview:\nDIFF:\ndiff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n context";
let lines = rendered_detail_lines(detail);
assert_eq!(lines[0].spans[0].content.as_ref(), "Status: finished");
let file_header = MissionControlTheme::default().display_role(DisplayRole::DiffFileHeader);
let hunk_header = MissionControlTheme::default().display_role(DisplayRole::DiffHunkHeader);
let removed = MissionControlTheme::default().display_role(DisplayRole::DiffRemoved);
let inserted = MissionControlTheme::default().display_role(DisplayRole::DiffInserted);
assert!(lines.iter().any(|line| line.spans[0].content.as_ref()
== "diff --git a/file.txt b/file.txt"
&& line.spans[0].style == file_header));
assert!(
lines
.iter()
.any(|line| line.spans[0].content.as_ref() == "@@ -1,1 +1,1 @@"
&& line.spans[0].style == hunk_header)
);
assert!(
lines
.iter()
.any(|line| line.spans[0].content.as_ref() == "-old"
&& line.spans[0].style == removed)
);
assert!(lines.iter().any(
|line| line.spans[0].content.as_ref() == "+new" && line.spans[0].style == inserted
));
}
#[test]
fn optimization_harness_renders_large_activity_detail_without_dropping_lines() {
let line_count = 512;
let detail = (0..line_count)
.map(|index| format!("detail line {index}"))
.collect::<Vec<_>>()
.join("\n");
let lines = rendered_detail_lines(&detail);
assert_eq!(lines.len(), line_count);
assert_eq!(lines[0].spans[0].content.as_ref(), "detail line 0");
assert_eq!(
lines[line_count - 1].spans[0].content.as_ref(),
format!("detail line {}", line_count - 1)
);
}
#[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 detail = (0..line_count)
.map(|index| format!("detail line {index}"))
.collect::<Vec<_>>()
.join("\n");
let started = std::time::Instant::now();
let lines = rendered_detail_lines(&detail);
let elapsed = started.elapsed();
println!(
"optimization_harness activity_detail lines={} rendered={} elapsed={elapsed:?}",
line_count,
lines.len()
);
assert_eq!(lines.len(), line_count);
}
}