11use anyhow:: Result ;
22use crossterm:: {
3- event:: { EnableBracketedPaste , DisableBracketedPaste , Event as CrosstermEvent , EventStream , KeyboardEnhancementFlags , PushKeyboardEnhancementFlags , PopKeyboardEnhancementFlags } ,
3+ event:: {
4+ DisableBracketedPaste , DisableMouseCapture , EnableBracketedPaste , EnableMouseCapture ,
5+ Event as CrosstermEvent , EventStream , KeyboardEnhancementFlags ,
6+ MouseButton , MouseEvent , MouseEventKind , PopKeyboardEnhancementFlags ,
7+ PushKeyboardEnhancementFlags ,
8+ } ,
49 execute,
510 terminal:: { disable_raw_mode, enable_raw_mode, EnterAlternateScreen , LeaveAlternateScreen } ,
611} ;
712use futures:: StreamExt ;
8- use ratatui:: { backend:: CrosstermBackend , Terminal } ;
13+ use ratatui:: { backend:: CrosstermBackend , layout :: Position , Terminal } ;
914use std:: io;
1015use std:: path:: PathBuf ;
1116use std:: time:: Duration ;
@@ -92,7 +97,7 @@ impl TuiApp {
9297 // Setup terminal
9398 enable_raw_mode ( ) ?;
9499 let mut stdout = io:: stdout ( ) ;
95- execute ! ( stdout, EnterAlternateScreen , EnableBracketedPaste ) ?;
100+ execute ! ( stdout, EnterAlternateScreen , EnableBracketedPaste , EnableMouseCapture ) ?;
96101 // Try to enable keyboard enhancement (for Shift+Enter support).
97102 // This is only supported by some terminals (Kitty, WezTerm, foot, etc.)
98103 // so we ignore failures.
@@ -116,7 +121,7 @@ impl TuiApp {
116121 let original_hook = std:: panic:: take_hook ( ) ;
117122 std:: panic:: set_hook ( Box :: new ( move |panic_info| {
118123 let _ = disable_raw_mode ( ) ;
119- let _ = execute ! ( io:: stdout( ) , PopKeyboardEnhancementFlags , DisableBracketedPaste , LeaveAlternateScreen ) ;
124+ let _ = execute ! ( io:: stdout( ) , PopKeyboardEnhancementFlags , DisableMouseCapture , DisableBracketedPaste , LeaveAlternateScreen ) ;
120125 original_hook ( panic_info) ;
121126 } ) ) ;
122127
@@ -151,23 +156,31 @@ impl TuiApp {
151156
152157 // Poll crossterm events (async)
153158 Some ( Ok ( crossterm_event) ) = crossterm_reader. next( ) => {
154- if let CrosstermEvent :: Key ( key) = crossterm_event {
155- match handle_key_event( key, & mut textarea, & mut self . state) {
156- TuiAction :: SendMessage ( text) => {
157- self . send_user_message( & text) ?;
159+ match crossterm_event {
160+ CrosstermEvent :: Key ( key) => {
161+ match handle_key_event( key, & mut textarea, & mut self . state) {
162+ TuiAction :: SendMessage ( text) => {
163+ self . send_user_message( & text) ?;
164+ }
165+ TuiAction :: Cancel => {
166+ self . chat_actor. cancel( ) ?;
167+ }
168+ TuiAction :: Quit => {
169+ self . state. should_quit = true ;
170+ }
171+ TuiAction :: None => { }
158172 }
159- TuiAction :: Cancel => {
160- self . chat_actor. cancel( ) ?;
161- }
162- TuiAction :: Quit => {
163- self . state. should_quit = true ;
164- }
165- TuiAction :: None => { }
166173 }
167- } else if let CrosstermEvent :: Paste ( text) = crossterm_event {
168- textarea. insert_str( & text) ;
169- } else if let CrosstermEvent :: Resize ( _, _) = crossterm_event {
170- // Terminal will re-render on next loop iteration
174+ CrosstermEvent :: Mouse ( mouse) => {
175+ self . handle_mouse_event( mouse) ;
176+ }
177+ CrosstermEvent :: Paste ( text) => {
178+ textarea. insert_str( & text) ;
179+ }
180+ CrosstermEvent :: Resize ( _, _) => {
181+ // Terminal will re-render on next loop iteration
182+ }
183+ _ => { }
171184 }
172185 }
173186
@@ -242,11 +255,144 @@ impl TuiApp {
242255 Ok ( ( ) )
243256 }
244257
258+ fn handle_mouse_event ( & mut self , mouse : MouseEvent ) {
259+ let col = mouse. column ;
260+ let row = mouse. row ;
261+ let in_chat = self
262+ . state
263+ . chat_area
264+ . contains ( Position { x : col, y : row } ) ;
265+
266+ match mouse. kind {
267+ MouseEventKind :: ScrollUp if in_chat => {
268+ self . state . scroll_up ( 3 ) ;
269+ }
270+ MouseEventKind :: ScrollDown if in_chat => {
271+ self . state . scroll_down ( 3 ) ;
272+ }
273+ MouseEventKind :: Down ( MouseButton :: Left ) => {
274+ if in_chat {
275+ self . state . selection . start = ( col, row) ;
276+ self . state . selection . end = ( col, row) ;
277+ self . state . selection . is_dragging = true ;
278+ self . state . selection . has_selection = false ;
279+ } else {
280+ self . state . selection . clear ( ) ;
281+ }
282+ }
283+ MouseEventKind :: Drag ( MouseButton :: Left ) if self . state . selection . is_dragging => {
284+ // Clamp to chat area bounds
285+ let clamped_col = col. clamp ( self . state . chat_area . x , self . state . chat_area . right ( ) . saturating_sub ( 1 ) ) ;
286+ let clamped_row = row. clamp ( self . state . chat_area . y , self . state . chat_area . bottom ( ) . saturating_sub ( 1 ) ) ;
287+ self . state . selection . end = ( clamped_col, clamped_row) ;
288+ self . state . selection . has_selection = true ;
289+ }
290+ MouseEventKind :: Up ( MouseButton :: Left ) => {
291+ if self . state . selection . has_selection {
292+ self . state . selection . is_dragging = false ;
293+ let text = self . extract_selected_text ( ) ;
294+ if !text. is_empty ( ) {
295+ Self :: copy_to_clipboard ( & text) ;
296+ }
297+ } else {
298+ self . state . selection . clear ( ) ;
299+ }
300+ }
301+ _ => { }
302+ }
303+ }
304+
305+ fn extract_selected_text ( & self ) -> String {
306+ let ( ( sx, sy) , ( ex, ey) ) = self . state . selection . normalized ( ) ;
307+ let area = self . state . chat_area ;
308+
309+ // Convert terminal-absolute coords to chat-area-relative
310+ let rel_sy = sy. saturating_sub ( area. y ) as usize ;
311+ let rel_ey = ey. saturating_sub ( area. y ) as usize ;
312+ let rel_sx = sx. saturating_sub ( area. x ) as usize ;
313+ let rel_ex = ex. saturating_sub ( area. x ) as usize ;
314+
315+ let mut lines = Vec :: new ( ) ;
316+ for row_idx in rel_sy..=rel_ey {
317+ if row_idx >= self . state . screen_text . len ( ) {
318+ break ;
319+ }
320+ let line = & self . state . screen_text [ row_idx] ;
321+ let start_col = if row_idx == rel_sy { rel_sx } else { 0 } ;
322+ let end_col = if row_idx == rel_ey {
323+ ( rel_ex + 1 ) . min ( line. len ( ) )
324+ } else {
325+ line. len ( )
326+ } ;
327+
328+ if start_col < line. len ( ) {
329+ let selected: String = line
330+ . chars ( )
331+ . skip ( start_col)
332+ . take ( end_col. saturating_sub ( start_col) )
333+ . collect ( ) ;
334+ lines. push ( selected. trim_end ( ) . to_string ( ) ) ;
335+ } else {
336+ lines. push ( String :: new ( ) ) ;
337+ }
338+ }
339+
340+ // Remove trailing empty lines
341+ while lines. last ( ) . map_or ( false , |l| l. is_empty ( ) ) {
342+ lines. pop ( ) ;
343+ }
344+
345+ lines. join ( "\n " )
346+ }
347+
348+ fn copy_to_clipboard ( text : & str ) {
349+ #[ cfg( target_os = "macos" ) ]
350+ {
351+ use std:: process:: { Command , Stdio } ;
352+ if let Ok ( mut child) = Command :: new ( "pbcopy" )
353+ . stdin ( Stdio :: piped ( ) )
354+ . spawn ( )
355+ {
356+ if let Some ( stdin) = child. stdin . as_mut ( ) {
357+ use std:: io:: Write ;
358+ let _ = stdin. write_all ( text. as_bytes ( ) ) ;
359+ }
360+ let _ = child. wait ( ) ;
361+ }
362+ }
363+ #[ cfg( target_os = "linux" ) ]
364+ {
365+ use std:: process:: { Command , Stdio } ;
366+ use std:: io:: Write ;
367+ // Try xclip first, fall back to xsel
368+ let result = Command :: new ( "xclip" )
369+ . args ( [ "-selection" , "clipboard" ] )
370+ . stdin ( Stdio :: piped ( ) )
371+ . spawn ( ) ;
372+ if let Ok ( mut child) = result {
373+ if let Some ( stdin) = child. stdin . as_mut ( ) {
374+ let _ = stdin. write_all ( text. as_bytes ( ) ) ;
375+ }
376+ let _ = child. wait ( ) ;
377+ } else if let Ok ( mut child) = Command :: new ( "xsel" )
378+ . args ( [ "--clipboard" , "--input" ] )
379+ . stdin ( Stdio :: piped ( ) )
380+ . spawn ( )
381+ {
382+ if let Some ( stdin) = child. stdin . as_mut ( ) {
383+ let _ = stdin. write_all ( text. as_bytes ( ) ) ;
384+ }
385+ let _ = child. wait ( ) ;
386+ }
387+ }
388+ }
389+
245390 fn restore_terminal ( & mut self ) -> Result < ( ) > {
246391 disable_raw_mode ( ) ?;
247392 execute ! (
248393 self . terminal. backend_mut( ) ,
249394 PopKeyboardEnhancementFlags ,
395+ DisableMouseCapture ,
250396 DisableBracketedPaste ,
251397 LeaveAlternateScreen
252398 ) ?;
@@ -261,6 +407,7 @@ impl Drop for TuiApp {
261407 let _ = execute ! (
262408 self . terminal. backend_mut( ) ,
263409 PopKeyboardEnhancementFlags ,
410+ DisableMouseCapture ,
264411 DisableBracketedPaste ,
265412 LeaveAlternateScreen
266413 ) ;
0 commit comments