use std::time::{Duration, Instant};
use crossterm::event::{MouseButton, MouseEventKind};
use ratatui::{
backend::Backend,
layout::{Position, Rect},
Terminal,
};
use super::{
copy_interaction::{selection_position, selection_position_clamped},
paste_burst::word_range_at,
picker_input::PickerMouseEvent,
text_selection::{screen_lines, CopyNotice, TextSelection},
tool_card_hover::{ToolCardHit, ToolCardTarget},
tool_output_ui::expandable_tool_entry,
view::LiveHistory,
App, ComposerMode,
};
const COMPOSER_DOUBLE_CLICK: Duration = Duration::from_millis(500);
impl App {
pub(super) fn clear_selections(&mut self) {
self.history.clear_text_selection();
self.screen_selection = None;
self.input_ui.cancel_pointer_click_sequence();
}
fn mouse_history_view(&self, history_content: Rect, history_len: usize) -> (Rect, usize) {
let (history_start, _) =
self.visible_history_window(history_len, history_content.height as usize);
(history_content, history_start)
}
pub(super) fn handle_mouse_event<B: Backend>(
&mut self,
kind: MouseEventKind,
column: u16,
row: u16,
terminal: &mut Terminal<B>,
) -> Result<(), B::Error> {
let size = terminal.size()?;
let screen = Rect::new(0, 0, size.width, size.height);
let width = size.width as usize;
let height = size.height as usize;
self.note_terminal_geometry(width, height);
let now = Instant::now();
match kind {
MouseEventKind::ScrollUp => {
self.input_ui.cancel_pointer_click_sequence();
if self.scroll_limits_overlay_wheel(
size.width,
size.height,
-(super::HISTORY_MOUSE_SCROLL_LINES as isize),
) {
return Ok(());
}
if self.route_picker_mouse(
PickerMouseEvent::Wheel(-1),
column,
row,
size.width,
size.height,
) {
return Ok(());
}
self.screen_selection = None;
self.history.set_hovered_code_block_copy(None);
self.subagent_panel.clear_pointer_state();
self.reveal_history_scrollbar(now);
self.history.set_scrollbar_drag(None);
self.scroll_history_lines(
width,
height,
now,
-(super::HISTORY_MOUSE_SCROLL_LINES as isize),
);
}
MouseEventKind::ScrollDown => {
self.input_ui.cancel_pointer_click_sequence();
if self.scroll_limits_overlay_wheel(
size.width,
size.height,
super::HISTORY_MOUSE_SCROLL_LINES as isize,
) {
return Ok(());
}
if self.route_picker_mouse(
PickerMouseEvent::Wheel(1),
column,
row,
size.width,
size.height,
) {
return Ok(());
}
self.screen_selection = None;
self.history.set_hovered_code_block_copy(None);
self.subagent_panel.clear_pointer_state();
self.reveal_history_scrollbar(now);
self.history.set_scrollbar_drag(None);
self.scroll_history_lines(
width,
height,
now,
super::HISTORY_MOUSE_SCROLL_LINES as isize,
);
}
MouseEventKind::Down(MouseButton::Left) => {
if self.route_picker_mouse(
PickerMouseEvent::Click,
column,
row,
size.width,
size.height,
) {
self.input_ui.clear_selection();
self.input_ui.cancel_pointer_click_sequence();
return Ok(());
}
self.screen_selection = None;
let layout = self.screen_layout(screen, now);
let (history, history_start) =
self.mouse_history_view(layout.history_content, layout.history_len);
let code_target = self.code_block_copy_target_at_position(
width,
history,
history_start,
Position { x: column, y: row },
);
let scrollbar = layout
.history_scrollbar
.filter(|scrollbar| scrollbar.contains(column, row))
.filter(|_| self.should_render_history_scrollbar(now));
self.update_history_scrollbar_hover(layout.history_scrollbar, column, row);
self.history
.set_hovered_code_block_copy(code_target.as_ref().map(|target| target.line));
let subagent_target = matches!(self.input_ui.composer(), ComposerMode::Input)
.then(|| {
self.subagent_panel
.attach_target_at(layout.subagents, column, row)
})
.flatten();
if let Some(target) = subagent_target {
self.input_ui.clear_selection();
self.input_ui.cancel_pointer_click_sequence();
self.history.clear_text_selection();
self.history.set_scrollbar_drag(None);
self.subagent_panel.set_pressed(Some(&target.run_id));
self.subagent_panel.set_hovered(Some(&target.run_id));
} else if let Some(scrollbar) = scrollbar {
self.input_ui.clear_selection();
self.input_ui.cancel_pointer_click_sequence();
self.subagent_panel.clear_pointer_state();
self.history.clear_text_selection();
self.history.scroll_chrome_mut().begin_scrollbar_drag(
scrollbar,
row,
now,
super::HISTORY_SCROLLBAR_REVEAL_DURATION,
);
self.reveal_unmeasured_history_at_scrollbar_top(width, height, now);
} else if layout.jump_to_bottom.is_some_and(|rect| {
rect.contains(ratatui::layout::Position { x: column, y: row })
}) {
self.input_ui.clear_selection();
self.input_ui.cancel_pointer_click_sequence();
self.subagent_panel.clear_pointer_state();
self.history.clear_text_selection();
self.history.set_scrollbar_drag(None);
self.scroll_history_to_bottom();
} else if let Some(target) = code_target {
self.input_ui.clear_selection();
self.input_ui.cancel_pointer_click_sequence();
self.subagent_panel.clear_pointer_state();
self.history.clear_text_selection();
self.copy_text(&target.text, now);
} else if self.pointer_in_composer(&layout, column, row) {
self.subagent_panel.clear_pointer_state();
self.history.clear_text_selection();
self.history.set_scrollbar_drag(None);
self.reset_input_history_navigation();
self.input_ui.clear_transient_edit_state();
if let Some(index) =
self.composer_text_char_index_at(&layout, column, row, false)
{
let index = self.composer_caret_index(index);
let double_click = self.input_ui.register_pointer_click(
now,
column,
row,
index,
COMPOSER_DOUBLE_CLICK,
);
if double_click {
let range = self
.input_ui
.paste_segments()
.iter()
.find(|segment| segment.start <= index && index < segment.end())
.map(|segment| segment.start..segment.end())
.unwrap_or_else(|| word_range_at(self.input_ui.text(), index));
self.input_ui.select_range(range.start, range.end);
self.input_ui.set_cursor(range.end);
} else {
self.input_ui.begin_selection(index);
self.input_ui.set_cursor(index);
}
} else {
self.input_ui.clear_selection();
self.input_ui.cancel_pointer_click_sequence();
}
} else if let Some(position) =
selection_position(history, history_start, column, row)
{
self.input_ui.clear_selection();
self.input_ui.cancel_pointer_click_sequence();
self.subagent_panel.clear_pointer_state();
self.history.set_scrollbar_drag(None);
*self.history.text_selection_mut() = Some(TextSelection::new(position));
} else {
self.input_ui.clear_selection();
self.input_ui.cancel_pointer_click_sequence();
self.subagent_panel.clear_pointer_state();
self.history.clear_text_selection();
self.screen_selection =
selection_position_clamped(screen, 0, column, row).map(TextSelection::new);
}
}
MouseEventKind::Drag(MouseButton::Left) => {
self.input_ui.cancel_pointer_click_sequence();
if self.route_picker_mouse(
PickerMouseEvent::Drag,
column,
row,
size.width,
size.height,
) {
return Ok(());
}
let layout = self.screen_layout(screen, now);
self.update_history_scrollbar_hover(layout.history_scrollbar, column, row);
self.subagent_panel.clear_pointer_state();
if self.history.scrollbar_drag().is_some() {
self.history.clear_text_selection();
self.history.set_hovered_code_block_copy(None);
if let Some(scrollbar) = layout.history_scrollbar {
self.history.scroll_chrome_mut().drag_to(scrollbar, row);
self.reveal_unmeasured_history_at_scrollbar_top(width, height, now);
}
} else if self.input_ui.selection_dragging() {
if let Some(index) =
self.composer_text_char_index_at(&layout, column, row, true)
{
let index = self.composer_selection_focus(index);
self.input_ui.update_selection(index);
self.input_ui.set_cursor(index);
}
} else if self.screen_selection.is_some() {
if let (Some(selection), Some(position)) = (
self.screen_selection.as_mut(),
selection_position_clamped(screen, 0, column, row),
) {
selection.update(position);
}
} else {
let (history, history_start) =
self.mouse_history_view(layout.history_content, layout.history_len);
let hovered = self
.code_block_copy_target_at_position(
width,
history,
history_start,
Position { x: column, y: row },
)
.map(|target| target.line);
self.history.set_hovered_code_block_copy(hovered);
if let (Some(selection), Some(position)) = (
self.history.text_selection_mut().as_mut(),
selection_position_clamped(history, history_start, column, row),
) {
selection.update(position);
}
}
}
MouseEventKind::Up(MouseButton::Left) => {
if self.route_picker_mouse(
PickerMouseEvent::Release,
column,
row,
size.width,
size.height,
) {
return Ok(());
}
let pressed_subagent = self.subagent_panel.pressed_run_id().map(str::to_owned);
let was_scrollbar_drag = self.history.scrollbar_drag().is_some();
let composer_selecting = self.input_ui.selection_dragging();
self.history.set_scrollbar_drag(None);
let layout = self.screen_layout(screen, now);
self.update_history_scrollbar_hover(layout.history_scrollbar, column, row);
let released_subagent = matches!(self.input_ui.composer(), ComposerMode::Input)
.then(|| {
self.subagent_panel
.attach_target_at(layout.subagents, column, row)
})
.flatten();
self.subagent_panel.set_pressed(None);
self.subagent_panel.set_hovered(
released_subagent
.as_ref()
.map(|target| target.run_id.as_str()),
);
let activate_subagent = released_subagent
.filter(|target| pressed_subagent.as_deref() == Some(target.run_id.as_str()));
let (history, history_start) =
self.mouse_history_view(layout.history_content, layout.history_len);
let hovered = self
.code_block_copy_target_at_position(
width,
history,
history_start,
Position { x: column, y: row },
)
.map(|target| target.line);
self.history.set_hovered_code_block_copy(hovered);
if let Some(target) = activate_subagent {
self.input_ui.clear_selection();
self.history.clear_text_selection();
self.activate_subagent_row(&target, now);
} else if was_scrollbar_drag {
self.input_ui.clear_selection();
self.history.clear_text_selection();
} else if composer_selecting {
if let Some(index) =
self.composer_text_char_index_at(&layout, column, row, true)
{
let index = self.composer_selection_focus(index);
self.input_ui.update_selection(index);
self.input_ui.set_cursor(index);
}
let focus = self.input_ui.selection_focus();
self.input_ui.finalize_selection();
if let Some(focus) = focus {
self.input_ui.set_cursor(focus);
}
} else if let Some(mut selection) = self.history.text_selection_mut().take() {
let release_position =
selection_position_clamped(history, history_start, column, row);
if let Some(position) = release_position {
selection.update(position);
}
if selection.has_moved() {
let selected_lines = selection.selected_line_range();
let lines = self.visible_history_lines(
width,
now,
selected_lines.start,
selected_lines.len(),
);
if let Some(text) = selection.selected_text(&lines, selected_lines.start) {
self.copy_text(&text, now);
*self.history.text_selection_mut() = Some(selection);
}
} else if release_position.is_some() {
let line =
history_start.saturating_add(row.saturating_sub(history.y) as usize);
self.toggle_tool_output_at_history_line(line, width, terminal)?;
}
} else if let Some(mut selection) = self.screen_selection.take() {
if let Some(position) = selection_position_clamped(screen, 0, column, row) {
selection.update(position);
}
if selection.has_moved() {
let completed = terminal.draw(|frame| self.draw(frame))?;
let lines = screen_lines(completed.buffer, screen);
if let Some(text) = selection.selected_text(&lines, 0) {
self.copy_text(&text, now);
self.screen_selection = Some(selection);
}
}
}
}
MouseEventKind::Moved if self.last_mouse_position == Some((column, row)) => {}
MouseEventKind::Moved => {
self.input_ui.cancel_pointer_click_sequence();
self.last_mouse_position = Some((column, row));
if self.route_picker_mouse(
PickerMouseEvent::Move,
column,
row,
size.width,
size.height,
) {
return Ok(());
}
let layout = self.screen_layout(screen, now);
self.update_history_scrollbar_hover(layout.history_scrollbar, column, row);
let (history, history_start) =
self.mouse_history_view(layout.history_content, layout.history_len);
let hovered = self
.code_block_copy_target_at_position(
width,
history,
history_start,
Position { x: column, y: row },
)
.map(|target| target.line);
self.history.set_hovered_code_block_copy(hovered);
let subagent_hover = matches!(self.input_ui.composer(), ComposerMode::Input)
.then(|| {
self.subagent_panel
.attach_target_at(layout.subagents, column, row)
.map(|target| target.run_id)
})
.flatten();
self.subagent_panel.set_hovered(subagent_hover.as_deref());
}
MouseEventKind::Down(MouseButton::Right)
| MouseEventKind::Down(MouseButton::Middle)
| MouseEventKind::Up(MouseButton::Right)
| MouseEventKind::Up(MouseButton::Middle)
| MouseEventKind::Drag(MouseButton::Right)
| MouseEventKind::Drag(MouseButton::Middle)
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => {
self.input_ui.cancel_pointer_click_sequence();
}
}
Ok(())
}
fn toggle_tool_output_at_history_line<B: Backend>(
&mut self,
line: usize,
width: usize,
terminal: &mut Terminal<B>,
) -> Result<bool, B::Error> {
let live = self.live_history_layout(width, self.feed_image_row_budget(width));
let Some(hit) = self.tool_card_hit_at_history_line(line, width, &live) else {
return Ok(false);
};
match hit.target {
ToolCardTarget::Transcript(index) => {
self.toggle_transcript_tool_output(index);
}
ToolCardTarget::Preview(index) => {
let expanded = self
.turn
.tool_calls_mut()
.previews
.get_mut(&index)
.is_some_and(|pending| {
pending.expanded = !pending.expanded;
pending.expanded
});
self.set_tool_expand_status(expanded);
}
ToolCardTarget::Running(call_id) => {
let expanded = self
.turn
.tool_calls_mut()
.running
.get_mut(&call_id)
.is_some_and(|pending| {
pending.expanded = !pending.expanded;
pending.expanded
});
self.set_tool_expand_status(expanded);
}
}
self.clamp_history_scroll_for_terminal(terminal)?;
Ok(true)
}
fn set_tool_expand_status(&mut self, expanded: bool) {
self.set_status(if expanded {
"tool output expanded"
} else {
"tool output collapsed"
});
}
pub(super) fn tool_card_hit_at_history_line(
&mut self,
line: usize,
width: usize,
live: &LiveHistory,
) -> Option<ToolCardHit> {
if !self.info.runtime.shows_work_chrome() {
return None;
}
let max_tool_output_lines = self.info.runtime.max_tool_output_lines;
let header_len = self.visible_session_header_len(width);
if let Some(transcript_line) = line.checked_sub(header_len) {
let cwd = self.info.runtime.cwd.clone();
let settings = self.history_render_settings(width);
self.sync_open_stream_tail();
let hit = self.history.with_lines_and_images_mut(
|history_lines, entries, markdown_images| {
let index = history_lines.entry_index_at_line(
entries,
settings,
transcript_line,
&|entry_index, sources| {
markdown_images.ready_images(entry_index, sources, &cwd)
},
)?;
entries
.get(index)
.filter(|entry| expandable_tool_entry(entry, max_tool_output_lines, width))
.map(|_| index)
.and_then(|index| history_lines.entry_line_range(index))
.map(|range| (index, range))
},
);
if let Some((index, range)) = hit {
return Some(ToolCardHit {
target: ToolCardTarget::Transcript(index),
lines: (header_len + range.start)..(header_len + range.end),
});
}
}
let static_len = self.history_static_len(width);
live.card_hit_at(line.checked_sub(static_len)?)
.map(|(target, range)| ToolCardHit {
target,
lines: (static_len + range.start)..(static_len + range.end),
})
}
pub(super) fn copy_text(&mut self, text: &str, now: Instant) {
let character_count = text.chars().count();
self.history
.set_copy_notice(Some(CopyNotice::from_copy_result(
self.clipboard.copy(text),
character_count,
now,
)));
}
}