Skip to content

Commit ba3257a

Browse files
author
CodeWhale Bot
committed
fix(tui): slash autocomplete rows accept click and wheel
Painted slash-menu rows were key-only (ops clickability map). Record row hitboxes during ComposerWidget paint and route composer mouse over those rows like the command palette: click moves highlight, click the highlighted row applies, wheel moves selection. Mouse == keys for the first-run slash surface.
1 parent 7258735 commit ba3257a

4 files changed

Lines changed: 173 additions & 5 deletions

File tree

crates/tui/src/tui/app.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,11 @@ pub struct ViewportState {
909909
/// Vertical padding above the first text line in the composer,
910910
/// stored at render time for mouse coordinate mapping.
911911
pub last_composer_top_padding: usize,
912+
/// Slash-autocomplete rows painted inside the composer on the latest
913+
/// frame. Cleared and rewritten during `ComposerWidget::render` so a
914+
/// resized or closed menu cannot swallow a click. Index is the entry
915+
/// index into the visible slash menu (same as `slash_menu_selected`).
916+
pub last_slash_menu_hitboxes: RefCell<Vec<(usize, Rect)>>,
912917
}
913918

914919
impl Default for ViewportState {
@@ -940,6 +945,7 @@ impl Default for ViewportState {
940945
last_composer_content: None,
941946
last_composer_scroll_offset: 0,
942947
last_composer_top_padding: 0,
948+
last_slash_menu_hitboxes: RefCell::new(Vec::new()),
943949
}
944950
}
945951
}

crates/tui/src/tui/app/init.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ impl App {
722722
selected_attachment_index: None,
723723
slash_menu_selected: 0,
724724
slash_menu_hidden: false,
725-
mention_menu_selected: 0,
725+
mention_menu_selected: 0,
726726
mention_menu_hidden: false,
727727
mention_completion_cache: None,
728728
mention_discovery: crate::tui::mention_completion::MentionDiscovery::default(),

crates/tui/src/tui/mouse_ui.rs

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
342408
pub(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)]
17771848
mod 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();

crates/tui/src/tui/widgets/mod.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,9 @@ impl<'a> ComposerWidget<'a> {
13571357

13581358
impl Renderable for ComposerWidget<'_> {
13591359
fn render(&self, area: Rect, buf: &mut Buffer) {
1360+
// Slash rows are re-recorded below; clear first so a closed or
1361+
// resized menu cannot keep stale hitboxes from the prior frame.
1362+
self.app.viewport.last_slash_menu_hitboxes.borrow_mut().clear();
13601363
let background = Style::default().bg(self.app.ui_theme.composer_bg);
13611364
let has_panel = self.has_panel(area);
13621365
let inner_area = self.inner_area(area);
@@ -1815,15 +1818,26 @@ impl Renderable for ComposerWidget<'_> {
18151818
Span::styled(desc_display, desc_style),
18161819
]));
18171820

1821+
let row_y = inner_area
1822+
.y
1823+
.saturating_add(u16::try_from(row_line_index).unwrap_or(u16::MAX));
1824+
if row_y < inner_area.bottom() && inner_area.width > 0 {
1825+
self.app
1826+
.viewport
1827+
.last_slash_menu_hitboxes
1828+
.borrow_mut()
1829+
.push((
1830+
idx,
1831+
Rect::new(inner_area.x, row_y, inner_area.width, 1),
1832+
));
1833+
}
1834+
18181835
if name_was_truncated || description_was_truncated {
18191836
let full_text = if entry.description.trim().is_empty() {
18201837
display_name
18211838
} else {
18221839
format!("{display_name} {}", entry.description)
18231840
};
1824-
let row_y = inner_area
1825-
.y
1826-
.saturating_add(u16::try_from(row_line_index).unwrap_or(u16::MAX));
18271841
if row_y < inner_area.bottom() {
18281842
crate::tui::hover_layer::register_rect(
18291843
crate::tui::hover_hit::HoverTargetKind::TruncatedText,

0 commit comments

Comments
 (0)