use super::{
bordered_block, hotkey_line, pane_text_area, render_scroll_view_persistent, themed_block,
};
use crate::{
output::{ActivityKind, redact_sensitive_text},
rendering::DisplayLine,
tui::{
PADDED_INLINE_SEPARATOR, normalize_inline_separators,
state::{MissionControlState, activity_status_mark, compact_activity_metadata},
theme::MATRIX_GREEN,
},
};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
text::{Line, Span},
widgets::{Paragraph, Wrap},
};
#[cfg(test)]
const ACTIVITY_ROW_MIN_WIDTH: u16 = 8;
#[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 = visible
.iter()
.enumerate()
.skip(render_scroll)
.take(usize::from(text_area.height))
.find_map(|(index, (_, id))| (id == target_id).then_some(index - 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_text_area(area: Rect) -> Rect {
pane_text_area(themed_block("", false).inner(area))
}
pub(super) fn draw_tree(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let activity_focused = state.is_activity_tree_focused();
let visible = state.cached_visible_nodes();
let block = bordered_block()
.border_style(MATRIX_GREEN.border(activity_focused))
.style(MATRIX_GREEN.app())
.title(hotkey_line(
" Activity Tree [Alt-1] ",
MATRIX_GREEN.title(activity_focused),
));
frame.render_widget(block, area);
let text_area = activity_tree_text_area(area);
let visible_rows = text_area.height;
let render_scroll = state.activity_tree_render_scroll(visible_rows);
let mut lines = Vec::new();
for (index, (depth, id)) in visible.iter().enumerate() {
if let Some(node) = state.nodes.get(id) {
let marker = if !state.has_activity_children(id) {
"•"
} else if state.expanded.contains(id) {
"▾"
} else {
"▸"
};
let prefix = " ".repeat(*depth);
let selected = index == state.selected;
let base_style = MATRIX_GREEN.label(selected, activity_focused);
let metadata = compact_activity_metadata(&node.metadata.fields, 2);
let mut spans = vec![
Span::styled(
format!("{} ", node.started_at.format("%H:%M:%S")),
MATRIX_GREEN.dim(activity_focused),
),
Span::styled(prefix, base_style),
Span::styled(
marker,
MATRIX_GREEN
.dim(activity_focused)
.bg(base_style.bg.unwrap_or(MATRIX_GREEN.background)),
),
Span::styled(" ", base_style),
Span::styled(
activity_status_mark(node.status),
MATRIX_GREEN.status(node.status, selected, activity_focused),
),
Span::styled(" ", base_style),
Span::styled(
kind_mark(&node.kind),
MATRIX_GREEN.kind(&node.kind, selected, activity_focused),
),
Span::styled(" ", base_style),
Span::styled(
normalize_inline_separators(&redact_sensitive_text(&node.metadata.label)),
base_style,
),
];
if !metadata.is_empty() {
spans.push(Span::styled(
PADDED_INLINE_SEPARATOR,
MATRIX_GREEN.dim(activity_focused),
));
spans.push(Span::styled(metadata, MATRIX_GREEN.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.",
MATRIX_GREEN.dim(activity_focused),
)));
}
render_scroll_view_persistent(
frame,
text_area,
crate::tui::transcript::usize_to_u16_saturating(render_scroll),
lines.len().try_into().unwrap_or(u16::MAX),
Paragraph::new(lines).style(MATRIX_GREEN.pane()),
&state.scroll_views.activity_tree,
);
}
pub(super) fn draw_detail(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let active = state.is_activity_detail_focused();
let block = themed_block("", active).title(hotkey_line(
" Selected Activity Detail [Alt-2] ",
MATRIX_GREEN.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(display_line_to_tui_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(MATRIX_GREEN.pane())
.wrap(Wrap { trim: false }),
chunks[0],
);
if body_height == 0 {
return;
}
let body_lines = state
.selected_activity_body_lines()
.into_iter()
.map(display_line_to_tui_line)
.collect::<Vec<_>>();
let body_offset = state.scroll_offset(&state.scroll_views.detail);
let content_height = state.detail_body_visual_rows(wrap_width).max(1);
render_scroll_view_persistent(
frame,
chunks[1],
super::usize_to_u16_saturating(body_offset),
super::usize_to_u16_saturating(content_height),
Paragraph::new(body_lines)
.style(MATRIX_GREEN.pane())
.wrap(Wrap { trim: false }),
&state.scroll_views.detail,
);
}
#[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()
}
pub(in crate::tui::render) fn display_line_to_tui_line(line: DisplayLine) -> Line<'static> {
Line::from(
line.spans
.into_iter()
.map(|span| Span::styled(span.text, MATRIX_GREEN.display_role(span.role)))
.collect::<Vec<_>>(),
)
}
fn kind_mark(kind: &ActivityKind) -> &'static str {
match kind {
ActivityKind::Tool => "⚙",
ActivityKind::Hook => "↪",
ActivityKind::ProviderContextInjection => "⇢",
ActivityKind::SubagentBatch => "▣",
ActivityKind::SubagentTask => "◇",
ActivityKind::Assistant => "✦",
ActivityKind::Diagnostic => "!",
ActivityKind::Compaction => "⤓",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{output::ActivityId, rendering::DisplayRole, tui::render::test_support::find_text};
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
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
}
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()
}
#[test]
fn activity_tree_header_omits_border_count_and_dims_hotkey() {
let state = state_with_activity(2);
let buffer = rendered_tree_buffer(&state);
let title = find_text(&buffer, "Activity Tree [Alt-1]").expect("left title");
let hotkey = find_text(&buffer, "[Alt-1]").expect("hotkey");
assert_eq!(title.1, 0);
assert!(find_text(&buffer, "2 Activities").is_none());
assert_eq!(buffer[hotkey].fg, MATRIX_GREEN.green_dim);
assert!(!buffer[hotkey].modifier.contains(Modifier::BOLD));
}
#[test]
fn selected_activity_detail_title_uses_dim_bracketed_hotkey() {
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 [Alt-2]").expect("detail title");
let hotkey = find_text(buffer, "[Alt-2]").expect("detail hotkey");
assert_eq!(title.1, 0);
assert_eq!(buffer[hotkey].fg, MATRIX_GREEN.green_dim);
assert!(!buffer[hotkey].modifier.contains(Modifier::BOLD));
}
#[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 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 = MATRIX_GREEN.display_role(DisplayRole::DiffFileHeader);
let hunk_header = MATRIX_GREEN.display_role(DisplayRole::DiffHunkHeader);
let removed = MATRIX_GREEN.display_role(DisplayRole::DiffRemoved);
let inserted = MATRIX_GREEN.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);
}
}