use super::super::*;
use super::support::*;
use std::time::Instant;
#[test]
fn detail_page_keys_scroll_by_viewport_and_leave_selection_unchanged() {
for visible_rows in [1, 3, 7] {
let mut state = state_with_activity();
state.focus_activity_detail();
state.selected = 1;
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("failed"),
preview: (0..50).map(|row| format!("line {row}\n")).collect(),
});
let viewports = KeyViewports {
detail: Some(DetailViewport {
visible_rows,
wrap_width: 80,
}),
..KeyViewports::default()
};
let page_rows = usize::from(visible_rows.max(1));
for (code, expected) in [
(KeyCode::PageDown, page_rows),
(KeyCode::Down, page_rows + 1),
(KeyCode::PageUp, 1),
(KeyCode::PageUp, 0),
] {
handle_key_with_all_viewports(key(code), &mut state, viewports, &[]);
assert_eq!(state.detail_offset(), expected);
assert_eq!(state.selected, 1);
assert_eq!(state.transcript_scroll_offset(), 0);
}
for _ in 0..60 {
handle_key_with_all_viewports(key(KeyCode::PageDown), &mut state, viewports, &[]);
}
assert_eq!(
state.detail_offset(),
state.detail_overflow(visible_rows.max(1), 80)
);
}
}
#[test]
fn detail_keyboard_scroll_clamps_to_body_viewport_rows_after_sticky_summary() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = MissionControlState::default();
state.focus_activity_detail();
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new("tool-1"),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("long detail"),
});
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("tool-1"),
preview: (0..40)
.map(|line| format!("detail line {line}"))
.collect::<Vec<_>>()
.join("\n"),
});
let viewports = crate::tui::viewports::key_viewports(area, &state);
let detail_viewport = viewports.detail.expect("detail viewport");
let detail_area = crate::tui::render::layout_for(area, &state)
.pane_area(crate::tui::render::TuiPane::Detail)
.expect("detail pane");
let text_area = crate::tui::render::content_text_area_for_pane(
crate::tui::render::TuiPane::Detail,
detail_area,
);
assert!(detail_viewport.visible_rows < text_area.height);
let old_full_pane_overflow = state.detail_overflow(text_area.height, text_area.width.max(1));
let body_overflow =
state.detail_overflow(detail_viewport.visible_rows, detail_viewport.wrap_width);
assert!(body_overflow > old_full_pane_overflow);
for _ in 0..100 {
assert_eq!(
handle_key_at(
key(KeyCode::Down),
&mut state,
viewports,
&[],
Instant::now()
),
InputAction::None
);
}
assert_eq!(state.detail_offset(), body_overflow);
}
#[test]
fn keyboard_transcript_viewport_uses_transcript_content_width() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = MissionControlState::default();
let viewports = crate::tui::viewports::key_viewports(area, &state);
let transcript_viewport = viewports.transcript.expect("transcript viewport");
let transcript_area = crate::tui::render::layout_for(area, &state)
.pane_area(crate::tui::render::TuiPane::Transcript)
.expect("transcript pane");
let text_area = crate::tui::render::content_text_area_for_pane(
crate::tui::render::TuiPane::Transcript,
transcript_area,
);
let transcript_width = crate::tui::render::transcript_scroll_view_content_width(text_area);
let generic_width = crate::tui::render::scroll_view_content_width(text_area);
assert_eq!(transcript_viewport.wrap_width, transcript_width.max(1));
assert_eq!(generic_width, transcript_width + 1);
}
#[test]
fn alt_up_down_scroll_detail_when_detail_focused() {
let mut state = state_with_activity();
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("running"),
preview: "detail body line 1\ndetail body line 2\ndetail body line 3".to_string(),
});
state.focus_activity_detail();
assert!(matches!(
handle_key(modified_key(KeyCode::Down, KeyModifiers::ALT), &mut state),
InputAction::None
));
assert_eq!(state.detail_offset(), 1);
assert!(matches!(
handle_key(modified_key(KeyCode::Up, KeyModifiers::ALT), &mut state),
InputAction::None
));
assert_eq!(state.detail_offset(), 0);
}
#[test]
fn prompt_arrows_move_cursor_not_activity_selection_when_prompt_focused() {
let mut state = state_with_activity();
state.focus_prompt();
state.set_prompt_text("abcd", 2);
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Left), &mut state, None, &[]),
InputAction::None
));
assert_eq!(state.prompt_cursor(), 1);
assert_eq!(state.selected, 0);
assert!(matches!(
handle_key_with_viewport(key(KeyCode::Right), &mut state, None, &[]),
InputAction::None
));
assert_eq!(state.prompt_cursor(), 2);
}
#[test]
fn keyboard_activity_selection_uses_activity_viewport_not_transcript_viewport() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::ActivityTree,
..Default::default()
};
for index in 0..10 {
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new(format!("tool-{index}")),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(format!("tool {index}")),
});
}
state.selected = 2;
state.set_scroll_offset(&state.scroll_views.activity_tree, 0);
assert_eq!(
handle_key_at(
key(KeyCode::Down),
&mut state,
KeyViewports {
transcript: Some(TranscriptViewport {
visible_rows: 10,
wrap_width: 80,
}),
activity_tree: Some(ActivityTreeViewport { visible_rows: 3 }),
..KeyViewports::default()
},
&[],
Instant::now(),
),
InputAction::None
);
assert_eq!(state.selected, 3);
assert_eq!(state.activity_tree_render_scroll(3), 1);
}
#[test]
fn keyboard_activity_selection_after_viewport_scroll_reveals_selected_row() {
let mut state = MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::ActivityTree,
..Default::default()
};
for index in 0..10 {
state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new(format!("tool-{index}")),
parent_id: None,
kind: ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new(format!("tool {index}")),
});
}
state.selected = 0;
state.set_scroll_offset(&state.scroll_views.activity_tree, 5);
assert_eq!(state.activity_tree_render_scroll(3), 5);
assert_eq!(
handle_key_at(
key(KeyCode::Down),
&mut state,
KeyViewports {
transcript: Some(TranscriptViewport {
visible_rows: 3,
wrap_width: 80,
}),
..KeyViewports::default()
},
&[],
Instant::now(),
),
InputAction::None
);
assert_eq!(state.selected, 1);
assert_eq!(state.activity_tree_render_scroll(3), 1);
}
#[test]
fn transcript_focus_plain_up_down_scrolls_transcript_only() {
let mut state = state_with_activity();
state.focus_pane = crate::tui::state::TuiFocusPane::Transcript;
state.set_prompt_cursor_visible(false);
state.set_prompt_text("alpha\nbeta\ngamma", "alpha\nbeta".len());
state.set_scroll_offset(&state.scroll_views.detail, 2);
state.selected = 1;
for line in 0..10 {
state
.transcript
.push_back(format!("assistant: line {line}"));
}
let viewports = KeyViewports {
prompt: Some(PromptViewport {
visible_rows: 2,
wrap_width: 80,
}),
transcript: Some(TranscriptViewport {
visible_rows: 3,
wrap_width: 80,
}),
detail: Some(DetailViewport {
visible_rows: 2,
wrap_width: 80,
}),
..KeyViewports::default()
};
let selected = state.selected;
let prompt_cursor = state.prompt_cursor();
let detail_offset = state.detail_offset();
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Up), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(state.transcript_scroll_offset(), 1);
assert_eq!(state.selected, selected);
assert_eq!(state.prompt_cursor(), prompt_cursor);
assert_eq!(state.detail_offset(), detail_offset);
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Down), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.selected, selected);
assert_eq!(state.prompt_cursor(), prompt_cursor);
assert_eq!(state.detail_offset(), detail_offset);
}
#[test]
fn activity_detail_focus_plain_up_down_scrolls_detail_only() {
let mut state = state_with_activity();
state.focus_activity_detail();
state.set_prompt_text("alpha\nbeta\ngamma", "alpha\nbeta".len());
state.set_scroll_offset(&state.scroll_views.transcript, 3);
state.selected = 1;
state.apply_activity_event(ActivityEvent::Delta {
id: ActivityId::new("failed"),
preview: "detail body line 1\ndetail body line 2\ndetail body line 3".to_string(),
});
let viewports = KeyViewports {
prompt: Some(PromptViewport {
visible_rows: 2,
wrap_width: 80,
}),
transcript: Some(TranscriptViewport {
visible_rows: 3,
wrap_width: 80,
}),
detail: Some(DetailViewport {
visible_rows: 1,
wrap_width: 8,
}),
..KeyViewports::default()
};
assert!(state.detail_overflow(1, 8) > 0);
let selected = state.selected;
let prompt_cursor = state.prompt_cursor();
let transcript_scroll_offset = state.transcript_scroll_offset();
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Down), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(state.detail_offset(), 1);
assert_eq!(state.selected, selected);
assert_eq!(state.prompt_cursor(), prompt_cursor);
assert_eq!(state.transcript_scroll_offset(), transcript_scroll_offset);
assert_eq!(
handle_key_with_all_viewports(key(KeyCode::Up), &mut state, viewports, &[]),
InputAction::None
);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.selected, selected);
assert_eq!(state.prompt_cursor(), prompt_cursor);
assert_eq!(state.transcript_scroll_offset(), transcript_scroll_offset);
}
#[test]
fn picker_viewports_follow_each_modal_list_header() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = MissionControlState::default();
let viewports = crate::tui::viewports::key_viewports(area, &state);
let mcp_rows = viewports.mcp_modal.expect("MCP viewport").visible_rows;
for rows in [
viewports
.skills_modal
.expect("skills viewport")
.visible_rows,
viewports.tools_modal.expect("tools viewport").visible_rows,
viewports
.subagents_modal
.expect("subagents viewport")
.visible_rows,
viewports
.models_modal
.expect("models viewport")
.visible_rows,
] {
assert_eq!(mcp_rows, rows.saturating_add(1));
}
}
#[test]
fn picker_viewports_are_safe_at_and_below_modal_minimum() {
let state = MissionControlState::default();
let below_minimum =
crate::tui::viewports::key_viewports(ratatui::layout::Rect::new(0, 0, 10, 4), &state);
assert!(below_minimum.skills_modal.is_none());
assert!(below_minimum.mcp_modal.is_none());
assert!(below_minimum.tools_modal.is_none());
assert!(below_minimum.subagents_modal.is_none());
assert!(below_minimum.models_modal.is_none());
let usable =
crate::tui::viewports::key_viewports(ratatui::layout::Rect::new(0, 0, 12, 12), &state);
for viewport in [
usable.skills_modal,
usable.mcp_modal,
usable.tools_modal,
usable.subagents_modal,
usable.models_modal,
] {
assert!(viewport.is_some_and(|viewport| viewport.visible_rows > 0));
}
}
#[test]
fn picker_viewports_match_rendered_list_area_at_minimum_sizes() {
let state = MissionControlState::default();
let exact_minimum =
crate::tui::viewports::key_viewports(ratatui::layout::Rect::new(0, 0, 12, 5), &state);
for viewport in [
exact_minimum.skills_modal,
exact_minimum.mcp_modal,
exact_minimum.tools_modal,
exact_minimum.subagents_modal,
exact_minimum.models_modal,
exact_minimum.theme_picker,
] {
assert!(viewport.is_none());
}
type ListArea = fn(ratatui::layout::Rect) -> Option<ratatui::layout::Rect>;
let cases: [(&str, ListArea); 5] = [
("skills", crate::tui::render::skills_modal_list_area),
("mcp", crate::tui::render::mcp_modal_list_area),
("tools", crate::tui::render::tools_modal_list_area),
("subagents", crate::tui::render::subagents_modal_list_area),
("models", crate::tui::render::models_modal_list_area),
];
for (name, list_area) in cases {
let (first_height, rendered_area) = (5..=40)
.find_map(|height| {
list_area(ratatui::layout::Rect::new(0, 0, 12, height)).map(|area| (height, area))
})
.unwrap_or_else(|| panic!("{name} never gets a rendered list area"));
assert!(rendered_area.width > 0);
assert!(rendered_area.height > 0);
assert!(list_area(ratatui::layout::Rect::new(0, 0, 12, first_height - 1,)).is_none());
let viewports = crate::tui::viewports::key_viewports(
ratatui::layout::Rect::new(0, 0, 12, first_height),
&state,
);
let viewport = match name {
"skills" => viewports.skills_modal,
"mcp" => viewports.mcp_modal,
"tools" => viewports.tools_modal,
"subagents" => viewports.subagents_modal,
"models" => viewports.models_modal,
_ => unreachable!(),
};
assert_eq!(
viewport.map(|viewport| viewport.visible_rows),
Some(rendered_area.height),
"{name} viewport does not match its rendered list area",
);
}
}