#[cfg(test)]
use super::clipboard;
use crate::tui::{input, render, state};
#[cfg(test)]
use std::ops::Not;
#[cfg(test)]
use std::time::Instant;
#[cfg(test)]
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct MouseHandlingResult {
pub(crate) changed: bool,
pub(crate) copy: Option<String>,
}
#[cfg(test)]
impl MouseHandlingResult {
fn changed(changed: bool) -> Self {
Self {
changed,
copy: None,
}
}
}
#[cfg(test)]
impl Not for MouseHandlingResult {
type Output = bool;
fn not(self) -> Self::Output {
!self.changed && self.copy.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct MouseScrollBatch {
pub(crate) target: MouseScrollTarget,
pub(crate) kind: crossterm::event::MouseEventKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MouseScrollTarget {
SessionList,
SessionPreview,
SystemPrompt,
RewindModal,
Usage,
SubagentViewer,
Transcript,
ActivityTree,
Detail,
Help,
}
impl MouseScrollBatch {
pub(crate) fn coalesces_with(self, other: Self) -> bool {
self.target == other.target && self.kind == other.kind
}
}
pub(crate) fn mouse_scroll_batch(
mouse: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &state::MissionControlState,
) -> Option<MouseScrollBatch> {
let top_modal = state.top_modal();
if top_modal == Some(state::ModalKind::ThemePicker) {
return None;
}
use crossterm::event::MouseEventKind;
if !matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) {
return None;
}
if top_modal == Some(state::ModalKind::SubagentViewer) {
return render::subagent_viewer_timeline_area(terminal_area)
.filter(|area| point_in_rect(mouse.column, mouse.row, *area))
.map(|_| MouseScrollBatch {
target: MouseScrollTarget::SubagentViewer,
kind: mouse.kind,
});
}
if top_modal == Some(state::ModalKind::SessionPicker) {
if render::sessions_modal_list_area(terminal_area)
.is_some_and(|area| point_in_rect(mouse.column, mouse.row, area))
{
return Some(MouseScrollBatch {
target: MouseScrollTarget::SessionList,
kind: mouse.kind,
});
}
if render::sessions_modal_preview_area(terminal_area)
.is_some_and(|area| point_in_rect(mouse.column, mouse.row, area))
{
return Some(MouseScrollBatch {
target: MouseScrollTarget::SessionPreview,
kind: mouse.kind,
});
}
return None;
}
if top_modal == Some(state::ModalKind::SystemPrompt) {
return render::system_prompt_text_area(terminal_area)
.filter(|area| area.contains((mouse.column, mouse.row).into()))
.map(|_| MouseScrollBatch {
target: MouseScrollTarget::SystemPrompt,
kind: mouse.kind,
});
}
if top_modal == Some(state::ModalKind::Rewind) {
return render::rewind_modal_text_area(terminal_area)
.filter(|area| area.contains((mouse.column, mouse.row).into()))
.map(|_| MouseScrollBatch {
target: MouseScrollTarget::RewindModal,
kind: mouse.kind,
});
}
if top_modal == Some(state::ModalKind::Usage) {
return render::usage_modal_text_area(terminal_area)
.filter(|area| area.contains((mouse.column, mouse.row).into()))
.map(|_| MouseScrollBatch {
target: MouseScrollTarget::Usage,
kind: mouse.kind,
});
}
if top_modal.is_some() {
return None;
}
let layout = render::layout_for(terminal_area, state);
let target = match layout.pane_at(mouse.column, mouse.row)? {
render::TuiPane::Transcript => MouseScrollTarget::Transcript,
render::TuiPane::ActivityTree => MouseScrollTarget::ActivityTree,
render::TuiPane::Detail => MouseScrollTarget::Detail,
render::TuiPane::Summary => return None,
render::TuiPane::RightColumnRail => return None,
render::TuiPane::Prompt => return None,
render::TuiPane::Help => MouseScrollTarget::Help,
};
Some(MouseScrollBatch {
target,
kind: mouse.kind,
})
}
pub(crate) fn classify_mouse_event(
mouse: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &state::MissionControlState,
scroll_rows: u16,
) -> Option<input::DisplayCommand> {
use crossterm::event::{MouseButton, MouseEventKind};
let top_modal = state.top_modal();
if top_modal == Some(state::ModalKind::ThemePicker) {
return Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume));
}
if top_modal == Some(state::ModalKind::SubagentViewer) {
let command = match mouse.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
render::subagent_viewer_timeline_area(terminal_area)
.filter(|area| point_in_rect(mouse.column, mouse.row, *area))
.map(|area| {
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::SubagentViewer,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: area.height.max(1),
wrap_width: render::scroll_view_content_width(area).max(1),
})
})
}
MouseEventKind::Down(MouseButton::Left) => {
render::subagent_viewer_tab_at(terminal_area, state, mouse.column, mouse.row).map(
|visible_index| {
input::DisplayCommand::Mouse(input::MouseCommand::SelectSubagentViewerTab {
visible_index,
})
},
)
}
_ => None,
};
return Some(command.unwrap_or(input::DisplayCommand::Mouse(input::MouseCommand::Consume)));
}
if top_modal == Some(state::ModalKind::SessionPicker) {
let command = match mouse.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
if let Some(area) = render::sessions_modal_list_area(terminal_area)
&& point_in_rect(mouse.column, mouse.row, area)
{
Some(input::DisplayCommand::Modal(
input::ModalCommand::SessionPickerMove {
focus: state::SessionPickerFocus::List,
down: mouse.kind == MouseEventKind::ScrollDown,
amount: 1,
visible_rows: area.height.max(1) as usize,
preview_rows: render::sessions_modal_preview_area(terminal_area)
.map(|area| area.height.max(1))
.unwrap_or(1),
preview_width: render::sessions_modal_preview_area(terminal_area)
.map(|area| render::scroll_view_content_width(area).max(1))
.unwrap_or(1),
},
))
} else if let Some(area) = render::sessions_modal_preview_area(terminal_area)
&& point_in_rect(mouse.column, mouse.row, area)
{
Some(input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::SessionPreview,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: area.height.max(1),
wrap_width: render::scroll_view_content_width(area).max(1),
}))
} else {
None
}
}
MouseEventKind::Down(MouseButton::Left) => {
render::sessions_modal_list_area(terminal_area).and_then(|area| {
point_in_rect(mouse.column, mouse.row, area).then(|| {
input::DisplayCommand::Mouse(input::MouseCommand::SelectSessionRow {
visible_index: (mouse.row - area.y) as usize,
visible_rows: area.height.max(1) as usize,
})
})
})
}
_ => None,
};
return Some(command.unwrap_or(input::DisplayCommand::Mouse(input::MouseCommand::Consume)));
}
if matches!(
top_modal,
Some(
state::ModalKind::Skills
| state::ModalKind::Mcp
| state::ModalKind::Tools
| state::ModalKind::Subagents
)
) {
return Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume));
}
if top_modal == Some(state::ModalKind::SystemPrompt)
&& matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
)
{
return render::system_prompt_text_area(terminal_area)
.filter(|area| area.contains((mouse.column, mouse.row).into()))
.map(|area| {
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::SystemPrompt,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: area.height.max(1),
wrap_width: render::scroll_view_content_width(area).max(1),
})
});
}
if top_modal == Some(state::ModalKind::Rewind)
&& matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
)
{
return render::rewind_modal_text_area(terminal_area)
.filter(|area| area.contains((mouse.column, mouse.row).into()))
.map(|area| {
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::Rewind,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: area.height.max(1),
wrap_width: render::scroll_view_content_width(area).max(1),
})
});
}
if top_modal == Some(state::ModalKind::Usage)
&& matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
)
{
return render::usage_modal_text_area(terminal_area)
.filter(|area| area.contains((mouse.column, mouse.row).into()))
.map(|area| {
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::Usage,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: area.height.max(1),
wrap_width: render::scroll_view_content_width(area).max(1),
})
});
}
if top_modal.is_some() {
return Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume));
}
let layout = render::layout_for(terminal_area, state);
if mouse.kind == MouseEventKind::Down(MouseButton::Left)
&& let Some(tab) = layout.right_column_tab_at(mouse.column, mouse.row)
{
return Some(input::DisplayCommand::Focus(
input::FocusCommand::RightColumnTab(tab),
));
}
if let Some(area) = layout.summary {
let sections = render::summary_sections(area);
let files = point_in_rect(mouse.column, mouse.row, sections[1]);
let text = render::content_text_area_for_pane(render::TuiPane::Summary, sections[0]);
if mouse.kind == MouseEventKind::Down(MouseButton::Left)
&& mouse.row == text.y
&& mouse.column >= text.x
&& mouse.column < text.x.saturating_add(9).min(text.right())
{
return Some(input::DisplayCommand::ToggleSummary);
}
if point_in_rect(mouse.column, mouse.row, area)
&& matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
)
{
return Some(input::DisplayCommand::ScrollSummary {
files,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
width: render::summary_section_body(area, files).width,
height: render::summary_section_body(area, files).height,
});
}
if mouse.kind == MouseEventKind::Down(MouseButton::Left)
&& point_in_rect(mouse.column, mouse.row, area)
{
return Some(input::DisplayCommand::FocusSummarySection { files });
}
}
if matches!(
layout.pane_at(mouse.column, mouse.row),
Some(render::TuiPane::RightColumnRail | render::TuiPane::Summary)
) {
return Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume));
}
let pane = layout.pane_at(mouse.column, mouse.row).filter(|pane| {
if matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) {
return true;
}
layout.pane_area(*pane).is_none_or(|area| {
let text_area = render::content_text_area_for_pane(*pane, area);
let width = match pane {
render::TuiPane::Prompt => text_area.width,
render::TuiPane::Transcript => {
render::transcript_scroll_view_content_width(text_area)
}
_ => render::scroll_view_content_width(text_area),
};
mouse.column < text_area.x.saturating_add(width)
})
});
match (pane, mouse.kind) {
(Some(render::TuiPane::Transcript), MouseEventKind::ScrollUp)
| (Some(render::TuiPane::Transcript), MouseEventKind::ScrollDown) => {
layout.pane_area(render::TuiPane::Transcript).map(|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Transcript, area);
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::Transcript,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: text_area.height,
wrap_width: render::transcript_scroll_view_content_width(text_area).max(1),
})
})
}
(Some(render::TuiPane::ActivityTree), MouseEventKind::ScrollUp)
| (Some(render::TuiPane::ActivityTree), MouseEventKind::ScrollDown) => {
layout.pane_area(render::TuiPane::ActivityTree).map(|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::ActivityTree, area);
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::ActivityTree,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: text_area.height,
wrap_width: render::scroll_view_content_width(text_area).max(1),
})
})
}
(Some(render::TuiPane::Detail), MouseEventKind::ScrollUp)
| (Some(render::TuiPane::Detail), MouseEventKind::ScrollDown) => {
layout.pane_area(render::TuiPane::Detail).map(|area| {
let text_area = render::content_text_area_for_pane(render::TuiPane::Detail, area);
let wrap_width = render::scroll_view_content_width(text_area).max(1);
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::Detail,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: state.detail_body_viewport_rows(text_area.height, wrap_width),
wrap_width,
})
})
}
(Some(render::TuiPane::Help), MouseEventKind::ScrollUp)
| (Some(render::TuiPane::Help), MouseEventKind::ScrollDown) => {
layout.pane_area(render::TuiPane::Help).map(|area| {
let text_area = render::content_text_area_for_pane(render::TuiPane::Help, area);
input::DisplayCommand::Scroll(input::ScrollCommand {
target: input::ScrollTarget::Help,
down: mouse.kind == MouseEventKind::ScrollDown,
rows: scroll_rows,
visible_rows: text_area.height,
wrap_width: render::scroll_view_content_width(text_area).max(1),
})
})
}
(Some(render::TuiPane::Summary), MouseEventKind::ScrollUp)
| (Some(render::TuiPane::Summary), MouseEventKind::ScrollDown) => {
Some(input::DisplayCommand::Mouse(input::MouseCommand::Consume))
}
(Some(render::TuiPane::Prompt), MouseEventKind::Down(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Prompt)
.and_then(|_| {
state.prompt_cursor_at_position(ratatui::layout::Position::new(
mouse.column,
mouse.row,
))
})
.map(|byte| {
input::DisplayCommand::Mouse(input::MouseCommand::StartPromptSelection { byte })
})
.or_else(|| Some(clear_selection_command())),
(Some(render::TuiPane::ActivityTree), MouseEventKind::Down(MouseButton::Left)) => layout
.pane_area(render::TuiPane::ActivityTree)
.and_then(|area| {
text_cell(mouse.column, mouse.row, render::TuiPane::ActivityTree, area)
})
.map(|(row, _, text_area)| {
input::DisplayCommand::Mouse(input::MouseCommand::ClickActivityTree {
row,
visible_rows: text_area.height.max(1),
})
})
.or_else(|| Some(clear_selection_command())),
(Some(render::TuiPane::Detail), MouseEventKind::Down(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Detail)
.and_then(|area| text_cell(mouse.column, mouse.row, render::TuiPane::Detail, area))
.map(|(row, _, text_area)| {
input::DisplayCommand::Mouse(input::MouseCommand::ClickActivityDetail {
row,
visible_rows: text_area.height,
wrap_width: render::scroll_view_content_width(text_area).max(1),
})
})
.or_else(|| Some(clear_selection_command())),
(Some(render::TuiPane::Transcript), MouseEventKind::Down(MouseButton::Left)) => {
if let Some(target) = subagent_card_target(mouse, terminal_area, state) {
Some(input::DisplayCommand::Mouse(match target {
crate::tui::transcript_cards::TranscriptHitTarget::SelectTask {
batch_id,
task_id,
} => input::MouseCommand::PinSubagentCard { batch_id, task_id },
crate::tui::transcript_cards::TranscriptHitTarget::OpenTranscript {
batch_id,
task_id,
} => input::MouseCommand::OpenSubagentViewer { batch_id, task_id },
}))
} else {
layout
.pane_area(render::TuiPane::Transcript)
.and_then(|area| {
text_cell(mouse.column, mouse.row, render::TuiPane::Transcript, area)
})
.and_then(|(row, col, text_area)| {
state.transcript_text_position_for_cell(
row,
col,
text_area.height.max(1),
render::transcript_scroll_view_content_width(text_area).max(1),
)
})
.map(|position| {
input::DisplayCommand::Mouse(
input::MouseCommand::StartTranscriptSelection { position },
)
})
.or_else(|| Some(clear_selection_command()))
}
}
(Some(render::TuiPane::Prompt), MouseEventKind::Drag(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Prompt)
.and_then(|_| {
state.prompt_cursor_at_position(ratatui::layout::Position::new(
mouse.column,
mouse.row,
))
})
.map(|byte| {
input::DisplayCommand::Mouse(input::MouseCommand::UpdatePromptSelection { byte })
})
.or_else(|| Some(clear_selection_command())),
(Some(render::TuiPane::Transcript), MouseEventKind::Drag(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Transcript)
.and_then(|area| text_cell(mouse.column, mouse.row, render::TuiPane::Transcript, area))
.and_then(|(row, col, text_area)| {
state.transcript_text_position_for_cell(
row,
col,
text_area.height.max(1),
render::transcript_scroll_view_content_width(text_area).max(1),
)
})
.map(|position| {
input::DisplayCommand::Mouse(input::MouseCommand::UpdateTranscriptSelection {
position,
})
})
.or_else(|| Some(clear_selection_command())),
(Some(render::TuiPane::Prompt), MouseEventKind::Up(MouseButton::Left)) => {
let pane_area = layout.pane_area(render::TuiPane::Prompt);
let byte = state
.prompt_cursor_at_position(ratatui::layout::Position::new(mouse.column, mouse.row));
let (visible_rows, wrap_width) = pane_area
.map(|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Prompt, area);
(
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
)
})
.unwrap_or((1, 1));
Some(input::DisplayCommand::Mouse(
input::MouseCommand::FinishPromptSelection {
byte,
visible_rows,
wrap_width,
},
))
}
(Some(render::TuiPane::Transcript), MouseEventKind::Up(MouseButton::Left)) => {
layout.pane_area(render::TuiPane::Transcript).map(|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Transcript, area);
input::DisplayCommand::Mouse(input::MouseCommand::FinishTranscriptSelection {
wrap_width: render::transcript_scroll_view_content_width(text_area).max(1),
})
})
}
(_, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left)) => {
Some(input::DisplayCommand::Mouse(
input::MouseCommand::ClearSelection,
))
}
_ => None,
}
}
fn clear_selection_command() -> input::DisplayCommand {
input::DisplayCommand::Mouse(input::MouseCommand::ClearSelection)
}
#[cfg(test)]
pub(crate) fn handle_mouse_event(
mouse: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &mut state::MissionControlState,
) -> MouseHandlingResult {
handle_mouse_event_with_scroll_rows(mouse, terminal_area, state, state::MOUSE_WHEEL_SCROLL_ROWS)
}
#[cfg(test)]
pub(crate) fn handle_mouse_event_with_scroll_rows(
mouse: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &mut state::MissionControlState,
scroll_rows: u16,
) -> MouseHandlingResult {
let Some(command) = classify_mouse_event(mouse, terminal_area, state, scroll_rows) else {
return MouseHandlingResult::changed(false);
};
let (changed, copy) =
crate::tui::controller::apply_display_command_for_test(command, state, &[]);
MouseHandlingResult { changed, copy }
}
fn subagent_card_target(
mouse: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &state::MissionControlState,
) -> Option<crate::tui::transcript_cards::TranscriptHitTarget> {
let pane_area =
render::layout_for(terminal_area, state).pane_area(render::TuiPane::Transcript)?;
let area = render::content_text_area_for_pane(render::TuiPane::Transcript, pane_area);
let content_width = render::transcript_scroll_view_content_width(area).max(1);
if mouse.column < area.x
|| mouse.column >= area.x.saturating_add(content_width)
|| mouse.row < area.y
|| mouse.row >= area.y.saturating_add(area.height)
{
return None;
}
let visible = crate::tui::transcript_cards::visible_scrollback_visual_lines(
state,
content_width,
area.height,
state.transcript_scroll_offset(),
);
let mut visual_row =
usize::from(mouse.row.saturating_sub(area.y)).saturating_add(visible.scroll_offset);
let line = visible.lines.iter().find(|line| {
let rows = crate::tui::transcript_cards::visual_line_rows(line, content_width);
if visual_row < rows {
true
} else {
visual_row = visual_row.saturating_sub(rows);
false
}
})?;
if visual_row != 0 {
return None;
}
let column = mouse.column.saturating_sub(area.x);
line.hit_regions
.iter()
.find(|region| region.columns.contains(&column))
.map(|region| region.target.clone())
}
#[cfg(test)]
pub(crate) fn apply_copy_request<C: clipboard::ClipboardWriter + ?Sized>(
text: &str,
state: &mut state::MissionControlState,
clipboard: &mut C,
now: Instant,
) -> bool {
if text.is_empty() {
return false;
}
if clipboard.copy(text).is_ok() {
state.show_copy_success_toast(now);
} else {
state.show_copy_failure_toast(now);
}
true
}
fn point_in_rect(column: u16, row: u16, area: ratatui::layout::Rect) -> bool {
column >= area.x
&& row >= area.y
&& column
< area
.x
.saturating_add(render::scroll_view_content_width(area))
&& row < area.y.saturating_add(area.height)
}
fn text_cell(
column: u16,
row: u16,
pane: render::TuiPane,
pane_area: ratatui::layout::Rect,
) -> Option<(u16, u16, ratatui::layout::Rect)> {
let text_area = render::content_text_area_for_pane(pane, pane_area);
let width = if pane == render::TuiPane::Transcript {
render::transcript_scroll_view_content_width(text_area)
} else {
render::scroll_view_content_width(text_area)
};
if column < text_area.x
|| row < text_area.y
|| column >= text_area.x.saturating_add(width)
|| row >= text_area.y.saturating_add(text_area.height)
{
return None;
}
Some((row - text_area.y, column - text_area.x, text_area))
}
#[cfg(test)]
#[path = "mouse_tests.rs"]
mod tests;