use super::*;
struct FakeClipboard {
result: anyhow::Result<()>,
calls: usize,
}
impl clipboard::ClipboardWriter for FakeClipboard {
fn copy(&mut self, _text: &str) -> anyhow::Result<()> {
self.calls += 1;
self.result
.as_ref()
.map(|_| ())
.map_err(|error| anyhow::anyhow!(error.to_string()))
}
}
fn mouse(
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
) -> crossterm::event::MouseEvent {
crossterm::event::MouseEvent {
kind,
column,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
#[test]
fn mouse_scroll_batch_classifies_same_target_batches_and_excludes_clicks() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = state::MissionControlState::default();
let first = mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 4);
let second = mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 5);
let click = mouse(
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
1,
4,
);
let other_direction = mouse(crossterm::event::MouseEventKind::ScrollDown, 1, 4);
let detail = mouse(crossterm::event::MouseEventKind::ScrollUp, 61, 18);
let first_batch = mouse_scroll_batch(first, area, &state).expect("scroll batch");
assert!(
first_batch.coalesces_with(mouse_scroll_batch(second, area, &state).expect("same target"))
);
assert!(!first_batch.coalesces_with(
mouse_scroll_batch(other_direction, area, &state).expect("direction differs")
));
assert!(
!first_batch
.coalesces_with(mouse_scroll_batch(detail, area, &state).expect("target differs"))
);
assert_eq!(mouse_scroll_batch(click, area, &state), None);
}
#[test]
fn batched_mouse_scroll_applies_row_delta_and_clamps_detail() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new("tool-1"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("long detail"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: crate::output::ActivityId::new("tool-1"),
preview: (0..40)
.map(|line| format!("detail line {line}"))
.collect::<Vec<_>>()
.join("\n"),
});
let layout = render::layout_for(area, &state);
let detail = layout.pane_area(render::TuiPane::Detail).unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Detail, detail);
let rows = state::MOUSE_WHEEL_SCROLL_ROWS * 3;
assert!(handle_mouse_event_with_scroll_rows(
mouse(crossterm::event::MouseEventKind::ScrollDown, text.x, text.y,),
area,
&mut state,
rows,
));
assert_eq!(state.detail_offset(), usize::from(rows));
let body_rows = state.detail_body_viewport_rows(text.height, text.width.max(1));
let full_pane_overflow = state.detail_overflow(text.height, text.width.max(1));
let overflow = state.detail_overflow(body_rows, text.width.max(1));
assert!(overflow > full_pane_overflow);
assert!(handle_mouse_event_with_scroll_rows(
mouse(crossterm::event::MouseEventKind::ScrollDown, text.x, text.y,),
area,
&mut state,
u16::MAX,
));
assert_eq!(state.detail_offset(), overflow);
}
#[test]
fn system_prompt_modal_mouse_wheel_scrolls_content_and_blocks_fallthrough() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
for line in 0..30 {
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: format!("transcript line {line}"),
});
}
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new("tool-1"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("long detail"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: crate::output::ActivityId::new("tool-1"),
preview: (0..20)
.map(|line| format!("detail line {line}"))
.collect::<Vec<_>>()
.join("\n"),
});
state.input = "x".repeat(1000);
state.open_system_prompt_modal(
(0..40)
.map(|line| format!("system prompt line {line}"))
.collect::<Vec<_>>()
.join("\n"),
);
let modal_content = render::system_prompt_text_area(area).unwrap();
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: modal_content.x + modal_content.width / 2,
row: modal_content.y + modal_content.height / 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.scroll_offset(&state.scroll_views.system_prompt),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.prompt_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.help_offset(), 0);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: modal_content.x + modal_content.width / 2,
row: modal_content.y + modal_content.height / 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.scroll_offset(&state.scroll_views.system_prompt), 0);
let before = (
state.transcript_scroll_offset(),
state.prompt_offset(),
state.activity_scroll_offset(),
state.detail_offset(),
state.help_offset(),
state.scroll_offset(&state.scroll_views.system_prompt),
);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 1,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
before,
(
state.transcript_scroll_offset(),
state.prompt_offset(),
state.activity_scroll_offset(),
state.detail_offset(),
state.help_offset(),
state.scroll_offset(&state.scroll_views.system_prompt),
)
);
}
#[test]
fn mouse_wheel_scrolls_hovered_scrollable_pane() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState::default();
for line in 0..30 {
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: format!("line {line}"),
});
}
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new("tool-1"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("long detail"),
});
state.apply_activity_event(crate::output::ActivityEvent::Delta {
id: crate::output::ActivityId::new("tool-1"),
preview: (0..20)
.map(|line| format!("detail line {line}"))
.collect::<Vec<_>>()
.join("\n"),
});
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 61,
row: 18,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.detail_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 61,
row: 18,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.detail_offset(), 0);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 61,
row: 18,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
for index in 0..20 {
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: crate::output::ActivityId::new(format!("tree-{index}")),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new(format!("tree item {index}")),
});
}
state.selected = 0;
let selected_before = state.selected_activity_id();
let detail_before = state.selected_activity_text();
state.set_scroll_offset(&state.scroll_views.activity_tree, 0);
state.set_scroll_offset(&state.scroll_views.detail, 2);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 61,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.activity_scroll_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert_eq!(state.selected, 0);
assert_eq!(state.selected_activity_id(), selected_before);
assert_eq!(state.selected_activity_text(), detail_before);
assert_eq!(state.detail_offset(), 2);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 61,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.selected, 0);
assert_eq!(state.detail_offset(), 2);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 1,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.transcript_scroll_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 1,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.transcript_scroll_offset(), 0);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 1,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
state.input = "x".repeat(1000);
state.prompt_cursor = 0;
let layout = render::layout_for(area, &state);
let prompt = layout.pane_area(render::TuiPane::Prompt).unwrap();
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: prompt.x + 1,
row: prompt.y + 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.prompt_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert_eq!(state.prompt_cursor, 0);
assert_eq!(state.detail_offset(), 2);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: prompt.x + 1,
row: prompt.y + 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.prompt_offset(), 0);
state.show_help = true;
let help = render::layout_for(area, &state)
.pane_area(render::TuiPane::Help)
.unwrap();
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: help.x + help.width / 2,
row: help.y + help.height / 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(
state.help_offset(),
usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: help.x + help.width / 2,
row: help.y + help.height / 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.help_offset(), 0);
assert!(!handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 99,
row: 29,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
}
#[test]
fn mouse_activity_tree_click_selects_and_second_click_toggles() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
let parent = crate::output::ActivityId::new("parent");
let child = crate::output::ActivityId::new("child");
let sibling = crate::output::ActivityId::new("sibling");
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: parent.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("parent item"),
});
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: child,
parent_id: Some(parent.clone()),
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("child item"),
});
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: sibling.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("sibling item"),
});
let tree = render::layout_for(area, &state)
.pane_area(render::TuiPane::ActivityTree)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::ActivityTree, tree);
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y + 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert!(state.is_activity_tree_focused());
assert_eq!(state.selected_activity_id(), Some(sibling.clone()));
assert!(state.expanded.contains(&parent));
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.selected_activity_id(), Some(parent.clone()));
assert!(state.expanded.contains(&parent));
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.selected_activity_id(), Some(parent.clone()));
assert!(!state.expanded.contains(&parent));
assert!(handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
));
assert_eq!(state.selected_activity_id(), Some(parent.clone()));
assert!(state.expanded.contains(&parent));
}
#[test]
fn mouse_transcript_activity_button_selects_activity_and_focuses_tree() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let target = crate::output::ActivityId::new("call_read");
let sibling = crate::output::ActivityId::new("call_bash");
let mut state = state::MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
state
.transcript
.push_back("tool: ✓ read • done".to_string());
state.transcript_activity_links.insert(
0,
state::TranscriptActivityLink {
activity_id: target.clone(),
tool_name: "read".to_string(),
},
);
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: target.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Success,
metadata: crate::output::ActivityMetadata::new("read src/lib.rs"),
});
state.apply_activity_event(crate::output::ActivityEvent::Started {
id: sibling.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("bash cargo check"),
});
state.invalidate_transcript_cache();
assert_eq!(state.selected_activity_id(), Some(sibling));
let transcript = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, transcript);
let lines = state.cached_transcript_lines(text.width.max(1));
let (row, col) = lines
.iter()
.enumerate()
.find_map(|(row, line)| {
let text = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
text.find("[view-activity]").map(|byte_col| {
(
row as u16,
unicode_width::UnicodeWidthStr::width(&text[..byte_col]) as u16,
)
})
})
.expect("activity button rendered");
assert!(
col > text.width / 2,
"activity button should be right-aligned: col={col}"
);
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x + col,
row: text.y + row,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(result.changed);
assert!(state.is_activity_tree_focused());
assert_eq!(state.selected_activity_id(), Some(target));
assert!(state.selection.is_none());
}
#[test]
fn mouse_transcript_click_without_activity_button_keeps_selection_behavior() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
transcript: vec!["tool: ✓ read • done".to_string()].into(),
..Default::default()
};
let transcript = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, transcript);
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x + 4,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(result.changed);
assert!(!state.is_activity_tree_focused());
assert!(state.selection.is_some());
}
#[test]
fn mouse_prompt_drag_returns_copy_and_up_without_down_does_not() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let mut state = state::MissionControlState {
input: "hello world".to_string(),
focus_pane: crate::tui::state::TuiFocusPane::Prompt,
..Default::default()
};
let prompt = render::layout_for(area, &state)
.pane_area(render::TuiPane::Prompt)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Prompt, prompt);
let no_down = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
column: text.x + 1,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert_eq!(no_down.copy, None);
assert_eq!(state.prompt_cursor, 1);
handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
column: text.x + 6,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
let copied = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
column: text.x + 6,
row: text.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert_eq!(copied.copy.as_deref(), Some("hello"));
assert!(state.selection.is_none());
}
#[test]
fn copy_request_updates_toast_for_success_failure_and_empty() {
let now = Instant::now();
let mut state = state::MissionControlState::default();
let mut ok = FakeClipboard {
result: Ok(()),
calls: 0,
};
assert!(apply_copy_request("text", &mut state, &mut ok, now));
assert_eq!(ok.calls, 1);
assert_eq!(state.toast.as_ref().unwrap().message, "Copied to clipboard");
let mut failed = FakeClipboard {
result: Err(anyhow::anyhow!("no clipboard")),
calls: 0,
};
assert!(apply_copy_request("text", &mut state, &mut failed, now));
assert_eq!(failed.calls, 1);
assert_eq!(
state.toast.as_ref().unwrap().message,
"Clipboard copy failed"
);
assert!(!apply_copy_request("", &mut state, &mut failed, now));
assert_eq!(failed.calls, 1);
}
#[test]
fn mouse_transcript_drag_copies_visible_text_without_soft_wrap_newline() {
let area = ratatui::layout::Rect::new(0, 0, 80, 24);
let mut state = state::MissionControlState::default();
state.apply_output_event(&crate::output::OutputEvent::UserPrompt {
text: "abcdef ghij".to_string(),
});
let transcript = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, transcript);
handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x + 2,
row: text.y + 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
column: text.x + 8,
row: text.y + 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
let copied = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
column: text.x + 8,
row: text.y + 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert_eq!(copied.copy.as_deref(), Some("abcdef"));
}
#[test]
fn mouse_transcript_word_wrap_selection_keeps_spaces_and_no_soft_newline() {
let area = ratatui::layout::Rect::new(0, 0, 32, 40);
let mut state = state::MissionControlState {
transcript: vec!["assistant: alpha beta supercalifragilistic".to_string()].into(),
..Default::default()
};
let transcript = render::layout_for(area, &state)
.pane_area(render::TuiPane::Transcript)
.unwrap();
let text = render::content_text_area_for_pane(render::TuiPane::Transcript, transcript);
handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: text.x + 2,
row: text.y + 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
column: text.x + 4,
row: text.y + 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
let copied = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
column: text.x + 4,
row: text.y + 2,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert_eq!(copied.copy.as_deref(), Some("alpha beta supe"));
}
#[test]
fn session_picker_mouse_wheel_scrolls_list_and_blocks_fallthrough() {
let mut state = state::MissionControlState {
transcript: vec!["hello\nworld\nagain".to_string()].into(),
..Default::default()
};
state.open_session_picker(
(0..4)
.map(|index| state::SessionPickerRow {
id: format!("session-{index}"),
label: format!("session-{index}"),
is_current: false,
preview: None,
})
.collect(),
2,
);
let area = ratatui::layout::Rect::new(0, 0, 80, 24);
let list = render::sessions_modal_list_area(area).unwrap();
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: list.x,
row: list.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(result.changed);
assert_eq!(state.modals.session_picker.as_ref().unwrap().selected, 1);
assert_eq!(state.transcript_scroll_offset(), 0);
}
#[test]
fn session_picker_mouse_wheel_at_bounds_is_bounded() {
let mut state = state::MissionControlState {
transcript: vec!["hello\nworld\nagain".to_string()].into(),
..Default::default()
};
state.open_session_picker(
(0..3)
.map(|index| state::SessionPickerRow {
id: format!("session-{index}"),
label: format!("session-{index}"),
is_current: false,
preview: None,
})
.collect(),
2,
);
let area = ratatui::layout::Rect::new(0, 0, 80, 24);
let list = render::sessions_modal_list_area(area).unwrap();
let at_top = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: list.x,
row: list.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(!at_top.changed);
assert_eq!(state.modals.session_picker.as_ref().unwrap().selected, 0);
assert_eq!(state.scroll_offset(&state.scroll_views.session_list), 0);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.help_offset(), 0);
for _ in 0..4 {
let _ = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: list.x,
row: list.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
}
let picker = state.modals.session_picker.as_ref().unwrap();
assert_eq!(picker.selected, 2);
assert_eq!(state.scroll_offset(&state.scroll_views.session_list), 0);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.help_offset(), 0);
}
#[test]
fn session_picker_preview_mouse_wheel_scrolls_preview_and_blocks_fallthrough() {
let mut state = state::MissionControlState {
transcript: vec!["hello\nworld\nagain".to_string()].into(),
..Default::default()
};
state.open_session_picker(
vec![state::SessionPickerRow {
id: "session-a".to_string(),
label: "session-a".to_string(),
is_current: false,
preview: None,
}],
2,
);
state.set_session_preview(
"session-a",
state::SessionPreviewState::Ready(state::SessionPreview {
first: state::PreviewMessage {
role: "user",
text: (0..40)
.map(|index| format!("preview line {index}"))
.collect::<Vec<_>>()
.join("\n"),
},
most_recent: None,
}),
);
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let preview = render::sessions_modal_preview_area(area).unwrap();
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: preview.x,
row: preview.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
area,
&mut state,
);
assert!(result.changed);
let picker = state.modals.session_picker.as_ref().unwrap();
assert_eq!(picker.selected, 0);
assert!(state.scroll_offset(&state.scroll_views.session_preview) > 0);
assert_eq!(state.transcript_scroll_offset(), 0);
assert_eq!(state.activity_scroll_offset(), 0);
assert_eq!(state.detail_offset(), 0);
assert_eq!(state.help_offset(), 0);
}
#[test]
fn skills_modal_mouse_blocks_fallthrough() {
let mut state = state::MissionControlState {
transcript: vec!["hello\nworld\nagain".to_string()].into(),
..Default::default()
};
state.open_skills_modal(
vec![state::SkillToggleRow {
name: "review".to_string(),
source: "test".to_string(),
enabled: true,
}],
input::DEFAULT_PICKER_VISIBLE_ROWS as usize,
);
let result = handle_mouse_event(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 1,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
},
ratatui::layout::Rect::new(0, 0, 80, 24),
&mut state,
);
assert!(!result.changed);
assert_eq!(state.transcript_scroll_offset(), 0);
}