From 286916e6ea4c3c2320c3103cb2470c57a7d92f0c Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Thu, 22 Jan 2026 21:06:34 +0200 Subject: [PATCH 1/5] feat: Add mouse-based text selection with clipboard copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable mouse capture by default on all terminals - Add MouseDragStart, MouseDrag, MouseDragEnd events for selection tracking - Create text_selection module for selection state, text extraction, and highlighting - Create toast notification system for copy feedback - Exclude Unicode box-drawing characters from selection (preserve ASCII |, -, + for markdown tables) - Extend selection when scrolling during active drag - Show 'Copied!' toast in top-right corner on successful copy - Trim leading/trailing whitespace from copied lines (removes border prefixes like '┃ ') --- tui/src/app.rs | 10 + tui/src/app/events.rs | 5 + tui/src/event.rs | 8 +- tui/src/event_loop.rs | 34 +- tui/src/services/handlers/mod.rs | 25 +- tui/src/services/handlers/text_selection.rs | 177 +++++++++++ tui/src/services/mod.rs | 2 + tui/src/services/text_selection.rs | 330 ++++++++++++++++++++ tui/src/services/toast.rs | 68 ++++ tui/src/view.rs | 55 ++++ 10 files changed, 690 insertions(+), 24 deletions(-) create mode 100644 tui/src/services/handlers/text_selection.rs create mode 100644 tui/src/services/text_selection.rs create mode 100644 tui/src/services/toast.rs diff --git a/tui/src/app.rs b/tui/src/app.rs index 440ff381..236ba1a4 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -18,7 +18,9 @@ use crate::services::shell_mode::run_background_shell_command; #[cfg(unix)] use crate::services::shell_mode::run_pty_command; use crate::services::shell_mode::{SHELL_PROMPT_PREFIX, ShellCommand, ShellEvent}; +use crate::services::text_selection::SelectionState; use crate::services::textarea::{TextArea, TextAreaState}; +use crate::services::toast::Toast; use ratatui::layout::Size; use ratatui::text::Line; use stakpak_api::models::ListRuleBook; @@ -206,6 +208,10 @@ pub struct AppState { pub interactive_shell_message_id: Option, pub shell_interaction_occurred: bool, + // ========== Text Selection State ========== + pub selection: SelectionState, + pub toast: Option, + // ========== Side Panel State ========== pub show_side_panel: bool, pub side_panel_focus: SidePanelSection, @@ -402,6 +408,10 @@ impl AppState { interactive_shell_message_id: None, shell_interaction_occurred: false, + // Text selection initialization + selection: SelectionState::default(), + toast: None, + // Profile switcher initialization show_profile_switcher: false, available_profiles: Vec::new(), diff --git a/tui/src/app/events.rs b/tui/src/app/events.rs index 1c23044b..f8664d7f 100644 --- a/tui/src/app/events.rs +++ b/tui/src/app/events.rs @@ -140,7 +140,12 @@ pub enum InputEvent { ToggleSidePanel, SidePanelNextSection, SidePanelToggleSection, + + // Mouse events MouseClick(u16, u16), + MouseDragStart(u16, u16), + MouseDrag(u16, u16), + MouseDragEnd(u16, u16), } #[derive(Debug)] diff --git a/tui/src/event.rs b/tui/src/event.rs index d1c7a396..de23e27c 100644 --- a/tui/src/event.rs +++ b/tui/src/event.rs @@ -172,7 +172,13 @@ pub fn map_crossterm_event_to_input_event(event: Event) -> Option { MouseEventKind::ScrollUp => Some(InputEvent::ScrollUp), MouseEventKind::ScrollDown => Some(InputEvent::ScrollDown), MouseEventKind::Down(crossterm::event::MouseButton::Left) => { - Some(InputEvent::MouseClick(me.column, me.row)) + Some(InputEvent::MouseDragStart(me.column, me.row)) + } + MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { + Some(InputEvent::MouseDrag(me.column, me.row)) + } + MouseEventKind::Up(crossterm::event::MouseButton::Left) => { + Some(InputEvent::MouseDragEnd(me.column, me.row)) } _ => None, }, diff --git a/tui/src/event_loop.rs b/tui/src/event_loop.rs index 4b468ffa..9176da82 100644 --- a/tui/src/event_loop.rs +++ b/tui/src/event_loop.rs @@ -3,7 +3,6 @@ //! Contains the main TUI event loop and related helper functions. use crate::app::{AppState, AppStateOptions, InputEvent, OutputEvent}; -use crate::services::detect_term::is_unsupported_terminal; use crate::services::handlers::tool::{ clear_streaming_tool_results, handle_tool_result, update_session_tool_calls_queue, }; @@ -56,25 +55,19 @@ pub async fn run_tui( crossterm::terminal::enable_raw_mode()?; - // Detect terminal support for mouse capture + // Detect terminal for adaptive colors (but always enable mouse capture) #[cfg(unix)] let terminal_info = crate::services::detect_term::detect_terminal(); - #[cfg(unix)] - let enable_mouse_capture = is_unsupported_terminal(&terminal_info.emulator); + #[allow(unused_variables)] + let _ = terminal_info; // Used for adaptive colors, kept for future use execute!( std::io::stdout(), EnterAlternateScreen, - EnableBracketedPaste + EnableBracketedPaste, + EnableMouseCapture )?; - #[cfg(unix)] - if enable_mouse_capture { - execute!(std::io::stdout(), EnableMouseCapture)?; - } else { - execute!(std::io::stdout(), DisableMouseCapture)?; - } - let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?; let term_size = terminal.size()?; @@ -95,15 +88,14 @@ pub async fn run_tui( auth_display_info, }); - // Set mouse_capture_enabled based on terminal detection (matches the execute logic above) - #[cfg(unix)] - { - state.mouse_capture_enabled = enable_mouse_capture; - } - #[cfg(not(unix))] - { - state.mouse_capture_enabled = false; - } + // Mouse capture is always enabled + state.mouse_capture_enabled = true; + + // Set initial terminal size + state.terminal_size = ratatui::layout::Size { + width: term_size.width, + height: term_size.height, + }; // Set the current profile name and rulebook config state.current_profile_name = current_profile_name; diff --git a/tui/src/services/handlers/mod.rs b/tui/src/services/handlers/mod.rs index 9d057acc..5bc36d8c 100644 --- a/tui/src/services/handlers/mod.rs +++ b/tui/src/services/handlers/mod.rs @@ -10,6 +10,7 @@ mod misc; mod navigation; mod popup; pub mod shell; +mod text_selection; pub mod tool; // Re-export find_image_file_by_name for use in clipboard_paste @@ -100,10 +101,14 @@ pub fn update( popup::handle_file_changes_popup_backspace(state); return; } - InputEvent::MouseClick(col, row) => { + InputEvent::MouseClick(col, row) | InputEvent::MouseDragStart(col, row) => { popup::handle_file_changes_popup_mouse_click(state, col, row); return; } + InputEvent::MouseDrag(_, _) | InputEvent::MouseDragEnd(_, _) => { + // Ignore drag events when file changes popup is open + return; + } _ => { // Consume other events to prevent side effects return; @@ -415,9 +420,17 @@ pub fn update( } InputEvent::ScrollUp => { navigation::handle_up_navigation(state); + // Extend selection when scrolling during active selection + if state.selection.active { + text_selection::handle_scroll_during_selection(state, -1, message_area_height); + } } InputEvent::ScrollDown => { navigation::handle_down_navigation(state, message_area_height, message_area_width); + // Extend selection when scrolling during active selection + if state.selection.active { + text_selection::handle_scroll_during_selection(state, 1, message_area_height); + } } InputEvent::PageUp => { navigation::handle_page_up(state, message_area_height, message_area_width); @@ -735,14 +748,22 @@ pub fn update( // so we don't need to call it again to avoid double-counting file changes. } InputEvent::ApprovalPopupSubmit => {} - InputEvent::MouseClick(col, row) => { + InputEvent::MouseClick(col, row) | InputEvent::MouseDragStart(col, row) => { // Check if click is on file changes popup first if state.show_file_changes_popup { popup::handle_file_changes_popup_mouse_click(state, col, row); } else { + // Try side panel click first, then start text selection if in message area popup::handle_side_panel_mouse_click(state, col, row); + text_selection::handle_drag_start(state, col, row, message_area_height); } } + InputEvent::MouseDrag(col, row) => { + text_selection::handle_drag(state, col, row, message_area_height); + } + InputEvent::MouseDragEnd(col, row) => { + text_selection::handle_drag_end(state, col, row, message_area_height); + } } navigation::adjust_scroll(state, message_area_height, message_area_width); diff --git a/tui/src/services/handlers/text_selection.rs b/tui/src/services/handlers/text_selection.rs new file mode 100644 index 00000000..2cfed0ef --- /dev/null +++ b/tui/src/services/handlers/text_selection.rs @@ -0,0 +1,177 @@ +//! Text selection handler for mouse-based text selection in the message area. +//! +//! This module handles: +//! - Starting selection on mouse drag start +//! - Updating selection during mouse drag +//! - Ending selection and copying to clipboard on mouse release +//! - Extracting clean text (excluding borders, decorations) + +use crate::app::AppState; +use crate::services::text_selection::{SelectionState, copy_to_clipboard, extract_selected_text}; +use crate::services::toast::Toast; + +/// Handle mouse drag start - begins text selection if in message area +pub fn handle_drag_start(state: &mut AppState, col: u16, row: u16, message_area_height: usize) { + // Check if click is within message area (top portion of screen) + // Message area starts at row 0 and extends to message_area_height + if row as usize >= message_area_height { + // Click is outside message area, don't start selection + state.selection = SelectionState::default(); + return; + } + + // Also check if side panel is shown and click is in side panel area + if state.show_side_panel { + // Side panel is on the right, typically 32 chars wide + let side_panel_width = 32u16; + let main_area_width = state + .terminal_size + .width + .saturating_sub(side_panel_width + 1); + if col >= main_area_width { + // Click is in side panel, don't start selection + state.selection = SelectionState::default(); + return; + } + } + + // Convert screen row to absolute line index + let absolute_line = state.scroll + row as usize; + + state.selection = SelectionState { + active: true, + start_line: Some(absolute_line), + start_col: Some(col), + end_line: Some(absolute_line), + end_col: Some(col), + }; +} + +/// Handle mouse drag - updates selection +pub fn handle_drag(state: &mut AppState, col: u16, row: u16, message_area_height: usize) { + if !state.selection.active { + return; + } + + // Clamp row to message area + let clamped_row = (row as usize).min(message_area_height.saturating_sub(1)); + + // Convert screen row to absolute line index + let absolute_line = state.scroll + clamped_row; + + // Clamp col to main area if side panel is visible + let clamped_col = if state.show_side_panel { + let side_panel_width = 32u16; + let main_area_width = state + .terminal_size + .width + .saturating_sub(side_panel_width + 1); + col.min(main_area_width.saturating_sub(1)) + } else { + col + }; + + state.selection.end_line = Some(absolute_line); + state.selection.end_col = Some(clamped_col); +} + +/// Handle mouse drag end - extracts text, copies to clipboard, shows toast +pub fn handle_drag_end(state: &mut AppState, col: u16, row: u16, message_area_height: usize) { + if !state.selection.active { + return; + } + + // Update final position + handle_drag(state, col, row, message_area_height); + + // Check if this was just a click (no actual drag) + let is_just_click = match ( + &state.selection.start_line, + &state.selection.end_line, + &state.selection.start_col, + &state.selection.end_col, + ) { + (Some(sl), Some(el), Some(sc), Some(ec)) => *sl == *el && *sc == *ec, + _ => true, + }; + + if is_just_click { + // Just a click, not a selection - clear and return + state.selection = SelectionState::default(); + return; + } + + // Extract selected text + let selected_text = extract_selected_text(state); + + // Clear selection + state.selection = SelectionState::default(); + + if selected_text.is_empty() { + return; + } + + // Copy to clipboard + match copy_to_clipboard(&selected_text) { + Ok(()) => { + state.toast = Some(Toast::success("Copied!")); + } + Err(e) => { + log::warn!("Failed to copy to clipboard: {}", e); + state.toast = Some(Toast::error("Copy failed")); + } + } +} + +/// Handle scroll during active selection - extends selection in scroll direction +pub fn handle_scroll_during_selection( + state: &mut AppState, + direction: i32, + _message_area_height: usize, +) { + if !state.selection.active { + return; + } + + // Get current end position + let Some(end_line) = state.selection.end_line else { + return; + }; + + // Calculate new end line based on scroll direction + let new_end_line = if direction < 0 { + // Scrolling up - extend selection upward + end_line.saturating_sub(1) + } else { + // Scrolling down - extend selection downward + // Get total lines from cache to clamp + let max_line = state + .assembled_lines_cache + .as_ref() + .map(|(_, lines, _)| lines.len().saturating_sub(1)) + .unwrap_or(end_line); + (end_line + 1).min(max_line) + }; + + state.selection.end_line = Some(new_end_line); + + // Update end column to end of line when extending via scroll + // This gives a better selection experience + if let Some((_, cached_lines, _)) = &state.assembled_lines_cache + && new_end_line < cached_lines.len() + { + let line_width: u16 = cached_lines[new_end_line] + .spans + .iter() + .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()) as u16) + .sum(); + + // If scrolling down, select to end of line + // If scrolling up, select from start of line + if direction > 0 { + state.selection.end_col = Some(line_width); + } else { + state.selection.end_col = Some(0); + } + } +} diff --git a/tui/src/services/mod.rs b/tui/src/services/mod.rs index bd49e261..a399dd5c 100644 --- a/tui/src/services/mod.rs +++ b/tui/src/services/mod.rs @@ -26,7 +26,9 @@ pub mod shell_popup; pub mod shortcuts_popup; pub mod side_panel; pub mod syntax_highlighter; +pub mod text_selection; pub mod textarea; +pub mod toast; pub mod todo_extractor; pub mod update; pub mod wrapping; diff --git a/tui/src/services/text_selection.rs b/tui/src/services/text_selection.rs new file mode 100644 index 00000000..259084a4 --- /dev/null +++ b/tui/src/services/text_selection.rs @@ -0,0 +1,330 @@ +//! Text selection module for mouse-based text selection in the TUI. +//! +//! This module provides: +//! - SelectionState: tracks active selection bounds +//! - Text extraction: converts selection to plain text, excluding borders +//! - Clipboard operations: copy selected text to system clipboard +//! - Highlight rendering: applies selection highlighting to visible lines + +use crate::app::AppState; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; + +/// Characters that are considered borders/decorations and should be excluded from selection +/// NOTE: We only exclude Unicode box-drawing characters, NOT ASCII '|', '-', '+' +/// because those are commonly used in content (markdown tables, code, etc.) +const BORDER_CHARS: &[char] = &[ + // Light box drawing + '│', '─', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴', '┼', '┌', '┐', '└', '┘', + // Heavy/thick box drawing (used for message prefixes) + '┃', '━', '┏', '┓', '┗', '┛', '┣', '┫', '┳', '┻', '╋', // Double box drawing + '║', '═', '╔', '╗', '╚', '╝', '╟', '╢', '╤', '╧', '╠', '╣', '╦', '╩', '╬', +]; + +/// State for tracking text selection +#[derive(Debug, Clone, Default)] +pub struct SelectionState { + /// Whether selection is currently active + pub active: bool, + /// Starting line index (absolute, not screen-relative) + pub start_line: Option, + /// Starting column + pub start_col: Option, + /// Ending line index (absolute, not screen-relative) + pub end_line: Option, + /// Ending column + pub end_col: Option, +} + +impl SelectionState { + /// Get normalized selection bounds (start always before end) + pub fn normalized_bounds(&self) -> Option<(usize, u16, usize, u16)> { + match (self.start_line, self.start_col, self.end_line, self.end_col) { + (Some(sl), Some(sc), Some(el), Some(ec)) => { + if sl < el || (sl == el && sc <= ec) { + Some((sl, sc, el, ec)) + } else { + Some((el, ec, sl, sc)) + } + } + _ => None, + } + } + + /// Check if a given line is within the selection + pub fn line_in_selection(&self, line_idx: usize) -> bool { + if let Some((start_line, _, end_line, _)) = self.normalized_bounds() { + line_idx >= start_line && line_idx <= end_line + } else { + false + } + } + + /// Get column range for a specific line within selection + pub fn column_range_for_line(&self, line_idx: usize, line_width: u16) -> Option<(u16, u16)> { + let (start_line, start_col, end_line, end_col) = self.normalized_bounds()?; + + if line_idx < start_line || line_idx > end_line { + return None; + } + + let col_start = if line_idx == start_line { start_col } else { 0 }; + let col_end = if line_idx == end_line { + end_col + } else { + line_width + }; + + Some((col_start, col_end)) + } +} + +/// Check if a character is a border/decoration character +fn is_border_char(c: char) -> bool { + BORDER_CHARS.contains(&c) +} + +/// Extract plain text from a Line, excluding border characters +fn extract_text_from_line(line: &Line, start_col: u16, end_col: u16) -> String { + let mut result = String::new(); + let mut current_col: u16 = 0; + + for span in &line.spans { + for c in span.content.chars() { + let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1) as u16; + + // Check if this character is within selection range + if current_col >= start_col && current_col < end_col { + // Skip border characters + if !is_border_char(c) { + result.push(c); + } + } + + current_col += char_width; + + // Stop if we're past the end + if current_col > end_col { + break; + } + } + } + + result +} + +/// Extract selected text from the assembled lines cache +pub fn extract_selected_text(state: &AppState) -> String { + let Some((start_line, start_col, end_line, end_col)) = state.selection.normalized_bounds() + else { + return String::new(); + }; + + // Get cached lines + let Some((_, cached_lines, _)) = &state.assembled_lines_cache else { + return String::new(); + }; + + let mut result = String::new(); + + for line_idx in start_line..=end_line { + if line_idx >= cached_lines.len() { + break; + } + + let line = &cached_lines[line_idx]; + let line_width = line_display_width(line); + + // Determine column range for this line + let col_start = if line_idx == start_line { start_col } else { 0 }; + let col_end = if line_idx == end_line { + end_col + } else { + line_width + }; + + let line_text = extract_text_from_line(line, col_start, col_end); + + if !line_text.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str(&line_text); + } else if line_idx > start_line && line_idx < end_line { + // Preserve empty lines within selection (but not at boundaries) + result.push('\n'); + } + } + + // Trim leading whitespace (from border prefixes like "┃ ") and trailing whitespace + // but preserve structure + result + .lines() + .map(|l| l.trim()) + .collect::>() + .join("\n") +} + +/// Calculate display width of a line +fn line_display_width(line: &Line) -> u16 { + line.spans + .iter() + .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()) as u16) + .sum() +} + +/// Copy text to system clipboard using arboard +pub fn copy_to_clipboard(text: &str) -> Result<(), String> { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| format!("Failed to access clipboard: {}", e))?; + + clipboard + .set_text(text.to_string()) + .map_err(|e| format!("Failed to copy to clipboard: {}", e))?; + + Ok(()) +} + +/// Apply selection highlighting to visible lines +pub fn apply_selection_highlight<'a>( + lines: Vec>, + selection: &SelectionState, + scroll: usize, +) -> Vec> { + if !selection.active { + return lines; + } + + let Some((start_line, start_col, end_line, end_col)) = selection.normalized_bounds() else { + return lines; + }; + + lines + .into_iter() + .enumerate() + .map(|(screen_row, line)| { + let absolute_line = scroll + screen_row; + + // Check if this line is in selection + if absolute_line < start_line || absolute_line > end_line { + return line; + } + + // Determine column range for this line + let line_width = line_display_width(&line); + let col_start = if absolute_line == start_line { + start_col + } else { + 0 + }; + let col_end = if absolute_line == end_line { + end_col + } else { + line_width + }; + + highlight_line_range(line, col_start, col_end) + }) + .collect() +} + +/// Highlight a range within a line by inverting colors +fn highlight_line_range(line: Line<'_>, start_col: u16, end_col: u16) -> Line<'_> { + let mut new_spans: Vec = Vec::new(); + let mut current_col: u16 = 0; + + for span in line.spans { + let span_start = current_col; + let span_width = unicode_width::UnicodeWidthStr::width(span.content.as_ref()) as u16; + let span_end = span_start + span_width; + + // Check overlap with selection + if span_end <= start_col || span_start >= end_col { + // No overlap - keep original + new_spans.push(span); + } else if span_start >= start_col && span_end <= end_col { + // Fully within selection - highlight entire span + new_spans.push(Span::styled(span.content, get_highlight_style(span.style))); + } else { + // Partial overlap - need to split span + let content = span.content.to_string(); + let chars: Vec = content.chars().collect(); + let mut char_col = span_start; + let mut segment_start = 0; + let mut in_selection = char_col >= start_col && char_col < end_col; + + for (i, c) in chars.iter().enumerate() { + let char_width = unicode_width::UnicodeWidthChar::width(*c).unwrap_or(1) as u16; + let next_col = char_col + char_width; + let next_in_selection = next_col > start_col && char_col < end_col; + + // Check if we're transitioning selection state + if next_in_selection != in_selection || i == chars.len() - 1 { + let segment_end = if i == chars.len() - 1 { i + 1 } else { i }; + if segment_end > segment_start { + let segment: String = chars[segment_start..segment_end].iter().collect(); + let style = if in_selection { + get_highlight_style(span.style) + } else { + span.style + }; + new_spans.push(Span::styled(segment, style)); + } + segment_start = segment_end; + in_selection = next_in_selection; + } + + char_col = next_col; + } + + // Handle remaining segment + if segment_start < chars.len() { + let segment: String = chars[segment_start..].iter().collect(); + let style = if in_selection { + get_highlight_style(span.style) + } else { + span.style + }; + new_spans.push(Span::styled(segment, style)); + } + } + + current_col = span_end; + } + + Line::from(new_spans).style(line.style) +} + +/// Get highlight style by using text color as background +fn get_highlight_style(original: Style) -> Style { + // Use the foreground color as background + let bg = original.fg.unwrap_or(Color::White); + + // Calculate contrasting foreground + let fg = if is_light_color(bg) { + Color::Black + } else { + Color::White + }; + + Style::default().fg(fg).bg(bg) +} + +/// Check if a color is considered "light" for contrast calculation +fn is_light_color(color: Color) -> bool { + match color { + Color::Rgb(r, g, b) => { + // Luminance formula + (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) > 128.0 + } + Color::White + | Color::LightYellow + | Color::LightCyan + | Color::LightGreen + | Color::LightBlue + | Color::LightMagenta + | Color::LightRed + | Color::Gray => true, + _ => false, + } +} diff --git a/tui/src/services/toast.rs b/tui/src/services/toast.rs new file mode 100644 index 00000000..81ffa8df --- /dev/null +++ b/tui/src/services/toast.rs @@ -0,0 +1,68 @@ +//! Toast notification system for the TUI. +//! +//! Provides brief, non-intrusive notifications that appear in the top-right +//! corner and automatically disappear after a short duration. + +use std::time::{Duration, Instant}; + +/// A toast notification +#[derive(Debug, Clone)] +pub struct Toast { + /// Message to display + pub message: String, + /// When the toast was created + pub created_at: Instant, + /// How long to display the toast + pub duration: Duration, + /// Visual style of the toast + pub style: ToastStyle, +} + +/// Visual style variants for toasts +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastStyle { + /// Success message (green background) + Success, + /// Error message (red background) + Error, + /// Informational message (blue background) + Info, +} + +impl Toast { + /// Create a success toast + pub fn success(message: impl Into) -> Self { + Self { + message: message.into(), + created_at: Instant::now(), + duration: Duration::from_secs(2), + style: ToastStyle::Success, + } + } + + /// Create an error toast + pub fn error(message: impl Into) -> Self { + Self { + message: message.into(), + created_at: Instant::now(), + duration: Duration::from_secs(3), // Errors stay longer + style: ToastStyle::Error, + } + } + + /// Create an info toast + #[allow(dead_code)] + pub fn info(message: impl Into) -> Self { + Self { + message: message.into(), + created_at: Instant::now(), + duration: Duration::from_secs(2), + style: ToastStyle::Info, + } + } + + /// Check if the toast has expired + pub fn is_expired(&self) -> bool { + self.created_at.elapsed() > self.duration + } +} diff --git a/tui/src/view.rs b/tui/src/view.rs index 3a5906a1..7403e56a 100644 --- a/tui/src/view.rs +++ b/tui/src/view.rs @@ -227,6 +227,50 @@ pub fn view(f: &mut Frame, state: &mut AppState) { if state.profile_switching_in_progress { crate::services::profile_switcher::render_profile_switch_overlay(f, state); } + + // Render toast notification (highest z-index, always on top) + render_toast(f, state); +} + +/// Render toast notification in top-right corner +fn render_toast(f: &mut Frame, state: &mut AppState) { + // Check and clear expired toast + if let Some(toast) = &state.toast + && toast.is_expired() + { + state.toast = None; + return; + } + + let Some(toast) = &state.toast else { + return; + }; + + let text = format!(" {} ", toast.message); + let text_width = text.len() as u16; + let screen = f.area(); + + // Position in top-right corner with some padding + let x = screen.width.saturating_sub(text_width + 2); + let y = 1; + + let area = Rect::new(x, y, text_width, 1); + + let style = match toast.style { + crate::services::toast::ToastStyle::Success => { + Style::default().bg(Color::Green).fg(Color::White) + } + crate::services::toast::ToastStyle::Error => { + Style::default().bg(Color::Red).fg(Color::White) + } + crate::services::toast::ToastStyle::Info => { + Style::default().bg(Color::Blue).fg(Color::White) + } + }; + + // Clear background and render toast + f.render_widget(ratatui::widgets::Clear, area); + f.render_widget(Paragraph::new(text).style(style), area); } // Calculate how many lines the input will take up when wrapped @@ -277,6 +321,17 @@ fn render_messages(f: &mut Frame, state: &mut AppState, area: Rect, width: usize } } + // Apply selection highlighting if active + let visible_lines = if state.selection.active { + crate::services::text_selection::apply_selection_highlight( + visible_lines, + &state.selection, + scroll, + ) + } else { + visible_lines + }; + let message_widget = Paragraph::new(visible_lines).wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(message_widget, area); } From 3f6328e5061b6d1a665dd48b71ca5fbd8542d52e Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Fri, 23 Jan 2026 01:02:44 +0200 Subject: [PATCH 2/5] feat: Add input area text selection and fix textarea wrapping - Add click-to-position cursor in input area - Add drag-to-select text in input area with highlighting - Copy selection to clipboard on mouse release - Fix textarea wrapping to account for prefix width - Add reusable WidgetSelection module for generic selection logic - Update toast notification style (black bg, cyan border, centered text) --- tui/src/app.rs | 5 + tui/src/event_loop.rs | 6 + tui/src/services/handlers/text_selection.rs | 64 +++++- tui/src/services/mod.rs | 1 + tui/src/services/textarea.rs | 217 +++++++++++++++++++- tui/src/services/widget_selection.rs | 204 ++++++++++++++++++ tui/src/view.rs | 60 ++++-- 7 files changed, 534 insertions(+), 23 deletions(-) create mode 100644 tui/src/services/widget_selection.rs diff --git a/tui/src/app.rs b/tui/src/app.rs index 236ba1a4..5e6b4d19 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -212,6 +212,10 @@ pub struct AppState { pub selection: SelectionState, pub toast: Option, + // ========== Input Area State ========== + /// Stores the input area content rect for mouse click positioning + pub input_content_area: Option, + // ========== Side Panel State ========== pub show_side_panel: bool, pub side_panel_focus: SidePanelSection, @@ -411,6 +415,7 @@ impl AppState { // Text selection initialization selection: SelectionState::default(), toast: None, + input_content_area: None, // Profile switcher initialization show_profile_switcher: false, diff --git a/tui/src/event_loop.rs b/tui/src/event_loop.rs index 9176da82..74dbf32b 100644 --- a/tui/src/event_loop.rs +++ b/tui/src/event_loop.rs @@ -97,6 +97,12 @@ pub async fn run_tui( height: term_size.height, }; + // Pre-initialize the gitleaks config for secret redaction + // This compiles all regex patterns upfront so first paste is fast + tokio::spawn(async move { + stakpak_shared::secrets::initialize_gitleaks_config(privacy_mode); + }); + // Set the current profile name and rulebook config state.current_profile_name = current_profile_name; state.rulebook_config = rulebook_config; diff --git a/tui/src/services/handlers/text_selection.rs b/tui/src/services/handlers/text_selection.rs index 2cfed0ef..b6f7e159 100644 --- a/tui/src/services/handlers/text_selection.rs +++ b/tui/src/services/handlers/text_selection.rs @@ -5,13 +5,42 @@ //! - Updating selection during mouse drag //! - Ending selection and copying to clipboard on mouse release //! - Extracting clean text (excluding borders, decorations) +//! - Cursor positioning in input area on click use crate::app::AppState; use crate::services::text_selection::{SelectionState, copy_to_clipboard, extract_selected_text}; use crate::services::toast::Toast; -/// Handle mouse drag start - begins text selection if in message area +/// Check if coordinates are within the input area +fn is_in_input_area(state: &AppState, col: u16, row: u16) -> bool { + let Some(input_area) = state.input_content_area else { + return false; + }; + + col >= input_area.x + && col < input_area.x + input_area.width + && row >= input_area.y + && row < input_area.y + input_area.height +} + +/// Handle mouse drag start - begins text selection in message area or input area pub fn handle_drag_start(state: &mut AppState, col: u16, row: u16, message_area_height: usize) { + // First check if click is in input area + if is_in_input_area(state, col, row) { + // Click was in input area - start input selection + if let Some(input_area) = state.input_content_area { + state + .text_area + .start_selection(col, row, input_area, &state.text_area_state); + } + // Clear message area selection + state.selection = SelectionState::default(); + return; + } + + // Clear any input area selection when clicking outside + state.text_area.clear_selection(); + // Check if click is within message area (top portion of screen) // Message area starts at row 0 and extends to message_area_height if row as usize >= message_area_height { @@ -47,8 +76,19 @@ pub fn handle_drag_start(state: &mut AppState, col: u16, row: u16, message_area_ }; } -/// Handle mouse drag - updates selection +/// Handle mouse drag - updates selection in message area or input area pub fn handle_drag(state: &mut AppState, col: u16, row: u16, message_area_height: usize) { + // Check if we're dragging in input area selection mode + if state.text_area.selection.is_active() { + if let Some(input_area) = state.input_content_area { + state + .text_area + .update_selection(col, row, input_area, &state.text_area_state); + } + return; + } + + // Handle message area selection if !state.selection.active { return; } @@ -77,6 +117,26 @@ pub fn handle_drag(state: &mut AppState, col: u16, row: u16, message_area_height /// Handle mouse drag end - extracts text, copies to clipboard, shows toast pub fn handle_drag_end(state: &mut AppState, col: u16, row: u16, message_area_height: usize) { + // Check if we're ending an input area selection + if state.text_area.selection.is_active() { + if let Some(selected_text) = state.text_area.end_selection() + && !selected_text.is_empty() + { + // Copy to clipboard + match copy_to_clipboard(&selected_text) { + Ok(()) => { + state.toast = Some(Toast::success("Copied!")); + } + Err(e) => { + log::warn!("Failed to copy to clipboard: {}", e); + state.toast = Some(Toast::error("Copy failed")); + } + } + } + return; + } + + // Handle message area selection end if !state.selection.active { return; } diff --git a/tui/src/services/mod.rs b/tui/src/services/mod.rs index a399dd5c..3acee944 100644 --- a/tui/src/services/mod.rs +++ b/tui/src/services/mod.rs @@ -31,4 +31,5 @@ pub mod textarea; pub mod toast; pub mod todo_extractor; pub mod update; +pub mod widget_selection; pub mod wrapping; diff --git a/tui/src/services/textarea.rs b/tui/src/services/textarea.rs index 69c76d4b..892f70cf 100644 --- a/tui/src/services/textarea.rs +++ b/tui/src/services/textarea.rs @@ -19,6 +19,9 @@ struct TextElement { range: Range, } +// Re-export WidgetSelection as TextAreaSelection for backwards compatibility +pub use crate::services::widget_selection::WidgetSelection as TextAreaSelection; + #[derive(Debug)] pub struct TextArea { text: String, @@ -28,6 +31,8 @@ pub struct TextArea { elements: Vec, shell_mode: bool, session_empty: bool, + /// Text selection state + pub selection: TextAreaSelection, } #[derive(Debug, Clone)] @@ -58,6 +63,7 @@ impl TextArea { elements: Vec::new(), shell_mode: false, session_empty: true, + selection: TextAreaSelection::default(), } } @@ -162,6 +168,184 @@ impl TextArea { Some((area.x + col, area.y + screen_row)) } + /// Convert screen coordinates to text position. + /// Returns the text position (byte offset) for the given screen coordinates. + /// The coordinates are relative to the terminal, not the text area. + pub fn screen_pos_to_text_pos( + &self, + screen_x: u16, + screen_y: u16, + area: Rect, + state: &TextAreaState, + ) -> Option { + // Check if the click is within the text area + if screen_x < area.x || screen_x >= area.x + area.width { + return None; + } + if screen_y < area.y || screen_y >= area.y + area.height { + return None; + } + + // Handle empty text case + if self.text.is_empty() { + return Some(0); + } + + // Use content width (area width minus prefix) - must match rendering + let prefix_width = self.get_prefix_width() as u16; + let content_width = area.width.saturating_sub(prefix_width); + let lines = self.wrapped_lines(content_width); + if lines.is_empty() { + return Some(0); + } + + let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll); + + // Calculate which wrapped line was clicked + let relative_y = screen_y.saturating_sub(area.y); + let line_idx = (effective_scroll as usize) + (relative_y as usize); + + if line_idx >= lines.len() { + // Clicked below all lines - position at end + return Some(self.text.len()); + } + + let line_range = &lines[line_idx]; + + // Validate line_range is within text bounds + if line_range.start > self.text.len() || line_range.end > self.text.len() + 1 { + return Some(self.text.len()); + } + + // Calculate column within the line, accounting for prefix + let relative_x = screen_x.saturating_sub(area.x); + + // If clicking in prefix area, position at start of line + if relative_x < prefix_width { + return Some(line_range.start.min(self.text.len())); + } + + let click_col = (relative_x - prefix_width) as usize; + + // Safe end for slicing (exclusive end, but clamped to text length) + let safe_end = line_range.end.saturating_sub(1).min(self.text.len()); + let safe_start = line_range.start.min(self.text.len()); + + if safe_start >= safe_end { + return Some(safe_start); + } + + // Find the character position within the line based on display width + let line_text = &self.text[safe_start..safe_end]; + let mut current_width = 0usize; + let mut byte_pos = safe_start; + + for (idx, grapheme) in line_text.grapheme_indices(true) { + let grapheme_width = grapheme.width(); + + if current_width + grapheme_width > click_col { + // Click is on this character - decide if we should position before or after + if click_col >= current_width + grapheme_width / 2 { + byte_pos = safe_start + idx + grapheme.len(); + } else { + byte_pos = safe_start + idx; + } + break; + } + current_width += grapheme_width; + byte_pos = safe_start + idx + grapheme.len(); + } + + Some(byte_pos.min(self.text.len())) + } + + /// Set cursor position from screen coordinates + pub fn set_cursor_from_screen_pos( + &mut self, + screen_x: u16, + screen_y: u16, + area: Rect, + state: &TextAreaState, + ) -> bool { + if let Some(pos) = self.screen_pos_to_text_pos(screen_x, screen_y, area, state) { + self.set_cursor(pos); + true + } else { + false + } + } + + /// Start a text selection at the given screen position + pub fn start_selection( + &mut self, + screen_x: u16, + screen_y: u16, + area: Rect, + state: &TextAreaState, + ) -> bool { + if let Some(pos) = self.screen_pos_to_text_pos(screen_x, screen_y, area, state) { + self.selection.start(pos); + self.set_cursor(pos); + true + } else { + self.selection.clear(); + false + } + } + + /// Update the selection end position during drag + pub fn update_selection( + &mut self, + screen_x: u16, + screen_y: u16, + area: Rect, + state: &TextAreaState, + ) { + if !self.selection.is_active() { + return; + } + if let Some(pos) = self.screen_pos_to_text_pos(screen_x, screen_y, area, state) { + self.selection.update(pos); + self.set_cursor(pos); + } + } + + /// End the selection and return the selected text, clearing the selection + pub fn end_selection(&mut self) -> Option { + if !self.selection.is_active() { + return None; + } + + // Use the new API that clears after getting text + self.selection + .end_and_get_text(&self.text) + .map(|s| s.to_string()) + } + + /// Clear the selection + pub fn clear_selection(&mut self) { + self.selection.clear(); + } + + /// Check if there's an active selection + pub fn has_selection(&self) -> bool { + self.selection.has_selection() + } + + /// Get the selected text (without clearing) + pub fn get_selected_text(&self) -> Option<&str> { + self.selection.get_text(&self.text) + } + + /// Delete the selected text and return it + pub fn delete_selection(&mut self) -> Option { + let (start, end) = self.selection.normalized()?; + let deleted = self.text[start..end].to_string(); + self.replace_range(start..end, ""); + self.selection.clear(); + Some(deleted) + } + pub fn is_empty(&self) -> bool { self.text.is_empty() } @@ -204,7 +388,10 @@ impl TextArea { state: &mut TextAreaState, waiting_for_shell_input: bool, ) { - let lines = self.wrapped_lines(area.width); + // Wrap at content width (area width minus prefix) + let prefix_width = self.get_prefix_width() as u16; + let content_width = area.width.saturating_sub(prefix_width); + let lines = self.wrapped_lines(content_width); let scroll = self.effective_scroll(area.height, &lines, state.scroll); state.scroll = scroll; @@ -951,7 +1138,10 @@ impl TextArea { impl WidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let lines = self.wrapped_lines(area.width); + // Wrap at content width (area width minus prefix) + let prefix_width = self.get_prefix_width() as u16; + let content_width = area.width.saturating_sub(prefix_width); + let lines = self.wrapped_lines(content_width); self.render_lines(area, buf, &lines, 0..lines.len(), false); } } @@ -960,7 +1150,10 @@ impl StatefulWidgetRef for &TextArea { type State = TextAreaState; fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let lines = self.wrapped_lines(area.width); + // Wrap at content width (area width minus prefix) + let prefix_width = self.get_prefix_width() as u16; + let content_width = area.width.saturating_sub(prefix_width); + let lines = self.wrapped_lines(content_width); let scroll = self.effective_scroll(area.height, &lines, state.scroll); state.scroll = scroll; @@ -1063,6 +1256,24 @@ impl TextArea { buf.set_string(content_x + x_off, y, styled, style); } } + + // Overlay selection highlighting + if let Some((sel_start, sel_end)) = self.selection.normalized() { + // Check if selection intersects this line + let overlap_start = sel_start.max(line_range.start); + let overlap_end = sel_end.min(line_range.end); + if overlap_start < overlap_end { + let selected_text = if waiting_for_shell_input && self.shell_mode { + "*".repeat(overlap_end - overlap_start) + } else { + self.text[overlap_start..overlap_end].to_string() + }; + let x_off = self.text[line_range.start..overlap_start].width() as u16; + // Selection style: inverted colors (white text on blue background) + let selection_style = Style::default().fg(Color::White).bg(Color::Blue); + buf.set_string(content_x + x_off, y, &selected_text, selection_style); + } + } } // Render cursor diff --git a/tui/src/services/widget_selection.rs b/tui/src/services/widget_selection.rs new file mode 100644 index 00000000..069f5694 --- /dev/null +++ b/tui/src/services/widget_selection.rs @@ -0,0 +1,204 @@ +//! Reusable text selection module for any widget/area. +//! +//! This module provides a generic selection system that can be used in: +//! - TextArea (input field) +//! - Message area +//! - Future popups or other widgets +//! +//! # Usage +//! +//! ```rust,ignore +//! use widget_selection::WidgetSelection; +//! +//! // In your widget struct: +//! struct MyWidget { +//! selection: WidgetSelection, +//! // ... +//! } +//! +//! // Start selection on mouse down: +//! widget.selection.start(position); +//! +//! // Update during drag: +//! widget.selection.update(position); +//! +//! // End and get selected text: +//! if let Some(text) = widget.selection.end_and_get_text(&content) { +//! copy_to_clipboard(&text); +//! } +//! ``` + +use ratatui::style::{Color, Style}; + +/// Generic selection state for any widget +#[derive(Debug, Clone, Copy, Default)] +pub struct WidgetSelection { + /// Start position of selection (can be byte offset, line index, etc.) + pub start: Option, + /// End position of selection + pub end: Option, + /// Whether selection is currently active (mouse is being dragged) + pub active: bool, +} + +impl WidgetSelection { + /// Create a new empty selection + pub fn new() -> Self { + Self::default() + } + + /// Start a new selection at the given position + pub fn start(&mut self, pos: usize) { + self.start = Some(pos); + self.end = Some(pos); + self.active = true; + } + + /// Update the selection end position (during drag) + pub fn update(&mut self, pos: usize) { + if self.active { + self.end = Some(pos); + } + } + + /// End the selection (stop dragging) but keep the selection visible + /// Returns true if there was an actual selection (not just a click) + pub fn end(&mut self) -> bool { + let had_selection = self.has_selection(); + self.active = false; + had_selection + } + + /// End the selection and clear it, returning the normalized bounds if any + pub fn end_and_clear(&mut self) -> Option<(usize, usize)> { + let bounds = self.normalized(); + self.clear(); + bounds + } + + /// End the selection and get the selected text from the provided content + pub fn end_and_get_text<'a>(&mut self, text: &'a str) -> Option<&'a str> { + let result = self.get_text(text); + self.clear(); + result + } + + /// Get normalized selection bounds (start always <= end) + pub fn normalized(&self) -> Option<(usize, usize)> { + match (self.start, self.end) { + (Some(s), Some(e)) if s != e => Some((s.min(e), s.max(e))), + _ => None, + } + } + + /// Get the selected text from a string (without ending/clearing selection) + pub fn get_text<'a>(&self, text: &'a str) -> Option<&'a str> { + let (start, end) = self.normalized()?; + if end <= text.len() { + Some(&text[start..end]) + } else { + None + } + } + + /// Check if there's a valid selection (not just a click) + pub fn has_selection(&self) -> bool { + self.normalized().is_some() + } + + /// Check if selection is currently being made (mouse is dragging) + pub fn is_active(&self) -> bool { + self.active + } + + /// Check if a position is within the selection + pub fn contains(&self, pos: usize) -> bool { + if let Some((start, end)) = self.normalized() { + pos >= start && pos < end + } else { + false + } + } + + /// Clear the selection completely + pub fn clear(&mut self) { + self.start = None; + self.end = None; + self.active = false; + } + + /// Get the selection style for highlighting + pub fn highlight_style() -> Style { + Style::default().fg(Color::White).bg(Color::Blue) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_selection_lifecycle() { + let mut sel = WidgetSelection::new(); + + // Initially empty + assert!(!sel.has_selection()); + assert!(!sel.is_active()); + + // Start selection + sel.start(5); + assert!(sel.is_active()); + assert!(!sel.has_selection()); // Same start/end = no selection + + // Update selection + sel.update(10); + assert!(sel.is_active()); + assert!(sel.has_selection()); + assert_eq!(sel.normalized(), Some((5, 10))); + + // End selection + assert!(sel.end()); + assert!(!sel.is_active()); + assert!(sel.has_selection()); // Selection still visible + + // Clear + sel.clear(); + assert!(!sel.has_selection()); + } + + #[test] + fn test_get_text() { + let mut sel = WidgetSelection::new(); + let text = "Hello, World!"; + + sel.start(0); + sel.update(5); + + assert_eq!(sel.get_text(text), Some("Hello")); + } + + #[test] + fn test_reversed_selection() { + let mut sel = WidgetSelection::new(); + + // Select backwards (end before start) + sel.start(10); + sel.update(5); + + // Should normalize + assert_eq!(sel.normalized(), Some((5, 10))); + } + + #[test] + fn test_end_and_clear() { + let mut sel = WidgetSelection::new(); + let text = "Hello, World!"; + + sel.start(7); + sel.update(12); + + let result = sel.end_and_get_text(text); + assert_eq!(result, Some("World")); + assert!(!sel.has_selection()); // Should be cleared + } +} diff --git a/tui/src/view.rs b/tui/src/view.rs index 7403e56a..6e6e5d35 100644 --- a/tui/src/view.rs +++ b/tui/src/view.rs @@ -242,35 +242,56 @@ fn render_toast(f: &mut Frame, state: &mut AppState) { return; } - let Some(toast) = &state.toast else { + let Some(_toast) = &state.toast else { return; }; - let text = format!(" {} ", toast.message); - let text_width = text.len() as u16; + let text = "Copied to clipboard!"; + let padding_x = 2; + let border_width = 1; // Left border using "▌" + let text_width = text.len() + (padding_x * 2); let screen = f.area(); - // Position in top-right corner with some padding - let x = screen.width.saturating_sub(text_width + 2); + // Box dimensions + let box_width = (border_width + text_width) as u16; + let box_height = 3u16; // padding top + text + padding bottom + + // Position in top-right corner with some margin + let x = screen.width.saturating_sub(box_width + 2); let y = 1; - let area = Rect::new(x, y, text_width, 1); + let area = Rect::new(x, y, box_width, box_height); + + // Build lines: empty line, text line, empty line (vertically centered) + let padding_str = " ".repeat(text_width); + let text_line = format!("{:^width$}", text, width = text_width); + + let lines = vec![ + ratatui::text::Line::from(vec![ + ratatui::text::Span::styled("▌", Style::default().fg(Color::Cyan).bg(Color::Black)), + ratatui::text::Span::styled(padding_str.clone(), Style::default().bg(Color::Black)), + ]), + ratatui::text::Line::from(vec![ + ratatui::text::Span::styled("▌", Style::default().fg(Color::Cyan).bg(Color::Black)), + ratatui::text::Span::styled( + text_line, + Style::default() + .fg(Color::White) + .bg(Color::Black) + .add_modifier(ratatui::style::Modifier::BOLD), + ), + ]), + ratatui::text::Line::from(vec![ + ratatui::text::Span::styled("▌", Style::default().fg(Color::Cyan).bg(Color::Black)), + ratatui::text::Span::styled(padding_str, Style::default().bg(Color::Black)), + ]), + ]; - let style = match toast.style { - crate::services::toast::ToastStyle::Success => { - Style::default().bg(Color::Green).fg(Color::White) - } - crate::services::toast::ToastStyle::Error => { - Style::default().bg(Color::Red).fg(Color::White) - } - crate::services::toast::ToastStyle::Info => { - Style::default().bg(Color::Blue).fg(Color::White) - } - }; + let paragraph = Paragraph::new(lines); // Clear background and render toast f.render_widget(ratatui::widgets::Clear, area); - f.render_widget(Paragraph::new(text).style(style), area); + f.render_widget(paragraph, area); } // Calculate how many lines the input will take up when wrapped @@ -448,6 +469,9 @@ fn render_multiline_input(f: &mut Frame, state: &mut AppState, area: Rect) { height: area.height.saturating_sub(2), }; + // Store the content area for mouse click handling + state.input_content_area = Some(content_area); + // Render the block f.render_widget(block, area); From d0badf9728d7fc3e091f1d7b8b8fce1d146d5ef8 Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Sat, 24 Jan 2026 10:46:08 +0200 Subject: [PATCH 3/5] feat: Implement a message action popup allowing users to copy message text or revert to a message, and add a MouseMove event. --- tui/src/app.rs | 22 +++ tui/src/app/events.rs | 1 + tui/src/event.rs | 1 + tui/src/services/handlers/mod.rs | 38 +++++ tui/src/services/handlers/popup.rs | 128 +++++++++++++++++ tui/src/services/handlers/text_selection.rs | 36 ++++- tui/src/services/message.rs | 84 ++++++++--- tui/src/services/message_action_popup.rs | 151 ++++++++++++++++++++ tui/src/services/mod.rs | 1 + tui/src/view.rs | 67 ++++++++- 10 files changed, 504 insertions(+), 25 deletions(-) create mode 100644 tui/src/services/message_action_popup.rs diff --git a/tui/src/app.rs b/tui/src/app.rs index 5e6b4d19..26264c1c 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -79,6 +79,9 @@ pub struct AppState { pub render_metrics: RenderMetrics, /// Last width used for rendering (to detect width changes) pub last_render_width: usize, + /// Maps line ranges to message info for click detection + /// Format: Vec<(start_line, end_line, message_id, is_user_message, message_text)> + pub line_to_message_map: Vec<(usize, usize, Uuid, bool, String)>, // ========== Loading State ========== pub loading: bool, @@ -212,6 +215,15 @@ pub struct AppState { pub selection: SelectionState, pub toast: Option, + // ========== Message Action Popup State ========== + pub show_message_action_popup: bool, + pub message_action_popup_selected: usize, + pub message_action_popup_position: Option<(u16, u16)>, // (x, y) position for popup + pub message_action_target_message_id: Option, // The user message being acted on + pub message_action_target_text: Option, // The text of the target message + pub message_area_y: u16, // Y offset of message area for click detection + pub hover_row: Option, // Current mouse hover row for debugging + // ========== Input Area State ========== /// Stores the input area content rect for mouse click positioning pub input_content_area: Option, @@ -390,6 +402,7 @@ impl AppState { cache_generation: 0, render_metrics: RenderMetrics::new(), last_render_width: 0, + line_to_message_map: Vec::new(), pending_pastes: Vec::new(), mouse_capture_enabled: false, // Will be set based on terminal detection in event_loop loading_manager: LoadingStateManager::new(), @@ -417,6 +430,15 @@ impl AppState { toast: None, input_content_area: None, + // Message action popup initialization + show_message_action_popup: false, + message_action_popup_selected: 0, + message_action_popup_position: None, + message_action_target_message_id: None, + message_action_target_text: None, + message_area_y: 0, + hover_row: None, + // Profile switcher initialization show_profile_switcher: false, available_profiles: Vec::new(), diff --git a/tui/src/app/events.rs b/tui/src/app/events.rs index f8664d7f..0beb15a9 100644 --- a/tui/src/app/events.rs +++ b/tui/src/app/events.rs @@ -146,6 +146,7 @@ pub enum InputEvent { MouseDragStart(u16, u16), MouseDrag(u16, u16), MouseDragEnd(u16, u16), + MouseMove(u16, u16), } #[derive(Debug)] diff --git a/tui/src/event.rs b/tui/src/event.rs index de23e27c..1bbba3e3 100644 --- a/tui/src/event.rs +++ b/tui/src/event.rs @@ -180,6 +180,7 @@ pub fn map_crossterm_event_to_input_event(event: Event) -> Option { MouseEventKind::Up(crossterm::event::MouseButton::Left) => { Some(InputEvent::MouseDragEnd(me.column, me.row)) } + MouseEventKind::Moved => Some(InputEvent::MouseMove(me.column, me.row)), _ => None, }, Event::Resize(w, h) => Some(InputEvent::Resized(w, h)), diff --git a/tui/src/services/handlers/mod.rs b/tui/src/services/handlers/mod.rs index 5bc36d8c..e4f659e6 100644 --- a/tui/src/services/handlers/mod.rs +++ b/tui/src/services/handlers/mod.rs @@ -63,6 +63,40 @@ pub fn update( state.scroll = state.scroll.max(0); + // Intercept keys for Message Action Popup + if state.show_message_action_popup { + match event { + InputEvent::HandleEsc => { + popup::handle_message_action_popup_close(state); + return; + } + InputEvent::Up | InputEvent::ScrollUp => { + popup::handle_message_action_popup_navigate(state, -1); + return; + } + InputEvent::Down | InputEvent::ScrollDown => { + popup::handle_message_action_popup_navigate(state, 1); + return; + } + InputEvent::InputSubmitted => { + popup::handle_message_action_popup_execute(state); + return; + } + InputEvent::MouseClick(_, _) + | InputEvent::MouseDragStart(_, _) + | InputEvent::MouseDrag(_, _) + | InputEvent::MouseDragEnd(_, _) => { + // Close popup on any mouse click/interaction + popup::handle_message_action_popup_close(state); + return; + } + _ => { + // Consume other events to prevent side effects + return; + } + } + } + // Intercept keys for File Changes Popup if state.show_file_changes_popup { match event { @@ -764,6 +798,10 @@ pub fn update( InputEvent::MouseDragEnd(col, row) => { text_selection::handle_drag_end(state, col, row, message_area_height); } + InputEvent::MouseMove(_col, row) => { + // Track hover row for visual debugging + state.hover_row = Some(row); + } } navigation::adjust_scroll(state, message_area_height, message_area_width); diff --git a/tui/src/services/handlers/popup.rs b/tui/src/services/handlers/popup.rs index 49134a76..9681e510 100644 --- a/tui/src/services/handlers/popup.rs +++ b/tui/src/services/handlers/popup.rs @@ -830,3 +830,131 @@ pub fn handle_file_changes_popup_mouse_click(state: &mut AppState, col: u16, row } } } + +// ========== Message Action Popup Handlers ========== + +/// Close the message action popup +pub fn handle_message_action_popup_close(state: &mut AppState) { + state.show_message_action_popup = false; + state.message_action_popup_selected = 0; + state.message_action_popup_position = None; + state.message_action_target_message_id = None; + state.message_action_target_text = None; +} + +/// Navigate within the message action popup +pub fn handle_message_action_popup_navigate(state: &mut AppState, direction: i32) { + let num_actions = crate::services::message_action_popup::MessageAction::all().len(); + if num_actions == 0 { + return; + } + + if direction < 0 { + if state.message_action_popup_selected > 0 { + state.message_action_popup_selected -= 1; + } else { + state.message_action_popup_selected = num_actions - 1; + } + } else { + state.message_action_popup_selected = + (state.message_action_popup_selected + 1) % num_actions; + } +} + +/// Execute the selected action in the message action popup +pub fn handle_message_action_popup_execute(state: &mut AppState) { + use crate::services::message_action_popup::{MessageAction, get_selected_action}; + use crate::services::text_selection::copy_to_clipboard; + use crate::services::toast::Toast; + + let Some(action) = get_selected_action(state) else { + handle_message_action_popup_close(state); + return; + }; + + match action { + MessageAction::CopyMessage => { + // Copy the message text to clipboard + if let Some(text) = &state.message_action_target_text { + match copy_to_clipboard(text) { + Ok(()) => { + state.toast = Some(Toast::success("Copied!")); + } + Err(e) => { + log::warn!("Failed to copy to clipboard: {}", e); + state.toast = Some(Toast::error("Copy failed")); + } + } + } + } + MessageAction::RevertToMessage => { + // Revert: remove all messages after the target message and revert file changes + if let Some(target_id) = state.message_action_target_message_id { + // Find the index of the target message + if let Some(target_idx) = state.messages.iter().position(|m| m.id == target_id) { + // Remove all messages after the target message + state.messages.truncate(target_idx + 1); + + // Revert all file changes + use crate::services::changeset::FileState; + + // Clone the files to avoid borrow issues + let files_to_revert: Vec<_> = state + .changeset + .files_in_order() + .into_iter() + .filter(|f| { + f.state != FileState::Reverted + && (f.state != FileState::Deleted || f.backup_path.is_some()) + }) + .map(|f| (f.path.clone(), f.state, f.clone())) + .collect(); + + let mut reverted_count = 0; + for (path, old_state, file) in files_to_revert { + if crate::services::changeset::Changeset::revert_file( + &file, + &state.session_id, + ) + .is_ok() + { + // Update state based on what happened + if let Some(tracked) = state.changeset.files.get_mut(&path) { + if !std::path::Path::new(&path).exists() { + tracked.state = FileState::Deleted; + } else { + match old_state { + FileState::Deleted => tracked.state = FileState::Created, + FileState::Removed => tracked.state = FileState::Modified, + FileState::Created => tracked.state = FileState::Deleted, + _ => tracked.state = FileState::Reverted, + } + } + } + reverted_count += 1; + } + } + + // Clear todos + state.todos.clear(); + + // Invalidate message cache + invalidate_message_lines_cache(state); + + // Show success message + let message = if reverted_count > 0 { + format!( + "Reverted to message and undid {} file changes", + reverted_count + ) + } else { + "Reverted to message".to_string() + }; + state.toast = Some(Toast::success(&message)); + } + } + } + } + + handle_message_action_popup_close(state); +} diff --git a/tui/src/services/handlers/text_selection.rs b/tui/src/services/handlers/text_selection.rs index b6f7e159..8cda9778 100644 --- a/tui/src/services/handlers/text_selection.rs +++ b/tui/src/services/handlers/text_selection.rs @@ -6,8 +6,10 @@ //! - Ending selection and copying to clipboard on mouse release //! - Extracting clean text (excluding borders, decorations) //! - Cursor positioning in input area on click +//! - Showing message action popup on user message click use crate::app::AppState; +use crate::services::message_action_popup::find_user_message_at_line; use crate::services::text_selection::{SelectionState, copy_to_clipboard, extract_selected_text}; use crate::services::toast::Toast; @@ -41,9 +43,10 @@ pub fn handle_drag_start(state: &mut AppState, col: u16, row: u16, message_area_ // Clear any input area selection when clicking outside state.text_area.clear_selection(); - // Check if click is within message area (top portion of screen) - // Message area starts at row 0 and extends to message_area_height - if row as usize >= message_area_height { + // Check if click is within message area + // Message area starts at message_area_y and extends for message_area_height rows + let row_in_message_area = (row as usize).saturating_sub(state.message_area_y as usize); + if row < state.message_area_y || row_in_message_area >= message_area_height { // Click is outside message area, don't start selection state.selection = SelectionState::default(); return; @@ -64,8 +67,8 @@ pub fn handle_drag_start(state: &mut AppState, col: u16, row: u16, message_area_ } } - // Convert screen row to absolute line index - let absolute_line = state.scroll + row as usize; + // Convert screen row to absolute line index (row_in_message_area already calculated above) + let absolute_line = state.scroll + row_in_message_area; state.selection = SelectionState { active: true, @@ -94,7 +97,9 @@ pub fn handle_drag(state: &mut AppState, col: u16, row: u16, message_area_height } // Clamp row to message area - let clamped_row = (row as usize).min(message_area_height.saturating_sub(1)); + // Mouse row is absolute to terminal, so subtract message_area_y to get row relative to message area + let row_in_message_area = (row as usize).saturating_sub(state.message_area_y as usize); + let clamped_row = row_in_message_area.min(message_area_height.saturating_sub(1)); // Convert screen row to absolute line index let absolute_line = state.scroll + clamped_row; @@ -116,6 +121,7 @@ pub fn handle_drag(state: &mut AppState, col: u16, row: u16, message_area_height } /// Handle mouse drag end - extracts text, copies to clipboard, shows toast +/// Also detects clicks on user messages to show action popup pub fn handle_drag_end(state: &mut AppState, col: u16, row: u16, message_area_height: usize) { // Check if we're ending an input area selection if state.text_area.selection.is_active() { @@ -156,8 +162,24 @@ pub fn handle_drag_end(state: &mut AppState, col: u16, row: u16, message_area_he }; if is_just_click { - // Just a click, not a selection - clear and return + // Just a click, not a selection - check if it's on a user message + // Mouse row is absolute to terminal, so subtract message_area_y to get row relative to message area + let row_in_message_area = (row as usize).saturating_sub(state.message_area_y as usize); + let absolute_line = state.scroll + row_in_message_area; + + // Clear selection first state.selection = SelectionState::default(); + + // Check if clicking on a user message + if let Some((msg_id, msg_text)) = find_user_message_at_line(state, absolute_line) { + // Show message action popup + state.show_message_action_popup = true; + state.message_action_popup_selected = 0; + state.message_action_popup_position = Some((col, row)); + state.message_action_target_message_id = Some(msg_id); + state.message_action_target_text = Some(msg_text); + } + return; } diff --git a/tui/src/services/message.rs b/tui/src/services/message.rs index 43f8462f..0b7b32cd 100644 --- a/tui/src/services/message.rs +++ b/tui/src/services/message.rs @@ -1,4 +1,3 @@ -use crate::AppState; use crate::app::RenderedMessageCache; use crate::services::bash_block::{ format_text_content, render_bash_block, render_collapsed_command_message, render_file_diff, @@ -7,6 +6,7 @@ use crate::services::bash_block::{ use crate::services::detect_term::AdaptiveColors; use crate::services::markdown_renderer::render_markdown_to_lines_with_width; use crate::services::shell_mode::SHELL_PROMPT_PREFIX; +use crate::AppState; use ratatui::style::Color; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; @@ -309,7 +309,7 @@ fn strip_context_blocks(text: &str) -> String { /// Render user message with cyan bar prefix and proper word wrapping fn render_user_message_lines(text: &str, width: usize) -> Vec<(Line<'static>, Style)> { use ratatui::text::{Line, Span}; - use textwrap::{Options, wrap}; + use textwrap::{wrap, Options}; let mut lines = Vec::new(); let accent_color = Color::DarkGray; @@ -922,9 +922,19 @@ pub fn get_wrapped_message_lines_cached(state: &mut AppState, width: usize) -> V let estimated_lines = message_refs.len() * 10; // Rough estimate of 10 lines per message let mut all_processed_lines: Vec> = Vec::with_capacity(estimated_lines); + // Build line-to-message mapping for click detection + let mut line_to_message_map: Vec<(usize, usize, Uuid, bool, String)> = Vec::new(); + // Process each message, using cache when available for msg in &message_refs { let content_hash = hash_message_content(&msg.content); + let start_line = all_processed_lines.len(); + + // Check if this is a user message and extract text + let (is_user_message, message_text) = match &msg.content { + MessageContent::UserMessage(text) => (true, text.clone()), + _ => (false, String::new()), + }; // Check if we have a valid cached render for this message if let Some(cached) = state.per_message_cache.get(&msg.id) @@ -934,44 +944,81 @@ pub fn get_wrapped_message_lines_cached(state: &mut AppState, width: usize) -> V // Cache hit! Reuse rendered lines cache_hits += 1; all_processed_lines.extend(cached.rendered_lines.iter().cloned()); - continue; + } else { + // Cache miss - render this single message + cache_misses += 1; + let rendered_lines = render_single_message(msg, width); + + // Store in per-message cache + state.per_message_cache.insert( + msg.id, + RenderedMessageCache { + content_hash, + rendered_lines: Arc::new(rendered_lines.clone()), + width, + }, + ); + + all_processed_lines.extend(rendered_lines); } - // Cache miss - render this single message - cache_misses += 1; - let rendered_lines = render_single_message(msg, width); + let end_line = all_processed_lines.len(); - // Store in per-message cache - state.per_message_cache.insert( - msg.id, - RenderedMessageCache { - content_hash, - rendered_lines: Arc::new(rendered_lines.clone()), - width, - }, - ); - - all_processed_lines.extend(rendered_lines); + // Only track user messages in the map (for efficiency) + if is_user_message && end_line > start_line { + line_to_message_map.push((start_line, end_line, msg.id, true, message_text)); + } } // Collapse consecutive empty lines (max 2 consecutive empty lines) + // Also build a mapping from old line index to new line index let mut collapsed_lines: Vec> = Vec::with_capacity(all_processed_lines.len()); + let mut old_to_new_index: Vec> = Vec::with_capacity(all_processed_lines.len()); let mut consecutive_empty = 0; + for line in all_processed_lines { let is_empty = line.spans.is_empty() || (line.spans.len() == 1 && line.spans[0].content.trim().is_empty()); if is_empty { consecutive_empty += 1; if consecutive_empty <= 2 { + old_to_new_index.push(Some(collapsed_lines.len())); collapsed_lines.push(line); + } else { + old_to_new_index.push(None); // This line was removed } } else { consecutive_empty = 0; + old_to_new_index.push(Some(collapsed_lines.len())); collapsed_lines.push(line); } } let mut all_processed_lines = collapsed_lines; + // Adjust line_to_message_map indices based on collapsed lines + let adjusted_line_to_message_map: Vec<(usize, usize, Uuid, bool, String)> = line_to_message_map + .into_iter() + .filter_map(|(start, end, id, is_user, text)| { + // Find the new start index (first non-None mapping at or after old start) + let new_start = + (start..end).find_map(|i| old_to_new_index.get(i).and_then(|&idx| idx))?; + + // Find the new end index (last non-None mapping before old end, +1) + let new_end = (start..end) + .rev() + .find_map(|i| old_to_new_index.get(i).and_then(|&idx| idx)) + .map(|i| i + 1)?; + + if new_end > new_start { + Some((new_start, new_end, id, is_user, text)) + } else { + None + } + }) + .collect(); + + let line_to_message_map = adjusted_line_to_message_map; + // Add trailing empty lines if we have content if !all_processed_lines.is_empty() { all_processed_lines.push(Line::from("")); @@ -989,6 +1036,9 @@ pub fn get_wrapped_message_lines_cached(state: &mut AppState, width: usize) -> V state.visible_lines_cache = None; state.last_render_width = width; + // Update line-to-message map for click detection + state.line_to_message_map = line_to_message_map.clone(); + // Record performance metrics let render_time_us = render_start.elapsed().as_micros() as u64; state.render_metrics.record_render( diff --git a/tui/src/services/message_action_popup.rs b/tui/src/services/message_action_popup.rs new file mode 100644 index 00000000..637ab7e3 --- /dev/null +++ b/tui/src/services/message_action_popup.rs @@ -0,0 +1,151 @@ +//! Message Action Popup +//! +//! A popup that appears when left-clicking on a user message. +//! Provides actions like copying the message text or reverting to that point. + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; +use uuid::Uuid; + +use crate::app::AppState; + +/// The menu items in the message action popup +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageAction { + CopyMessage, + RevertToMessage, +} + +impl MessageAction { + pub fn all() -> Vec { + vec![Self::CopyMessage, Self::RevertToMessage] + } +} + +/// Render the message action popup - centered on screen like file_changes_popup +pub fn render_message_action_popup(f: &mut Frame, state: &AppState) { + if !state.show_message_action_popup { + return; + } + + // Calculate popup size - centered, max width 50, height for 2 items + title + padding + let popup_width: u16 = 50; + let popup_height: u16 = 7; // Title + 2 items + borders + padding + + let terminal_area = f.area(); + let x = (terminal_area.width.saturating_sub(popup_width)) / 2; + let y = (terminal_area.height.saturating_sub(popup_height)) / 2; + + let area = Rect::new(x, y, popup_width, popup_height); + + // Clear the area behind the popup + f.render_widget(Clear, area); + + // Create the main block with border (Cyan like file_changes_popup) + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + f.render_widget(block, area); + + // Inner area (inside borders) + let inner_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width - 2, + height: area.height - 2, + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Title + Constraint::Length(1), // Spacing + Constraint::Min(2), // Items + ]) + .split(inner_area); + + // Render title - Yellow Bold like file_changes_popup + let title = Paragraph::new(Line::from(vec![Span::styled( + " Message Action", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])); + f.render_widget(title, chunks[0]); + + // Render menu items + let actions = MessageAction::all(); + let mut item_lines: Vec = Vec::new(); + + for (idx, action) in actions.iter().enumerate() { + let is_selected = idx == state.message_action_popup_selected; + + let (highlight_word, rest_text) = match action { + MessageAction::CopyMessage => ("Copy", " message text to clipboard"), + MessageAction::RevertToMessage => ("Revert", " undo messages and file changes"), + }; + + // Full width background for selected item + let available_width = (inner_area.width as usize).saturating_sub(2); + let text_len = 2 + highlight_word.len() + rest_text.len(); // " " prefix + text + let padding = available_width.saturating_sub(text_len); + + let line = if is_selected { + Line::from(vec![ + Span::styled(" ", Style::default().bg(Color::Cyan).fg(Color::Black)), + Span::styled( + highlight_word, + Style::default() + .bg(Color::Cyan) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ), + Span::styled(rest_text, Style::default().bg(Color::Cyan).fg(Color::Black)), + Span::styled( + " ".repeat(padding), + Style::default().bg(Color::Cyan).fg(Color::Black), + ), + ]) + } else { + Line::from(vec![ + Span::raw(" "), + Span::styled( + highlight_word, + Style::default().fg(Color::Reset), // Reset color for highlight word + ), + Span::styled(rest_text, Style::default().fg(Color::DarkGray)), + ]) + }; + + item_lines.push(line); + } + + let items = Paragraph::new(item_lines); + f.render_widget(items, chunks[2]); +} + +/// Get the currently selected action +pub fn get_selected_action(state: &AppState) -> Option { + let actions = MessageAction::all(); + actions.get(state.message_action_popup_selected).copied() +} + +/// Find the user message at a given absolute line index +/// Returns (message_id, message_text) if found +/// Uses the line_to_message_map that was built during rendering +pub fn find_user_message_at_line(state: &AppState, absolute_line: usize) -> Option<(Uuid, String)> { + // Search through the line-to-message map to find which user message contains this line + for (start_line, end_line, msg_id, is_user, text) in &state.line_to_message_map { + if *is_user && absolute_line >= *start_line && absolute_line < *end_line { + return Some((*msg_id, text.clone())); + } + } + + None +} diff --git a/tui/src/services/mod.rs b/tui/src/services/mod.rs index 3acee944..885a3318 100644 --- a/tui/src/services/mod.rs +++ b/tui/src/services/mod.rs @@ -16,6 +16,7 @@ pub mod hint_helper; pub mod image_upload; pub mod markdown_renderer; pub mod message; +pub mod message_action_popup; pub mod message_pattern; pub mod placeholder_prompts; pub mod profile_switcher; diff --git a/tui/src/view.rs b/tui/src/view.rs index 6e6e5d35..c9d8f684 100644 --- a/tui/src/view.rs +++ b/tui/src/view.rs @@ -11,11 +11,11 @@ use crate::services::message_pattern::spans_to_string; use crate::services::shell_popup; use crate::services::side_panel; use ratatui::{ - Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::Line, widgets::{Block, Borders, Paragraph}, + Frame, }; pub fn view(f: &mut Frame, state: &mut AppState) { @@ -142,6 +142,9 @@ pub fn view(f: &mut Frame, state: &mut AppState) { let message_area_width = padded_message_area.width as usize; let message_area_height = message_area.height as usize; + // Store message area y offset for click detection + state.message_area_y = message_area.y; + render_messages( f, state, @@ -223,6 +226,11 @@ pub fn view(f: &mut Frame, state: &mut AppState) { crate::services::rulebook_switcher::render_rulebook_switcher_popup(f, state); } + // Render message action popup + if state.show_message_action_popup { + crate::services::message_action_popup::render_message_action_popup(f, state); + } + // Render profile switch overlay if state.profile_switching_in_progress { crate::services::profile_switcher::render_profile_switch_overlay(f, state); @@ -330,6 +338,12 @@ fn render_messages(f: &mut Frame, state: &mut AppState, area: Rect, width: usize state.scroll.min(max_scroll) }; + // Debug: show area position + eprintln!( + "[RenderDebug] area.x={}, area.y={}, area.width={}, area.height={}, scroll={}", + area.x, area.y, area.width, area.height, scroll + ); + // Create visible lines with pre-allocated capacity for better performance let mut visible_lines = Vec::with_capacity(height); @@ -342,6 +356,57 @@ fn render_messages(f: &mut Frame, state: &mut AppState, area: Rect, width: usize } } + // Apply hover highlighting for user messages (debug feature) + let visible_lines = if let Some(hover_row) = state.hover_row { + // Subtract 2 to account for visual offset (investigating root cause) + let hover_row = (hover_row as usize).saturating_sub(2); + // Check if hover is within message area + if hover_row >= area.y as usize && hover_row < (area.y as usize + height) { + let screen_row = hover_row - area.y as usize; + let absolute_line = scroll + screen_row; + + // Check if this line is a user message + let is_user_message = + state + .line_to_message_map + .iter() + .any(|(start, end, _, is_user, _)| { + *is_user && absolute_line >= *start && absolute_line < *end + }); + + if is_user_message { + // Highlight this line with green background + visible_lines + .into_iter() + .enumerate() + .map(|(i, line)| { + if i == screen_row { + Line::from( + line.spans + .into_iter() + .map(|span| { + ratatui::text::Span::styled( + span.content, + span.style.bg(Color::Green), + ) + }) + .collect::>(), + ) + } else { + line + } + }) + .collect() + } else { + visible_lines + } + } else { + visible_lines + } + } else { + visible_lines + }; + // Apply selection highlighting if active let visible_lines = if state.selection.active { crate::services::text_selection::apply_selection_highlight( From c21ea41b070c51afb92984cf40f132b86900d590 Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Sun, 25 Jan 2026 22:00:31 +0200 Subject: [PATCH 4/5] feat: Improve message line caching with a robust hash-based key and refine mouse interaction row calculations for selection and hover highlighting. --- tui/src/app.rs | 4 +- tui/src/services/handlers/text_selection.rs | 21 +++++-- tui/src/services/message.rs | 69 +++++++++++++-------- tui/src/view.rs | 28 ++++++--- 4 files changed, 81 insertions(+), 41 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index 26264c1c..92310ae0 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -69,8 +69,8 @@ pub struct AppState { /// Per-message rendered line cache for efficient incremental rendering pub per_message_cache: PerMessageCache, /// Assembled lines cache (the final combined output of all message lines) - /// Format: (cache_key, lines, generation_counter) - pub assembled_lines_cache: Option<(usize, Vec>, u64)>, + /// Format: (cache_key_hash, lines, generation_counter) + pub assembled_lines_cache: Option<(u64, Vec>, u64)>, /// Cache for visible lines on screen (avoids cloning on every frame) pub visible_lines_cache: Option, /// Generation counter for assembled cache (increments on each rebuild) diff --git a/tui/src/services/handlers/text_selection.rs b/tui/src/services/handlers/text_selection.rs index 8cda9778..ebe222f3 100644 --- a/tui/src/services/handlers/text_selection.rs +++ b/tui/src/services/handlers/text_selection.rs @@ -10,7 +10,7 @@ use crate::app::AppState; use crate::services::message_action_popup::find_user_message_at_line; -use crate::services::text_selection::{SelectionState, copy_to_clipboard, extract_selected_text}; +use crate::services::text_selection::{copy_to_clipboard, extract_selected_text, SelectionState}; use crate::services::toast::Toast; /// Check if coordinates are within the input area @@ -45,7 +45,9 @@ pub fn handle_drag_start(state: &mut AppState, col: u16, row: u16, message_area_ // Check if click is within message area // Message area starts at message_area_y and extends for message_area_height rows - let row_in_message_area = (row as usize).saturating_sub(state.message_area_y as usize); + // Subtract 1 to account for visual offset (same fix as hover highlighting in view.rs) + let row_adjusted = (row as usize).saturating_sub(1); + let row_in_message_area = row_adjusted.saturating_sub(state.message_area_y as usize); if row < state.message_area_y || row_in_message_area >= message_area_height { // Click is outside message area, don't start selection state.selection = SelectionState::default(); @@ -98,7 +100,9 @@ pub fn handle_drag(state: &mut AppState, col: u16, row: u16, message_area_height // Clamp row to message area // Mouse row is absolute to terminal, so subtract message_area_y to get row relative to message area - let row_in_message_area = (row as usize).saturating_sub(state.message_area_y as usize); + // Subtract 1 to account for visual offset (same fix as hover highlighting in view.rs) + let row_adjusted = (row as usize).saturating_sub(1); + let row_in_message_area = row_adjusted.saturating_sub(state.message_area_y as usize); let clamped_row = row_in_message_area.min(message_area_height.saturating_sub(1)); // Convert screen row to absolute line index @@ -164,9 +168,18 @@ pub fn handle_drag_end(state: &mut AppState, col: u16, row: u16, message_area_he if is_just_click { // Just a click, not a selection - check if it's on a user message // Mouse row is absolute to terminal, so subtract message_area_y to get row relative to message area - let row_in_message_area = (row as usize).saturating_sub(state.message_area_y as usize); + // Subtract 1 to account for visual offset (same fix as hover highlighting in view.rs) + let row_adjusted = (row as usize).saturating_sub(1); + let row_in_message_area = row_adjusted.saturating_sub(state.message_area_y as usize); let absolute_line = state.scroll + row_in_message_area; + // Debug logging for click + eprintln!( + "[ClickDebug] row={}, adjusted={}, msg_area_y={}, row_in_area={}, scroll={}, absolute_line={}, map={:?}", + row, row_adjusted, state.message_area_y, row_in_message_area, state.scroll, absolute_line, + state.line_to_message_map.iter().map(|(s, e, _, _, _)| (*s, *e)).collect::>() + ); + // Clear selection first state.selection = SelectionState::default(); diff --git a/tui/src/services/message.rs b/tui/src/services/message.rs index 0b7b32cd..160e7b9f 100644 --- a/tui/src/services/message.rs +++ b/tui/src/services/message.rs @@ -738,18 +738,48 @@ pub fn get_wrapped_message_lines( get_wrapped_message_lines_internal(messages, width, false) } +/// Compute a cache key that uniquely identifies the current message state. +/// This key changes when: +/// - Width changes +/// - Shell popup visibility changes +/// - Side panel visibility changes +/// - Messages are added, removed, or resumed (via message count and last message ID) +fn compute_cache_key(state: &AppState, width: usize) -> u64 { + let mut hasher = DefaultHasher::new(); + + // Include width + width.hash(&mut hasher); + + // Include visibility states + state.shell_popup_visible.hash(&mut hasher); + state.show_side_panel.hash(&mut hasher); + + // Include message count (filters out collapsed messages) + let visible_messages: Vec<&Message> = state + .messages + .iter() + .filter(|m| m.is_collapsed.is_none()) + .collect(); + visible_messages.len().hash(&mut hasher); + + // Include last message ID to detect content changes at the end (streaming) + if let Some(last_msg) = visible_messages.last() { + last_msg.id.hash(&mut hasher); + } + + // Include first message ID to detect changes at the beginning (resume) + if let Some(first_msg) = visible_messages.first() { + first_msg.id.hash(&mut hasher); + } + + hasher.finish() +} + /// Get the total number of cached lines without cloning. /// This is useful for scroll calculations where we only need the count. #[allow(dead_code)] pub fn get_cached_line_count(state: &AppState, width: usize) -> Option { - // Use consistent cache key calculation with get_wrapped_message_lines_cached - let mut cache_key = width; - if state.shell_popup_visible { - cache_key += 100000; - } - if state.show_side_panel { - cache_key += 200000; - } + let cache_key = compute_cache_key(state, width); if let Some((cached_key, ref cached_lines, _)) = state.assembled_lines_cache && cached_key == cache_key @@ -849,11 +879,7 @@ pub fn get_visible_lines_owned( /// This is more efficient when you just need to ensure the cache exists. #[allow(dead_code)] fn ensure_cache_populated(state: &mut AppState, width: usize) { - let cache_key = if state.shell_popup_visible { - width + 100000 - } else { - width - }; + let cache_key = compute_cache_key(state, width); if let Some((cached_key, _, _)) = &state.assembled_lines_cache && *cached_key == cache_key @@ -876,19 +902,10 @@ fn ensure_cache_populated(state: &mut AppState, width: usize) { /// NOTE: Prefer using `get_visible_lines_cached` when you only need a slice, /// as it avoids cloning the entire vector. pub fn get_wrapped_message_lines_cached(state: &mut AppState, width: usize) -> Vec> { - // FAST PATH: If assembled cache exists and width matches, return it immediately. - // The cache is explicitly invalidated when messages change, so if it exists, it's valid. - // We encode visibility states in the cache key to ensure cache invalidation when they change: - // - shell_popup_visible: adds 100000 - // - show_side_panel: adds 200000 - // This ensures the cache is invalidated when these visibility states change. - let mut cache_key = width; - if state.shell_popup_visible { - cache_key += 100000; - } - if state.show_side_panel { - cache_key += 200000; - } + // FAST PATH: If assembled cache exists and key matches, return it immediately. + // The cache key is a hash that includes width, visibility states, message count, + // and first/last message IDs to detect changes from resume, streaming, etc. + let cache_key = compute_cache_key(state, width); if let Some((cached_key, cached_lines, _)) = &state.assembled_lines_cache && *cached_key == cache_key diff --git a/tui/src/view.rs b/tui/src/view.rs index c9d8f684..d9fa5a5d 100644 --- a/tui/src/view.rs +++ b/tui/src/view.rs @@ -356,14 +356,24 @@ fn render_messages(f: &mut Frame, state: &mut AppState, area: Rect, width: usize } } - // Apply hover highlighting for user messages (debug feature) + // Apply hover highlighting for user messages + // Use state.scroll (not local scroll) to match selection handler behavior let visible_lines = if let Some(hover_row) = state.hover_row { - // Subtract 2 to account for visual offset (investigating root cause) - let hover_row = (hover_row as usize).saturating_sub(2); + // Subtract 1 to account for visual offset (same as selection click handling) + let hover_row_adjusted = (hover_row as usize).saturating_sub(1); + let row_in_message_area = hover_row_adjusted.saturating_sub(state.message_area_y as usize); + // Check if hover is within message area - if hover_row >= area.y as usize && hover_row < (area.y as usize + height) { - let screen_row = hover_row - area.y as usize; - let absolute_line = scroll + screen_row; + if row_in_message_area < height { + // Use state.scroll to match how selection calculates absolute_line + let absolute_line = state.scroll + row_in_message_area; + + // Debug logging for hover + eprintln!( + "[HoverDebug] hover_row={}, adjusted={}, msg_area_y={}, row_in_area={}, state.scroll={}, render_scroll={}, absolute_line={}, map={:?}", + hover_row, hover_row_adjusted, state.message_area_y, row_in_message_area, state.scroll, scroll, absolute_line, + state.line_to_message_map.iter().map(|(s, e, _, _, _)| (*s, *e)).collect::>() + ); // Check if this line is a user message let is_user_message = @@ -375,19 +385,19 @@ fn render_messages(f: &mut Frame, state: &mut AppState, area: Rect, width: usize }); if is_user_message { - // Highlight this line with green background + // Highlight this line with subtle dark background visible_lines .into_iter() .enumerate() .map(|(i, line)| { - if i == screen_row { + if i == row_in_message_area { Line::from( line.spans .into_iter() .map(|span| { ratatui::text::Span::styled( span.content, - span.style.bg(Color::Green), + span.style.bg(Color::Indexed(240)).fg(Color::White), ) }) .collect::>(), From 54058e0ef9edd6413a062b95c931bcbf0c4f103e Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Tue, 27 Jan 2026 11:58:12 +0200 Subject: [PATCH 5/5] refactor: Reformat debug logs for readability and reorder import statements. --- tui/src/services/handlers/text_selection.rs | 15 ++++++++++++--- tui/src/services/message.rs | 4 ++-- tui/src/view.rs | 16 +++++++++++++--- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/tui/src/services/handlers/text_selection.rs b/tui/src/services/handlers/text_selection.rs index ebe222f3..a31acecd 100644 --- a/tui/src/services/handlers/text_selection.rs +++ b/tui/src/services/handlers/text_selection.rs @@ -10,7 +10,7 @@ use crate::app::AppState; use crate::services::message_action_popup::find_user_message_at_line; -use crate::services::text_selection::{copy_to_clipboard, extract_selected_text, SelectionState}; +use crate::services::text_selection::{SelectionState, copy_to_clipboard, extract_selected_text}; use crate::services::toast::Toast; /// Check if coordinates are within the input area @@ -176,8 +176,17 @@ pub fn handle_drag_end(state: &mut AppState, col: u16, row: u16, message_area_he // Debug logging for click eprintln!( "[ClickDebug] row={}, adjusted={}, msg_area_y={}, row_in_area={}, scroll={}, absolute_line={}, map={:?}", - row, row_adjusted, state.message_area_y, row_in_message_area, state.scroll, absolute_line, - state.line_to_message_map.iter().map(|(s, e, _, _, _)| (*s, *e)).collect::>() + row, + row_adjusted, + state.message_area_y, + row_in_message_area, + state.scroll, + absolute_line, + state + .line_to_message_map + .iter() + .map(|(s, e, _, _, _)| (*s, *e)) + .collect::>() ); // Clear selection first diff --git a/tui/src/services/message.rs b/tui/src/services/message.rs index 160e7b9f..02dd9c1a 100644 --- a/tui/src/services/message.rs +++ b/tui/src/services/message.rs @@ -1,3 +1,4 @@ +use crate::AppState; use crate::app::RenderedMessageCache; use crate::services::bash_block::{ format_text_content, render_bash_block, render_collapsed_command_message, render_file_diff, @@ -6,7 +7,6 @@ use crate::services::bash_block::{ use crate::services::detect_term::AdaptiveColors; use crate::services::markdown_renderer::render_markdown_to_lines_with_width; use crate::services::shell_mode::SHELL_PROMPT_PREFIX; -use crate::AppState; use ratatui::style::Color; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; @@ -309,7 +309,7 @@ fn strip_context_blocks(text: &str) -> String { /// Render user message with cyan bar prefix and proper word wrapping fn render_user_message_lines(text: &str, width: usize) -> Vec<(Line<'static>, Style)> { use ratatui::text::{Line, Span}; - use textwrap::{wrap, Options}; + use textwrap::{Options, wrap}; let mut lines = Vec::new(); let accent_color = Color::DarkGray; diff --git a/tui/src/view.rs b/tui/src/view.rs index d9fa5a5d..7b17ef98 100644 --- a/tui/src/view.rs +++ b/tui/src/view.rs @@ -11,11 +11,11 @@ use crate::services::message_pattern::spans_to_string; use crate::services::shell_popup; use crate::services::side_panel; use ratatui::{ + Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::Line, widgets::{Block, Borders, Paragraph}, - Frame, }; pub fn view(f: &mut Frame, state: &mut AppState) { @@ -371,8 +371,18 @@ fn render_messages(f: &mut Frame, state: &mut AppState, area: Rect, width: usize // Debug logging for hover eprintln!( "[HoverDebug] hover_row={}, adjusted={}, msg_area_y={}, row_in_area={}, state.scroll={}, render_scroll={}, absolute_line={}, map={:?}", - hover_row, hover_row_adjusted, state.message_area_y, row_in_message_area, state.scroll, scroll, absolute_line, - state.line_to_message_map.iter().map(|(s, e, _, _, _)| (*s, *e)).collect::>() + hover_row, + hover_row_adjusted, + state.message_area_y, + row_in_message_area, + state.scroll, + scroll, + absolute_line, + state + .line_to_message_map + .iter() + .map(|(s, e, _, _, _)| (*s, *e)) + .collect::>() ); // Check if this line is a user message