use super::*;
use unicode_segmentation::UnicodeSegmentation;
impl MissionControlState {
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.header_modal_visible()
&& self.modals.focused_header_modal == Some(ModalKind::ActivityTree)
}
pub(crate) fn is_activity_detail_focused(&self) -> bool {
self.top_modal() == Some(ModalKind::ActivityDetail)
}
pub(crate) fn header_modals_allowed_on_tab(&self) -> bool {
!self.transcript_only_layout
&& (!self.modals.header_modals_pinned
|| self.right_column_tab == RightColumnTab::Activity)
}
pub(crate) fn toggle_header_modals_pinned(&mut self) {
if self.transcript_only_layout {
return;
}
self.modals.header_modals_pinned = !self.modals.header_modals_pinned;
if self.modals.header_modals_pinned && self.modals.header_modals.is_empty() {
self.modals.header_modals_opened_by_pin = true;
self.modals.header_modals.extend([
ModalKind::ActivityTree,
ModalKind::SessionFiles,
ModalKind::Summary,
]);
}
if !self.modals.header_modals_pinned && self.modals.header_modals_opened_by_pin {
self.modals.header_modals.clear();
self.modals.focused_header_modal = None;
self.modals.header_modals_opened_by_pin = false;
}
if !self.header_modals_allowed_on_tab() {
self.focus_prompt();
}
}
pub(crate) fn header_modal_visible(&self) -> bool {
matches!(
self.top_modal(),
Some(ModalKind::Summary | ModalKind::ActivityTree | ModalKind::SessionFiles)
)
}
pub(crate) fn modal_covers_transcript(&self) -> bool {
self.top_modal().is_some()
&& !(self.modals.header_modals_pinned && self.header_modal_visible())
}
pub(crate) fn prompt_accepts_input(&self) -> bool {
self.is_prompt_focused()
&& self.diff.dialog.is_none()
&& (self.top_modal().is_none()
|| (self.header_modal_visible() && self.modals.focused_header_modal.is_none()))
}
pub(crate) fn focus_prompt(&mut self) {
self.focus_pane = TuiFocusPane::Prompt;
self.modals.focused_header_modal = None;
self.prompt_editor.set_cursor_visible(true);
}
pub(crate) fn focus_transcript(&mut self) {
self.focus_pane = TuiFocusPane::Transcript;
self.modals.focused_header_modal = None;
self.prompt_editor.set_cursor_visible(false);
}
pub(crate) fn focus_next_primary_pane(&mut self) {
if self.modals.header_modals_pinned && self.header_modal_visible() {
if self.prompt_accepts_input() {
self.focus_transcript();
return;
}
let next = self
.modals
.focused_header_modal
.and_then(|focused| {
self.modals
.header_modals
.iter()
.position(|&modal| modal == focused)
})
.map_or(0, |index| index + 1);
if let Some(&modal) = self.modals.header_modals.get(next) {
self.focus_header_modal(modal);
} else {
self.focus_prompt();
}
return;
}
if self.is_prompt_focused() {
self.focus_transcript();
} else {
self.focus_prompt();
}
}
pub(crate) fn focus_previous_primary_pane(&mut self) {
self.focus_next_primary_pane();
}
pub(crate) fn focus_header_modal(&mut self, modal: ModalKind) {
if self.transcript_only_layout
|| !matches!(
modal,
ModalKind::Summary | ModalKind::ActivityTree | ModalKind::SessionFiles
)
{
return;
}
if !self.modals.header_modals.contains(&modal) {
self.modals.header_modals.push(modal);
}
if self.header_modals_allowed_on_tab() {
self.modals.focused_header_modal = Some(modal);
}
}
fn toggle_header_modal(&mut self, modal: ModalKind) {
if let Some(index) = self
.modals
.header_modals
.iter()
.position(|&open| open == modal)
{
self.modals.header_modals.remove(index);
if self.modals.focused_header_modal == Some(modal) {
self.modals.focused_header_modal = self.modals.header_modals.last().copied();
}
if self.modals.header_modals.is_empty() {
self.modals.header_modals_pinned = false;
self.modals.header_modals_opened_by_pin = false;
}
} else {
self.focus_header_modal(modal);
}
}
pub(crate) fn focus_activity_tree(&mut self) {
self.focus_header_modal(ModalKind::ActivityTree);
}
pub(crate) fn toggle_activity_tree_modal(&mut self) {
self.toggle_header_modal(ModalKind::ActivityTree);
}
pub(crate) fn toggle_session_files_modal(&mut self) {
self.toggle_header_modal(ModalKind::SessionFiles);
}
pub(crate) fn focus_summary(&mut self) {
self.focus_header_modal(ModalKind::Summary);
}
pub(crate) fn toggle_summary_modal(&mut self) {
self.toggle_header_modal(ModalKind::Summary);
}
pub(crate) fn can_switch_main_tabs(&self) -> bool {
!self.transcript_only_layout
&& self.diff.dialog.is_none()
&& (self.top_modal().is_none() || self.header_modal_visible())
}
pub(crate) fn select_right_column_tab(&mut self, tab: RightColumnTab) -> bool {
if self.transcript_only_layout {
return false;
}
let before = (self.right_column_tab, self.focus_pane);
self.right_column_tab = tab;
if tab == RightColumnTab::Diff && !self.header_modal_visible() {
self.focus_prompt();
}
before != (self.right_column_tab, self.focus_pane)
}
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
}
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 = super::super::transcript_cards::cached_visual_rows(self, wrap_width);
let mut cache = self.transcript_cache.borrow_mut();
cache.touch_visual_width(wrap_width);
cache.visual_rows.insert(wrap_width, rows);
rows
}
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));
if self.visible_node_id_at(index).is_none() {
return false;
}
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);
}
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
}
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)
}
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()
}
pub(super) 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()
}
pub(crate) fn detail_metadata_header_viewport_rows(
&self,
visible_rows: u16,
wrap_width: u16,
) -> Option<std::ops::Range<u16>> {
let wrap_width = wrap_width.max(1);
let body_rows = self.detail_body_viewport_rows(visible_rows, wrap_width);
if body_rows == 0 {
return None;
}
let summary_rows = visible_rows.saturating_sub(body_rows);
let content_rows = self.detail_body_visual_rows(wrap_width);
let offset = self
.detail_offset()
.min(content_rows.saturating_sub(usize::from(body_rows)));
let header = self.detail_metadata_header_visual_rows(wrap_width)?;
let visible_start = header.start.max(offset);
let visible_end = header
.end
.min(offset.saturating_add(usize::from(body_rows)));
if visible_start >= visible_end {
return None;
}
let start = summary_rows.saturating_add(super::super::transcript::usize_to_u16_saturating(
visible_start - offset,
));
let end = summary_rows.saturating_add(super::super::transcript::usize_to_u16_saturating(
visible_end - offset,
));
Some(start..end)
}
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, table: None }
}
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);
if self.detail_metadata_expanded {
self.detail_metadata_expanded = false;
self.invalidate_selected_activity_detail_cache();
}
}
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.prompt_accepts_input() {
self.prompt_editor.set_cursor_visible(false);
} else {
self.prompt_editor.toggle_cursor();
}
}
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;
}
self.selected = previous_selection
.as_ref()
.and_then(|previous| {
self.cached_visible_nodes()
.iter()
.position(|(_, id)| id == previous)
})
.unwrap_or_else(|| self.selected.min(visible_len.saturating_sub(1)));
let current_selection = self.visible_node_id_at(self.selected);
if previous_selection.as_ref() != current_selection.as_ref() {
self.reset_detail();
}
}
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_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();
let mut visited = HashSet::new();
for root in &self.roots {
self.collect_visible(root, 0, &mut out, &mut visited);
}
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)>,
visited: &mut HashSet<ActivityId>,
) {
if !visited.insert(id.clone()) {
return;
}
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, visited);
}
}
}
}