@@ -339,6 +339,72 @@ fn handle_plugin_cta_mouse(app: &mut App, mouse: MouseEvent) -> Option<Vec<ViewE
339339
340340/// Handle mouse events within the composer area.
341341/// Returns true if the event was consumed.
342+
343+ /// Slash-autocomplete rows painted inside the composer. Click selects
344+ /// (second click on the same row applies, matching the command palette);
345+ /// wheel moves the highlight. Returns true when the event was consumed so
346+ /// the composer caret / draft-scroll path does not also handle it.
347+ fn handle_slash_autocomplete_mouse ( app : & mut App , mouse : MouseEvent ) -> bool {
348+ let hitboxes = app. viewport . last_slash_menu_hitboxes . borrow ( ) ;
349+ if hitboxes. is_empty ( ) {
350+ return false ;
351+ }
352+ let over_row = hitboxes. iter ( ) . find_map ( |( idx, rect) | {
353+ mouse_hits_rect ( mouse, Some ( * rect) ) . then_some ( * idx)
354+ } ) ;
355+ let over_menu = over_row. is_some ( )
356+ || hitboxes. iter ( ) . any ( |( _, rect) | {
357+ mouse. row >= rect. y && mouse. row < rect. y . saturating_add ( rect. height )
358+ && mouse. column >= rect. x
359+ && mouse. column < rect. x . saturating_add ( rect. width )
360+ } ) ;
361+ // Wheel over any painted slash row moves selection (mouse == keys).
362+ // Clicks only fire when the pointer is on a row rect.
363+ match mouse. kind {
364+ MouseEventKind :: ScrollUp if over_menu => {
365+ drop ( hitboxes) ;
366+ let entries = crate :: tui:: slash_menu:: visible_slash_menu_entries ( app, 128 ) ;
367+ if entries. is_empty ( ) {
368+ return false ;
369+ }
370+ crate :: tui:: composer_ui:: select_previous_slash_menu_entry ( app, entries. len ( ) ) ;
371+ app. needs_redraw = true ;
372+ true
373+ }
374+ MouseEventKind :: ScrollDown if over_menu => {
375+ drop ( hitboxes) ;
376+ let entries = crate :: tui:: slash_menu:: visible_slash_menu_entries ( app, 128 ) ;
377+ if entries. is_empty ( ) {
378+ return false ;
379+ }
380+ crate :: tui:: composer_ui:: select_next_slash_menu_entry ( app, entries. len ( ) ) ;
381+ app. needs_redraw = true ;
382+ true
383+ }
384+ MouseEventKind :: Down ( MouseButton :: Left ) => {
385+ let Some ( idx) = over_row else {
386+ return false ;
387+ } ;
388+ drop ( hitboxes) ;
389+ let entries = crate :: tui:: slash_menu:: visible_slash_menu_entries ( app, 128 ) ;
390+ if entries. is_empty ( ) || idx >= entries. len ( ) {
391+ return false ;
392+ }
393+ // Same as command palette: click the highlighted row to apply;
394+ // click another row to move the highlight (mouse == keys).
395+ if app. slash_menu_selected == idx {
396+ let _ = crate :: tui:: slash_menu:: apply_slash_menu_selection ( app, & entries, true ) ;
397+ } else {
398+ app. slash_menu_selected = idx;
399+ app. slash_menu_hidden = false ;
400+ }
401+ app. needs_redraw = true ;
402+ true
403+ }
404+ _ => false ,
405+ }
406+ }
407+
342408pub ( crate ) fn handle_composer_mouse ( app : & mut App , mouse : MouseEvent ) -> bool {
343409 // Use outer area for hit-testing (includes border).
344410 let Some ( area) = app. viewport . last_composer_area else {
@@ -351,6 +417,11 @@ pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool {
351417 {
352418 return false ;
353419 }
420+ // Slash autocomplete owns its painted rows before caret placement or
421+ // draft scroll — otherwise a click on `/model` would only move the caret.
422+ if handle_slash_autocomplete_mouse ( app, mouse) {
423+ return true ;
424+ }
354425 // Resolve the border- and submit-aware input plane through the same
355426 // persistent prompt geometry used by rendering, cursor placement, and
356427 // viewport bookkeeping. The frame records it after reserving `[↑]`.
@@ -1776,7 +1847,8 @@ pub(crate) fn selection_to_text(app: &App) -> Option<String> {
17761847#[ cfg( test) ]
17771848mod tests {
17781849 use super :: {
1779- agent_transcript_text, build_context_menu_entries, handle_mouse_event, sidebar_click_action,
1850+ agent_transcript_text, build_context_menu_entries, handle_composer_mouse,
1851+ handle_mouse_event, sidebar_click_action,
17801852 } ;
17811853 use crate :: config:: Config ;
17821854 use crate :: models:: Role ;
@@ -1901,6 +1973,82 @@ mod tests {
19011973 crate :: tui:: hover_layer:: clear_pointer ( ) ;
19021974 }
19031975
1976+
1977+
1978+ #[ test]
1979+ fn slash_autocomplete_click_selects_and_second_click_applies ( ) {
1980+ let mut app = create_test_app ( ) ;
1981+ app. launch . visible = false ;
1982+ app. work_surface . last_area = None ;
1983+ app. input = "/he" . to_string ( ) ;
1984+ app. cursor_position = app. input . chars ( ) . count ( ) ;
1985+ app. slash_menu_hidden = false ;
1986+ app. slash_menu_selected = 0 ;
1987+ // Simulate two painted rows from ComposerWidget.
1988+ app. viewport . last_composer_area = Some ( Rect :: new ( 0 , 18 , 80 , 6 ) ) ;
1989+ * app. viewport . last_slash_menu_hitboxes . borrow_mut ( ) = vec ! [
1990+ ( 0 , Rect :: new( 1 , 20 , 78 , 1 ) ) ,
1991+ ( 1 , Rect :: new( 1 , 21 , 78 , 1 ) ) ,
1992+ ] ;
1993+
1994+ assert ! (
1995+ handle_composer_mouse( & mut app, left_click( 5 , 21 ) ) ,
1996+ "slash row click must be consumed by the composer"
1997+ ) ;
1998+ assert_eq ! ( app. slash_menu_selected, 1 , "click on another row highlights it" ) ;
1999+ let before = app. input . clone ( ) ;
2000+ assert_eq ! ( before, "/he" , "select-only click must not rewrite the composer" ) ;
2001+
2002+ assert ! ( handle_composer_mouse( & mut app, left_click( 5 , 21 ) ) ) ;
2003+ assert_ne ! ( app. input, before, "click on the highlighted row applies it" ) ;
2004+ assert ! (
2005+ app. input. starts_with( '/' ) ,
2006+ "applied slash entry must replace the composer: {:?}" ,
2007+ app. input
2008+ ) ;
2009+ }
2010+
2011+ #[ test]
2012+ fn slash_autocomplete_wheel_moves_selection ( ) {
2013+ let mut app = create_test_app ( ) ;
2014+ app. launch . visible = false ;
2015+ app. work_surface . last_area = None ;
2016+ app. input = "/he" . to_string ( ) ;
2017+ app. cursor_position = app. input . chars ( ) . count ( ) ;
2018+ app. slash_menu_hidden = false ;
2019+ app. slash_menu_selected = 0 ;
2020+ app. viewport . last_composer_area = Some ( Rect :: new ( 0 , 18 , 80 , 6 ) ) ;
2021+ * app. viewport . last_slash_menu_hitboxes . borrow_mut ( ) = vec ! [
2022+ ( 0 , Rect :: new( 1 , 20 , 78 , 1 ) ) ,
2023+ ( 1 , Rect :: new( 1 , 21 , 78 , 1 ) ) ,
2024+ ] ;
2025+ let entries = crate :: tui:: slash_menu:: visible_slash_menu_entries ( & app, 128 ) ;
2026+ assert ! ( entries. len( ) >= 2 , "prefix must offer multiple entries" ) ;
2027+
2028+ assert ! ( handle_composer_mouse(
2029+ & mut app,
2030+ MouseEvent {
2031+ kind: MouseEventKind :: ScrollDown ,
2032+ column: 5 ,
2033+ row: 20 ,
2034+ modifiers: KeyModifiers :: NONE ,
2035+ } ,
2036+ ) ) ;
2037+ assert_eq ! ( app. slash_menu_selected, 1 ) ;
2038+
2039+ assert ! ( handle_composer_mouse(
2040+ & mut app,
2041+ MouseEvent {
2042+ kind: MouseEventKind :: ScrollUp ,
2043+ column: 5 ,
2044+ row: 20 ,
2045+ modifiers: KeyModifiers :: NONE ,
2046+ } ,
2047+ ) ) ;
2048+ assert_eq ! ( app. slash_menu_selected, 0 ) ;
2049+ }
2050+
2051+
19042052 #[ test]
19052053 fn send_click_matches_the_keyboard_submit_and_focus_never_leaves_the_composer ( ) {
19062054 let mut app = create_test_app ( ) ;
0 commit comments