use super::*;
use unicode_segmentation::UnicodeSegmentation;
impl MissionControlState {
pub(crate) fn focus_activity_layout(&mut self) {
self.layout_mode = TuiLayoutMode::ActivityFocused;
}
pub(crate) fn focus_transcript_layout(&mut self) {
self.layout_mode = TuiLayoutMode::TranscriptFocused;
}
pub(crate) fn is_prompt_focused(&self) -> bool {
self.focus_pane == TuiFocusPane::Prompt
}
pub(crate) fn is_transcript_focused(&self) -> bool {
self.focus_pane == TuiFocusPane::Transcript
}
pub(crate) fn is_activity_tree_focused(&self) -> bool {
self.focus_pane == TuiFocusPane::ActivityTree
}
pub(crate) fn is_activity_detail_focused(&self) -> bool {
self.focus_pane == TuiFocusPane::ActivityDetail
}
pub(crate) fn focus_prompt(&mut self) {
self.focus_pane = TuiFocusPane::Prompt;
self.prompt_cursor_visible = true;
}
pub(crate) fn focus_activity_tree(&mut self) {
self.focus_pane = TuiFocusPane::ActivityTree;
self.prompt_cursor_visible = false;
}
pub(crate) fn focus_activity_detail(&mut self) {
self.focus_pane = TuiFocusPane::ActivityDetail;
self.prompt_cursor_visible = false;
}
pub(crate) fn clear_pending_standalone_ctrl(&mut self) {
self.last_standalone_ctrl = None;
self.ignore_next_standalone_ctrl_release = false;
}
pub(crate) fn arm_standalone_ctrl(&mut self, now: Instant) {
self.last_standalone_ctrl = Some(now);
}
pub(crate) fn suppress_next_standalone_ctrl_release(&mut self) {
self.ignore_next_standalone_ctrl_release = true;
}
pub(crate) fn take_suppressed_standalone_ctrl_release(&mut self) -> bool {
let suppressed = self.ignore_next_standalone_ctrl_release;
self.ignore_next_standalone_ctrl_release = false;
suppressed
}
#[cfg(test)]
pub(crate) fn scroll_transcript_mouse_up(
&mut self,
visible_rows: u16,
wrap_width: u16,
) -> bool {
self.scroll_transcript_up_by(MOUSE_WHEEL_SCROLL_ROWS, visible_rows, wrap_width)
}
#[cfg(test)]
pub(crate) fn scroll_transcript_mouse_down(
&mut self,
visible_rows: u16,
wrap_width: u16,
) -> bool {
self.scroll_transcript_down_by(MOUSE_WHEEL_SCROLL_ROWS, visible_rows, wrap_width)
}
pub(crate) fn scroll_transcript_up_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
if !self
.transcript_cache
.borrow()
.visual_rows
.contains_key(&wrap_width.max(1))
{
let before = self.transcript_scroll_offset();
self.set_scroll_offset(
&self.scroll_views.transcript,
before.saturating_add(usize::from(rows)),
);
return self.transcript_scroll_offset() != before;
}
let before = self.transcript_scroll_offset();
let overflow = self.transcript_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.transcript,
before.saturating_add(usize::from(rows)).min(overflow),
);
self.transcript_scroll_offset() != before
}
pub(crate) fn scroll_transcript_down_by(
&mut self,
rows: u16,
_visible_rows: u16,
_wrap_width: u16,
) -> bool {
let before = self.transcript_scroll_offset();
self.set_scroll_offset(
&self.scroll_views.transcript,
before.saturating_sub(usize::from(rows)),
);
self.transcript_scroll_offset() != before
}
pub(crate) fn transcript_render_scroll(&self, visible_rows: u16, wrap_width: u16) -> usize {
self.transcript_scroll_overflow(visible_rows, wrap_width)
.saturating_sub(self.transcript_scroll_offset())
}
pub(crate) fn transcript_scroll_overflow(&self, visible_rows: u16, wrap_width: u16) -> usize {
self.transcript_visual_rows(wrap_width)
.saturating_sub(visible_rows as usize)
}
pub(crate) fn transcript_visual_rows(&self, wrap_width: u16) -> usize {
let wrap_width = wrap_width.max(1);
if let Some(rows) = self
.transcript_cache
.borrow()
.visual_rows
.get(&wrap_width)
.copied()
{
return rows;
}
let rows = {
let lines = super::super::transcript_cards::cached_visual_lines_ref(self, wrap_width);
lines
.iter()
.map(|line| super::super::transcript_cards::visual_line_rows(line, wrap_width))
.sum()
};
self.transcript_cache
.borrow_mut()
.visual_rows
.insert(wrap_width, rows);
rows
}
#[cfg(test)]
pub(crate) fn transcript_visual_text(&self) -> String {
if let Some(text) = self.transcript_cache.borrow().visual_text.clone() {
return text;
}
let text = if self.transcript.is_empty() {
"Transcript will appear here. Type a prompt below.".to_string()
} else {
super::super::transcript_cards::visual_text(self)
};
self.transcript_cache.borrow_mut().visual_text = Some(text.clone());
text
}
pub(crate) fn active_assistant_transcript_index(&self) -> Option<usize> {
if !self.assistant_streaming {
return None;
}
self.active_assistant_transcript_index
}
pub(crate) fn scroll_activity_tree_up_by(&mut self, rows: u16, visible_rows: u16) -> bool {
let before = self.activity_tree_render_scroll(visible_rows);
let overflow = self.activity_tree_scroll_overflow(visible_rows);
self.set_scroll_offset(
&self.scroll_views.activity_tree,
before.saturating_sub(usize::from(rows)).min(overflow),
);
self.activity_follow_tail = false;
self.activity_tree_render_scroll(visible_rows) != before
}
pub(crate) fn scroll_activity_tree_down_by(&mut self, rows: u16, visible_rows: u16) -> bool {
let before = self.activity_tree_render_scroll(visible_rows);
let overflow = self.activity_tree_scroll_overflow(visible_rows);
self.set_scroll_offset(
&self.scroll_views.activity_tree,
before.saturating_add(usize::from(rows)).min(overflow),
);
self.activity_follow_tail = self.activity_tree_render_scroll(visible_rows) >= overflow;
self.activity_tree_render_scroll(visible_rows) != before
}
pub(crate) fn click_activity_tree_row(&mut self, row: u16, visible_rows: u16) -> bool {
let visible_len = self.visible_node_count();
if visible_len == 0 {
return false;
}
let index = self
.activity_tree_render_scroll(visible_rows)
.saturating_add(usize::from(row));
let Some(id) = self.visible_node_id_at(index) else {
return false;
};
if self.selected == index {
if !self.has_activity_children(&id) {
return false;
}
let previous_selection = Some(id.clone());
if self.expanded.remove(&id) {
self.invalidate_activity_render_cache();
self.normalize_selection_after_visible_change(previous_selection);
} else {
self.expanded.insert(id);
self.invalidate_activity_render_cache();
}
return true;
}
self.selected = index;
self.activity_follow_tail = self.selected >= visible_len.saturating_sub(1);
self.reset_detail();
true
}
pub(crate) fn activity_tree_render_scroll(&self, visible_rows: u16) -> usize {
self.activity_scroll_offset()
.min(self.activity_tree_scroll_overflow(visible_rows))
}
pub(crate) fn activity_tree_scroll_overflow(&self, visible_rows: u16) -> usize {
self.visible_node_count()
.saturating_sub(visible_rows as usize)
}
pub(crate) fn ensure_activity_selection_visible(&mut self, visible_rows: u16) {
let visible_len = self.visible_node_count();
if visible_len == 0 {
self.selected = 0;
self.set_scroll_offset(&self.scroll_views.activity_tree, 0);
return;
}
self.selected = self.selected.min(visible_len - 1);
let visible_rows = usize::from(visible_rows.max(1));
let overflow = self.activity_tree_scroll_overflow(visible_rows as u16);
let scroll = self.activity_scroll_offset().min(overflow);
let offset = if self.selected < scroll {
self.selected
} else if self.selected >= scroll.saturating_add(visible_rows) {
self.selected
.saturating_add(1)
.saturating_sub(visible_rows)
.min(overflow)
} else {
scroll
};
self.set_scroll_offset(&self.scroll_views.activity_tree, offset);
}
#[cfg(test)]
pub(crate) fn scroll_detail_mouse_down(&mut self, visible_rows: u16, wrap_width: u16) -> bool {
self.scroll_detail_down_by(MOUSE_WHEEL_SCROLL_ROWS, visible_rows, wrap_width)
}
pub(crate) fn scroll_detail_up_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.detail_offset();
let overflow = self.detail_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.detail,
before.saturating_sub(usize::from(rows)).min(overflow),
);
self.detail_offset() != before
}
#[cfg(test)]
pub(crate) fn scroll_detail_down(&mut self) {
let _ = self.scroll_detail_down_by(1, 1, 80);
}
#[cfg(test)]
pub(crate) fn scroll_detail_up(&mut self) {
let _ = self.scroll_detail_up_by(1, u16::MAX, 80);
}
pub(crate) fn scroll_detail_down_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.detail_offset();
let overflow = self.detail_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.detail,
before.saturating_add(usize::from(rows)).min(overflow),
);
self.detail_offset() != before
}
pub(crate) fn detail_body_viewport_rows(&self, visible_rows: u16, wrap_width: u16) -> u16 {
let wrap_width = wrap_width.max(1);
let summary_height = self
.selected_activity_summary_lines()
.iter()
.map(|line| transcript::ratatui_wrapped_visual_rows(&line.plain_text(), wrap_width))
.sum::<usize>()
.min(usize::from(visible_rows)) as u16;
visible_rows.saturating_sub(summary_height)
}
pub(crate) fn detail_overflow(&self, visible_rows: u16, wrap_width: u16) -> usize {
if visible_rows == 0 {
return 0;
}
self.detail_body_visual_rows(wrap_width)
.saturating_sub(visible_rows as usize)
}
#[cfg(test)]
pub(crate) fn detail_visual_rows(&self, wrap_width: u16) -> usize {
self.selected_activity_display_lines()
.iter()
.map(|line| {
transcript::ratatui_wrapped_visual_rows(&line.plain_text(), wrap_width.max(1))
})
.sum()
}
pub(crate) fn detail_body_visual_rows(&self, wrap_width: u16) -> usize {
let wrap_width = wrap_width.max(1);
self.ensure_selected_activity_body_wrap_cache(wrap_width);
self.activity_render_cache
.borrow()
.selected_detail
.as_ref()
.and_then(|detail| detail.body_wrap_cache.get(&wrap_width))
.map(|cache| cache.total_rows)
.unwrap_or_default()
}
fn ensure_selected_activity_body_wrap_cache(&self, wrap_width: u16) {
let wrap_width = wrap_width.max(1);
self.ensure_selected_activity_detail_cache();
let mut cache = self.activity_render_cache.borrow_mut();
let Some(detail) = cache.selected_detail.as_mut() else {
return;
};
if detail.body_wrap_cache.contains_key(&wrap_width) {
return;
}
const MAX_WIDTH_CACHE_ENTRIES: usize = 4;
if detail.body_wrap_cache.len() >= MAX_WIDTH_CACHE_ENTRIES
&& let Some(old_width) = detail.body_wrap_widths.pop_front()
{
detail.body_wrap_cache.remove(&old_width);
}
let mut total_rows = 0usize;
let mut visual_starts = Vec::new();
for (line_index, line) in detail.body_lines.iter().enumerate() {
let text = line.plain_text();
let mut column = 0usize;
visual_starts.push((line_index, 0));
for (byte, grapheme) in
unicode_segmentation::UnicodeSegmentation::grapheme_indices(text.as_str(), true)
{
if grapheme.chars().any(char::is_control) {
continue;
}
let width =
unicode_width::UnicodeWidthStr::width(grapheme).min(usize::from(wrap_width));
if column > 0 && column.saturating_add(width) > usize::from(wrap_width) {
total_rows = total_rows.saturating_add(1);
column = 0;
visual_starts.push((line_index, byte));
}
column = column.saturating_add(width);
}
total_rows = total_rows.saturating_add(1);
}
detail.body_wrap_cache.insert(
wrap_width,
ActivityBodyWrapCache {
total_rows,
visual_starts,
},
);
detail.body_wrap_widths.push_back(wrap_width);
}
pub(crate) fn visible_activity_body_lines(
&self,
offset: usize,
visible_rows: usize,
wrap_width: u16,
) -> Vec<DisplayLine> {
if visible_rows == 0 {
return Vec::new();
}
self.ensure_selected_activity_body_wrap_cache(wrap_width);
let cache = self.activity_render_cache.borrow();
let Some(detail) = cache.selected_detail.as_ref() else {
return Vec::new();
};
let Some(wrap) = detail.body_wrap_cache.get(&wrap_width.max(1)) else {
return Vec::new();
};
let start = offset.min(wrap.visual_starts.len());
let end = start
.saturating_add(visible_rows)
.min(wrap.visual_starts.len());
wrap.visual_starts[start..end]
.iter()
.enumerate()
.map(|(relative_row, &(line_index, byte_start))| {
let row = start + relative_row;
let byte_end = wrap
.visual_starts
.get(row + 1)
.filter(|(next_line, _)| *next_line == line_index)
.map(|(_, byte)| *byte);
Self::slice_display_line(
&detail.body_lines[line_index],
byte_start,
byte_end,
wrap_width,
)
})
.collect()
}
fn slice_display_line(
line: &DisplayLine,
start: usize,
end: Option<usize>,
wrap_width: u16,
) -> DisplayLine {
let mut cursor = 0usize;
let end = end.unwrap_or(usize::MAX);
let mut spans = Vec::new();
for span in &line.spans {
let span_start = cursor;
let span_end = cursor.saturating_add(span.text.len());
cursor = span_end;
let from = start.max(span_start).min(span_end);
let to = end.min(span_end).max(from);
if from < to {
let mut text = String::new();
for grapheme in span.text[from - span_start..to - span_start].graphemes(true) {
if grapheme.chars().any(char::is_control) {
continue;
}
if unicode_width::UnicodeWidthStr::width(grapheme) > usize::from(wrap_width) {
text.push('\u{fffd}');
} else {
text.push_str(grapheme);
}
}
if !text.is_empty() {
spans.push(crate::rendering::DisplaySpan::new(text, span.role));
}
}
}
DisplayLine { spans }
}
pub(crate) fn scroll_help_up_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
help_text: &str,
) -> bool {
let before = self.help_offset();
let overflow = self.help_overflow(visible_rows, wrap_width, help_text);
self.set_scroll_offset(
&self.scroll_views.help,
before.saturating_sub(usize::from(rows)).min(overflow),
);
self.help_offset() != before
}
pub(crate) fn scroll_help_down_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
help_text: &str,
) -> bool {
let before = self.help_offset();
let overflow = self.help_overflow(visible_rows, wrap_width, help_text);
self.set_scroll_offset(
&self.scroll_views.help,
before.saturating_add(usize::from(rows)).min(overflow),
);
self.help_offset() != before
}
pub(crate) fn help_render_offset(
&self,
_visible_rows: u16,
_wrap_width: u16,
_help_text: &str,
) -> usize {
self.help_offset()
}
pub(crate) fn help_overflow(
&self,
visible_rows: u16,
wrap_width: u16,
help_text: &str,
) -> usize {
transcript::ratatui_wrapped_visual_rows(help_text, wrap_width)
.saturating_sub(visible_rows as usize)
}
pub(crate) fn reset_detail(&mut self) {
self.set_scroll_offset(&self.scroll_views.detail, 0);
}
pub(crate) fn selected_activity_id(&self) -> Option<ActivityId> {
self.cached_visible_nodes()
.get(self.selected)
.map(|(_, id)| id.clone())
}
pub(crate) fn has_activity_children(&self, id: &ActivityId) -> bool {
self.children
.get(id)
.is_some_and(|children| !children.is_empty())
}
pub(crate) fn toggle_prompt_cursor(&mut self) {
if !self.is_prompt_focused() {
self.prompt_cursor_visible = false;
} else if self.input.is_empty() {
self.prompt_cursor_visible = !self.prompt_cursor_visible;
} else {
self.prompt_cursor_visible = true;
}
}
pub(crate) fn normalize_selection_after_visible_change(
&mut self,
previous_selection: Option<ActivityId>,
) {
let visible_len = self.visible_node_count();
if visible_len == 0 {
if previous_selection.is_some() || self.selected != 0 {
self.reset_detail();
}
self.selected = 0;
self.set_scroll_offset(&self.scroll_views.activity_tree, 0);
return;
}
let previous_index = self.selected;
self.selected = self.selected.min(visible_len.saturating_sub(1));
let current_selection = self.visible_node_id_at(self.selected);
if previous_index != self.selected || previous_selection != current_selection {
self.reset_detail();
}
}
#[cfg(test)]
pub(crate) fn visible_nodes(&self) -> Vec<(usize, ActivityId)> {
self.cached_visible_nodes().clone()
}
pub(crate) fn visible_node_count(&self) -> usize {
self.cached_visible_nodes().len()
}
pub(crate) fn visible_node_id_at(&self, index: usize) -> Option<ActivityId> {
self.cached_visible_nodes()
.get(index)
.map(|(_, id)| id.clone())
}
pub(crate) fn visible_node_index(&self, target: &ActivityId) -> Option<usize> {
self.cached_visible_nodes()
.iter()
.position(|(_, id)| id == target)
}
pub(crate) fn visible_activity_ids(&self) -> Vec<ActivityId> {
self.cached_visible_nodes()
.iter()
.map(|(_, id)| id.clone())
.collect()
}
pub(crate) fn cached_visible_nodes(&self) -> Ref<'_, Vec<(usize, ActivityId)>> {
if self.activity_render_cache.borrow().visible_nodes.is_none() {
let mut out = Vec::new();
for root in &self.roots {
self.collect_visible(root, 0, &mut out);
}
self.activity_render_cache.borrow_mut().visible_nodes = Some(out);
}
Ref::map(self.activity_render_cache.borrow(), |cache| {
cache.visible_nodes.as_ref().expect("visible nodes cached")
})
}
fn collect_visible(&self, id: &ActivityId, depth: usize, out: &mut Vec<(usize, ActivityId)>) {
out.push((depth, id.clone()));
if self.expanded.contains(id)
&& let Some(children) = self.children.get(id)
{
for child in children {
self.collect_visible(child, depth + 1, out);
}
}
}
}