use anyhow::Result;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::text::Line;
use unicode_width::UnicodeWidthChar;
use crate::app::App;
use crate::app_state::{AppState, TranscriptRenderCache};
use crate::interaction_prompt::PromptFocus;
use crate::provider_service::copy_to_clipboard;
use crate::render::find_substring_from;
use crate::render::scroll_offset_from_drag;
use crate::selection::TranscriptSelection;
impl App {
pub(crate) fn handle_mouse_event(&mut self, mouse_event: MouseEvent) -> Result<()> {
if self.state.api_key_dialog.is_some() {
return self.handle_api_key_dialog_mouse(mouse_event);
}
if self.state.provider_dialog.is_some() {
return Ok(());
}
if self.state.interaction_prompt.is_some() {
self.handle_interaction_prompt_mouse(mouse_event)?;
return Ok(());
}
if self.state.slash_menu_visible() {
if self.handle_header_mouse(mouse_event) {
return Ok(());
}
self.handle_slash_popup_mouse(mouse_event)?;
return Ok(());
}
if self.handle_header_mouse(mouse_event) {
return Ok(());
}
self.handle_slash_popup_mouse(mouse_event)?;
self.handle_transcript_mouse(mouse_event);
Ok(())
}
fn handle_api_key_dialog_mouse(&mut self, mouse_event: MouseEvent) -> Result<()> {
if mouse_event.kind != MouseEventKind::Down(MouseButton::Left) {
return Ok(());
}
let Some(toggle_area) = self.state.render_state.api_key_toggle_area else {
return Ok(());
};
if mouse_in_rect(mouse_event.column, mouse_event.row, toggle_area) {
self.state.toggle_api_key_visibility();
}
Ok(())
}
fn handle_header_mouse(&mut self, mouse_event: MouseEvent) -> bool {
if mouse_event.kind != MouseEventKind::Down(MouseButton::Left) {
return false;
}
let Some(theme_toggle_area) = self.state.render_state.theme_toggle_area else {
return false;
};
if !mouse_in_rect(mouse_event.column, mouse_event.row, theme_toggle_area) {
return false;
}
self.state.toggle_theme();
true
}
fn handle_interaction_prompt_mouse(&mut self, mouse_event: MouseEvent) -> Result<()> {
if self.state.interaction_prompt.is_none()
|| mouse_event.kind != MouseEventKind::Down(MouseButton::Left)
{
return Ok(());
}
if let Some(list_rect) = self.state.render_state.interaction_prompt_list_area {
if mouse_in_rect(mouse_event.column, mouse_event.row, list_rect) {
if let Some(prompt) = self.state.interaction_prompt.as_mut() {
prompt.focus = PromptFocus::List;
let row = (mouse_event.row.saturating_sub(list_rect.y)) as usize;
let visible_max = prompt.list_visible_max();
let index = prompt.list_scroll + row.min(visible_max.saturating_sub(1));
if index < prompt.request.choices.len() {
prompt.selected = index;
}
}
return Ok(());
}
}
if let Some(supplement_rect) = self.state.render_state.interaction_prompt_supplement_area {
if mouse_in_rect(mouse_event.column, mouse_event.row, supplement_rect) {
if let Some(prompt) = self.state.interaction_prompt.as_mut() {
prompt.focus = PromptFocus::Supplement;
}
}
}
Ok(())
}
fn handle_slash_popup_mouse(&mut self, mouse_event: MouseEvent) -> Result<()> {
if mouse_event.kind != MouseEventKind::Down(MouseButton::Left)
|| !self.state.slash_menu_visible()
{
return Ok(());
}
if let Some(inner) = self.state.render_state.slash_popup_inner {
if mouse_in_rect(mouse_event.column, mouse_event.row, inner) {
let row = (mouse_event.row - inner.y) as usize;
let value = self.state.chat_state.input.value();
let cursor = self.state.chat_state.input.cursor();
if let Some(prefix) = crate::slash_complete::slash_typed_prefix(value, cursor) {
let candidates = crate::slash_complete::candidates_for_prefix(
&prefix,
&self.state.external_commands,
);
if row < candidates.len() {
self.state.slash.selected = row;
self.state.apply_slash_selection();
}
}
}
}
Ok(())
}
fn handle_transcript_mouse(&mut self, mouse_event: MouseEvent) {
let Some(area) = self.state.render_state.messages_area else {
return;
};
let in_scrollbar_zone = mouse_event.column >= area.x + area.width.saturating_sub(2)
&& mouse_event.column < area.x + area.width
&& mouse_event.row >= area.y
&& mouse_event.row < area.y + area.height;
let in_content_zone = !in_scrollbar_zone
&& mouse_event.column >= area.x
&& mouse_event.column < area.x + area.width.saturating_sub(2)
&& mouse_event.row >= area.y
&& mouse_event.row < area.y + area.height;
match mouse_event.kind {
MouseEventKind::ScrollUp => {
self.state.transcript_selection = None;
self.state.active_transcript_scroll_up();
}
MouseEventKind::ScrollDown => {
self.state.transcript_selection = None;
self.state.active_transcript_scroll_down();
}
MouseEventKind::Down(MouseButton::Left) if in_scrollbar_zone => {
self.state.set_active_transcript_scrollbar_dragging(true);
}
MouseEventKind::Down(MouseButton::Right) => {
if let Some(text) = self.state.transcript_selected_text() {
if let Err(e) = copy_to_clipboard(&text) {
tracing::warn!("copy_to_clipboard failed: {}", e);
} else {
self.state.set_copy_notice();
}
self.state.transcript_selection = None;
}
}
MouseEventKind::Down(MouseButton::Left) if in_content_zone => {
if self
.state
.transcript_selection
.as_ref()
.is_some_and(|s| !s.is_empty())
{
self.state.transcript_selection = None;
return;
}
if let Some(region) = self
.state
.render_state
.subagent_open_regions
.iter()
.find(|region| mouse_in_rect(mouse_event.column, mouse_event.row, region.rect))
.cloned()
{
self.state.enter_subagent_view(®ion.agent_id);
return;
}
if let Some(region) = self
.state
.render_state
.tool_toggle_regions
.iter()
.find(|region| mouse_in_rect(mouse_event.column, mouse_event.row, region.rect))
.copied()
{
if let Some(message) = active_message_mut(&mut self.state, region.message_index)
{
if let Some(tool) = message.tool_state.as_mut() {
tool.expanded = !tool.expanded;
message.mark_render_dirty();
}
}
return;
}
let (line_idx, col) = mouse_to_line_col(
mouse_event.column,
mouse_event.row,
area,
self.state.active_transcript_scroll_offset(),
self.state.render_state.transcript_cache.as_ref(),
);
self.state.transcript_selection = Some(TranscriptSelection::new(line_idx, col));
self.state.chat_state.input.clear_selection();
}
MouseEventKind::Down(MouseButton::Left) => {
self.state.transcript_selection = None;
}
MouseEventKind::Drag(MouseButton::Left) if in_content_zone => {
let scroll_offset = self.state.active_transcript_scroll_offset();
if let Some(sel) = self.state.transcript_selection.as_mut() {
let (line_idx, col) = mouse_to_line_col(
mouse_event.column,
mouse_event.row,
area,
scroll_offset,
self.state.render_state.transcript_cache.as_ref(),
);
sel.cursor_line = line_idx;
sel.cursor_col = col;
}
}
MouseEventKind::Moved | MouseEventKind::Drag(MouseButton::Left)
if self.state.active_transcript_scrollbar_dragging() =>
{
let track_height = area.height as usize;
let max_scroll = self.state.active_transcript_max_scroll_offset();
if track_height > 0 && max_scroll > 0 {
let rel_y = (mouse_event.row.saturating_sub(area.y) as usize)
.min(track_height.saturating_sub(1));
self.state
.set_active_transcript_scroll_offset(scroll_offset_from_drag(
rel_y,
track_height,
max_scroll,
));
}
}
MouseEventKind::Up(MouseButton::Left) => {
self.state.set_active_transcript_scrollbar_dragging(false);
if let Some(text) = self.state.transcript_selected_text() {
if let Err(e) = copy_to_clipboard(&text) {
tracing::warn!("copy_to_clipboard failed: {}", e);
} else {
self.state.set_copy_notice();
}
}
self.state.transcript_selection = None;
}
_ => {}
}
}
}
fn active_message_mut(
state: &mut AppState,
message_index: usize,
) -> Option<&mut crate::chat::Message> {
if let Some(agent_id) = state.chat_state.active_subagent_id().map(ToOwned::to_owned) {
return state
.chat_state
.subagent_lanes
.get_mut(&agent_id)
.and_then(|lane| lane.messages.get_mut(message_index));
}
state.chat_state.messages.get_mut(message_index)
}
fn mouse_in_rect(column: u16, row: u16, rect: Rect) -> bool {
column >= rect.x
&& column < rect.x.saturating_add(rect.width)
&& row >= rect.y
&& row < rect.y.saturating_add(rect.height)
}
fn mouse_to_line_col(
column: u16,
row: u16,
area: Rect,
scroll_offset: usize,
cache: Option<&TranscriptRenderCache>,
) -> (usize, usize) {
let Some(cache) = cache else {
return (0, 0);
};
if cache.line_texts.is_empty() || cache.total_lines == 0 {
return (0, 0);
}
let rel_row = row.saturating_sub(area.y) as usize;
let visual_row = scroll_offset.saturating_add(rel_row);
let col_in_content = column.saturating_sub(area.x.saturating_add(1)) as usize;
if visual_row >= cache.total_lines {
let last_idx = cache.line_texts.len() - 1;
let last_col = cache.line_texts[last_idx].chars().count();
return (last_idx, last_col);
}
let logical_idx = cache
.logical_line_visual_starts
.partition_point(|&start| start <= visual_row)
.saturating_sub(1)
.min(cache.line_texts.len().saturating_sub(1));
let line_start_visual = cache
.logical_line_visual_starts
.get(logical_idx)
.copied()
.unwrap_or(0);
let logical_text = &cache.line_texts[logical_idx];
let mut char_offset = 0usize;
for v in line_start_visual..=visual_row {
let Some(visual_line) = cache.visual_line(v) else {
continue;
};
let visual_text: String = visual_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let Some(pos) = find_substring_from(logical_text, &visual_text, char_offset) else {
continue;
};
char_offset = if v == visual_row {
pos
} else {
pos + visual_text.chars().count()
};
}
let clicked_visual_line = cache
.visual_line(visual_row)
.expect("visual_row is in range (checked above)");
let char_idx_within_visual =
visual_line_char_at_display_col(clicked_visual_line, col_in_content);
(logical_idx, char_offset + char_idx_within_visual)
}
fn visual_line_char_at_display_col(line: &Line<'_>, target_disp: usize) -> usize {
let mut disp = 0usize;
let mut char_idx = 0usize;
for span in &line.spans {
for ch in span.content.chars() {
if disp >= target_disp {
return char_idx;
}
disp += UnicodeWidthChar::width(ch).unwrap_or(0);
char_idx += 1;
}
}
char_idx
}
#[cfg(test)]
mod tests {
use super::mouse_to_line_col;
use crate::app_state::CachedMessageRender;
use crate::render::{build_transcript_cache, wrap_line_to_visual_lines};
use crate::selection::TranscriptSelection;
use ratatui::layout::Rect;
use ratatui::text::Line;
fn cached(lines: Vec<Line<'static>>, width: u16) -> CachedMessageRender {
let wrapped_lines: Vec<Vec<Line<'static>>> = lines
.iter()
.map(|line| wrap_line_to_visual_lines(line, width))
.collect();
CachedMessageRender {
width,
tool_toggle_row_offset: None,
subagent_open_target: None,
wrapped_lines: Some(wrapped_lines),
lines,
frozen_prefix_line_count: None,
}
}
#[test]
fn mouse_to_line_col_maps_click_on_wrapped_path_tail_correctly() {
let content = "Session snapshot saved: name (/tmp/xiaoo-test/sessions/snapshot-name.json)";
let render = cached(
vec![
Line::from(" ▎ System 12:00:00"),
Line::from(format!(" {content}")),
Line::raw(""),
],
40,
);
let cache = build_transcript_cache(None, vec![Some(render)]);
assert_eq!(cache.logical_line_visual_starts[1], 1);
assert_eq!(cache.total_lines, 5);
let logical_text: String = cache.line_texts[1].chars().collect();
let area = Rect::new(0, 0, 42, 10);
let tail_text: String = cache
.visual_line(3)
.expect("visual row 3 must exist")
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let tail_chars: Vec<char> = tail_text.chars().collect();
assert!(
!tail_chars.is_empty(),
"path tail visual row must not be empty"
);
let cases: &[(u16, usize)] = &[(1, 0), (2, 1), (3, 2)];
for &(terminal_col, tail_idx) in cases {
if tail_idx >= tail_chars.len() {
continue;
}
let expected_ch = tail_chars[tail_idx];
let (line_idx, char_col) = mouse_to_line_col(terminal_col, 3, area, 0, Some(&cache));
assert_eq!(
line_idx, 1,
"click on path tail (col {terminal_col}) must map to content logical line"
);
let ch_at_col: Option<char> = logical_text.chars().nth(char_col);
assert_eq!(
ch_at_col,
Some(expected_ch),
"click at terminal col {terminal_col} → char_col {char_col} should be '{expected_ch}'"
);
}
let tail_pos = logical_text
.find(&tail_text)
.unwrap_or_else(|| panic!("logical text must contain path tail {tail_text:?}"));
let (line_idx, char_col_for_first) = mouse_to_line_col(1, 3, area, 0, Some(&cache));
assert_eq!(line_idx, 1);
assert_eq!(
char_col_for_first, tail_pos,
"click on first char of path tail must map to the exact char index in the logical text"
);
}
#[test]
fn mouse_to_line_col_past_end_clamps_to_last_line() {
let render = cached(vec![Line::from("hello"), Line::raw("")], 80);
let cache = build_transcript_cache(None, vec![Some(render)]);
let area = Rect::new(0, 0, 80, 10);
let (line_idx, char_col) = mouse_to_line_col(5, 100, area, 0, Some(&cache));
assert_eq!(line_idx, 1, "past-end click clamps to last logical line");
assert_eq!(char_col, 0, "empty spacer line has 0 chars");
}
#[test]
fn mouse_to_line_col_returns_zero_when_cache_is_none() {
let area = Rect::new(0, 0, 80, 10);
let (line_idx, char_col) = mouse_to_line_col(5, 5, area, 0, None);
assert_eq!(line_idx, 0);
assert_eq!(char_col, 0);
}
#[test]
fn mouse_to_line_col_maps_click_on_cjk_wrapped_line() {
let render = cached(
vec![
Line::from("Hdr"),
Line::from(" 你好世界你好世界你好世界"),
Line::raw(""),
],
10,
);
let cache = build_transcript_cache(None, vec![Some(render)]);
assert_eq!(cache.logical_line_visual_starts[1], 1);
assert_eq!(cache.total_lines, 5);
let logical_text: String = cache.line_texts[1].chars().collect();
let area = Rect::new(0, 0, 12, 10);
let (line_idx, char_col) = mouse_to_line_col(1, 3, area, 0, Some(&cache));
assert_eq!(line_idx, 1, "click on v2 must map to content logical line");
assert_eq!(
char_col, 11,
"click on first char of v2 must map to char 11 in logical text"
);
assert_eq!(
logical_text.chars().nth(char_col),
Some('好'),
"char at mapped position must be '好' (start of v2)"
);
}
#[test]
fn mouse_to_line_col_maps_click_on_first_visual_row_of_wrapped_line() {
let content = "Session snapshot saved: name (/tmp/xiaoo-test/sessions/snapshot-name.json)";
let render = cached(
vec![
Line::from(" ▎ System 12:00:00"),
Line::from(format!(" {content}")),
Line::raw(""),
],
40,
);
let cache = build_transcript_cache(None, vec![Some(render)]);
let logical_text: String = cache.line_texts[1].chars().collect();
let area = Rect::new(0, 0, 42, 10);
let (line_idx, char_col) = mouse_to_line_col(1, 1, area, 0, Some(&cache));
assert_eq!(line_idx, 1, "click on v0 must map to content logical line");
assert_eq!(
char_col, 0,
"click on first char of v0 must map to char 0 in logical text"
);
assert_eq!(logical_text.chars().nth(char_col), Some(' '));
let (line_idx2, char_col2) = mouse_to_line_col(2, 1, area, 0, Some(&cache));
assert_eq!(line_idx2, 1);
assert_eq!(char_col2, 1);
assert_eq!(logical_text.chars().nth(char_col2), Some(' '));
}
#[test]
fn mouse_to_line_col_handles_repeated_substrings() {
let render = cached(vec![Line::from("hello hello hello hello")], 5);
let cache = build_transcript_cache(None, vec![Some(render)]);
assert_eq!(cache.total_lines, 4);
assert_eq!(cache.logical_line_visual_starts, vec![0]);
let logical_text: String = cache.line_texts[0].chars().collect();
let area = Rect::new(0, 0, 7, 10);
for (visual_row, expected_offset) in [(0, 0), (1, 6), (2, 12), (3, 18)] {
let (line_idx, char_col) = mouse_to_line_col(1, visual_row, area, 0, Some(&cache));
assert_eq!(line_idx, 0, "all visual rows must map to logical line 0");
assert_eq!(
char_col, expected_offset,
"visual row {visual_row} must map to char {expected_offset}"
);
assert_eq!(
logical_text.chars().nth(char_col),
Some('h'),
"char at mapped position must be 'h' (start of word {visual_row})"
);
}
}
#[test]
fn mouse_to_line_col_on_header_line_does_not_panic() {
let render = cached(vec![Line::from(" ▎ You 12:00:00"), Line::raw("body")], 40);
let cache = build_transcript_cache(None, vec![Some(render)]);
assert_eq!(cache.line_is_header, vec![true, false]);
let area = Rect::new(0, 0, 42, 10);
let (line_idx, char_col) = mouse_to_line_col(5, 0, area, 0, Some(&cache));
assert_eq!(line_idx, 0, "click on header maps to logical line 0");
let header_text: String = cache.line_texts[0].chars().collect();
assert!(
char_col <= header_text.chars().count(),
"char_col {char_col} must be within header text bounds"
);
}
#[test]
fn mouse_to_line_col_drag_across_wrapped_visual_rows() {
let content = "Session snapshot saved: name (/tmp/xiaoo-test/sessions/snapshot-name.json)";
let render = cached(
vec![
Line::from(" ▎ System 12:00:00"),
Line::from(format!(" {content}")),
Line::raw(""),
],
40,
);
let cache = build_transcript_cache(None, vec![Some(render)]);
let area = Rect::new(0, 0, 42, 10);
let (anchor_line, anchor_col) = mouse_to_line_col(1, 1, area, 0, Some(&cache));
let (cursor_line, cursor_col) = mouse_to_line_col(5, 3, area, 0, Some(&cache));
let mut sel = TranscriptSelection::new(anchor_line, anchor_col);
sel.cursor_line = cursor_line;
sel.cursor_col = cursor_col;
let (start_line, start_col, end_line, end_col) = sel.normalised();
assert_eq!(start_line, 1, "drag stays within content logical line");
assert_eq!(end_line, 1);
assert!(
start_col < end_col,
"normalised start_col {start_col} must be < end_col {end_col}"
);
assert_eq!(start_col, anchor_col);
assert_eq!(end_col, cursor_col);
}
}