diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 1b5a9ca6..3c0f1c9a 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -143,6 +143,60 @@ pub fn find_file_mention_completions(workspace: &Path, partial: &str, limit: usi prefix_hits } +/// Resolve the `@`-mention completion popup contents for the current +/// composer state. Returns an empty `Vec` when: +/// +/// - The popup is suppressed (`app.mention_menu_hidden`). +/// - The cursor is not inside an `@` token. +/// - The workspace walk produced no candidates. +/// +/// Mirrors `visible_slash_menu_entries` so the composer widget can treat +/// both menus identically (one `Vec` of entries, one selected index). +/// +/// Once the composer widget is extended to render this as a popup, it will +/// pair with `apply_mention_menu_selection` for the Up/Down/Enter flow. +#[must_use] +pub fn visible_mention_menu_entries(app: &App, limit: usize) -> Vec { + if app.mention_menu_hidden { + return Vec::new(); + } + let Some((_byte_start, partial)) = + partial_file_mention_at_cursor(&app.input, app.cursor_position) + else { + return Vec::new(); + }; + if limit == 0 { + return Vec::new(); + } + find_file_mention_completions(&app.workspace, &partial, limit) +} + +/// Apply the currently selected `@`-mention popup entry to the composer +/// input, splicing it in place of the `@` token at the cursor. +/// Returns `true` if a substitution occurred. +/// +/// Designed to be invoked by the same keybinding that drives +/// `apply_slash_menu_selection` (Enter / Tab); the caller is responsible +/// for choosing which menu is "active" based on cursor context. +pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool { + if entries.is_empty() { + return false; + } + let Some((byte_start, partial)) = + partial_file_mention_at_cursor(&app.input, app.cursor_position) + else { + return false; + }; + let selected_idx = app + .mention_menu_selected + .min(entries.len().saturating_sub(1)); + let replacement = &entries[selected_idx]; + replace_file_mention(app, byte_start, &partial, replacement); + app.mention_menu_hidden = false; + app.status_message = Some(format!("Attached @{replacement}")); + true +} + /// Tab-completion handler for `@file` mentions. Mirrors the slash-command /// flow: a single match is applied directly; multiple matches with a longer /// shared prefix extend the partial; otherwise the first few candidates are diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index b501f3cc..bc98da0d 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -26,7 +26,6 @@ use std::sync::Arc; -#[cfg(any(test, feature = "perf-counters"))] #[cfg(any(test, feature = "perf-counters"))] use std::cell::Cell; @@ -36,14 +35,11 @@ use unicode_width::UnicodeWidthStr; use crate::palette; -/// Per-process counter incremented every time [`parse`] runs. Used by tests to -/// prove that width-only changes hit the cached-AST path and skip parsing. -/// -/// Available in test builds and behind the `perf-counters` feature flag so -/// release builds pay no cost. -// Thread-local instead of a global atomic so concurrent tests that call -// `parse()` don't pollute each other's counters. Each test thread sees only -// its own invocations. +// Thread-local counter incremented every time `parse` runs. Used by tests to +// prove that width-only changes hit the cached-AST path and skip parsing. +// Available in test builds and behind the `perf-counters` feature flag so +// release builds pay no cost. Thread-local (not global atomic) so concurrent +// tests calling `parse()` can't pollute each other's counters. #[cfg(any(test, feature = "perf-counters"))] thread_local! { static PARSE_INVOCATIONS: Cell = const { Cell::new(0) }; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 4801e8c6..90b9311c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -90,6 +90,7 @@ use super::widgets::{ // === Constants === const SLASH_MENU_LIMIT: usize = 6; +const MENTION_MENU_LIMIT: usize = 6; const MIN_CHAT_HEIGHT: u16 = 3; const MIN_COMPOSER_HEIGHT: u16 = 2; const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; @@ -1142,6 +1143,12 @@ async fn run_event_loop( if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() { app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1); } + let mention_menu_entries = + crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT); + let mention_menu_open = !mention_menu_entries.is_empty(); + if mention_menu_open && app.mention_menu_selected >= mention_menu_entries.len() { + app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1); + } // Global keybindings match key.code { @@ -1263,6 +1270,10 @@ async fn run_event_loop( let _ = engine_handle.send(Op::Shutdown).await; return Ok(()); } + KeyCode::Esc if mention_menu_open => { + app.mention_menu_hidden = true; + app.mention_menu_selected = 0; + } KeyCode::Esc => match next_escape_action(app, slash_menu_open) { EscapeAction::CloseSlashMenu => app.close_slash_menu(), EscapeAction::CancelRequest => { @@ -1281,6 +1292,13 @@ async fn run_event_loop( KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { app.scroll_up(3); } + KeyCode::Up + if key.modifiers.is_empty() + && mention_menu_open + && app.mention_menu_selected > 0 => + { + app.mention_menu_selected = app.mention_menu_selected.saturating_sub(1); + } KeyCode::Up if key.modifiers.is_empty() && slash_menu_open @@ -1291,6 +1309,10 @@ async fn run_event_loop( KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => { app.scroll_down(3); } + KeyCode::Down if key.modifiers.is_empty() && mention_menu_open => { + app.mention_menu_selected = (app.mention_menu_selected + 1) + .min(mention_menu_entries.len().saturating_sub(1)); + } KeyCode::Down if key.modifiers.is_empty() && slash_menu_open => { app.slash_menu_selected = (app.slash_menu_selected + 1) .min(slash_menu_entries.len().saturating_sub(1)); @@ -1304,6 +1326,14 @@ async fn run_event_loop( app.scroll_down(page); } KeyCode::Tab => { + if mention_menu_open + && crate::tui::file_mention::apply_mention_menu_selection( + app, + &mention_menu_entries, + ) + { + continue; + } if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, true) { continue; @@ -1366,6 +1396,15 @@ async fn run_event_loop( KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => { app.insert_char('\n'); } + KeyCode::Enter + if mention_menu_open + && crate::tui::file_mention::apply_mention_menu_selection( + app, + &mention_menu_entries, + ) => + { + continue; + } KeyCode::Enter => { if let Some(input) = app.submit_input() { if handle_plan_choice(app, &engine_handle, &input).await? { @@ -2580,12 +2619,24 @@ fn render(f: &mut Frame, app: &mut App) { let footer_height = 1; let body_height = size.height.saturating_sub(header_height + footer_height); let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT); + let mention_menu_entries = + crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT); + if !mention_menu_entries.is_empty() + && app.mention_menu_selected >= mention_menu_entries.len() + { + app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1); + } let context_usage = context_usage_snapshot(app); let composer_max_height = body_height .saturating_sub(MIN_CHAT_HEIGHT) .max(MIN_COMPOSER_HEIGHT); let composer_height = { - let composer_widget = ComposerWidget::new(app, composer_max_height, &slash_menu_entries); + let composer_widget = ComposerWidget::new( + app, + composer_max_height, + &slash_menu_entries, + &mention_menu_entries, + ); composer_widget.desired_height(size.width) }; @@ -2672,7 +2723,12 @@ fn render(f: &mut Frame, app: &mut App) { // Render composer let cursor_pos = { - let composer_widget = ComposerWidget::new(app, composer_max_height, &slash_menu_entries); + let composer_widget = ComposerWidget::new( + app, + composer_max_height, + &slash_menu_entries, + &mention_menu_entries, + ); let buf = f.buffer_mut(); composer_widget.render(chunks[2], buf); composer_widget.cursor_pos(chunks[2]) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index a6cc904c..870a50c0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1,8 +1,8 @@ use super::*; use crate::config::Config; use crate::tui::file_mention::{ - find_file_mention_completions, partial_file_mention_at_cursor, try_autocomplete_file_mention, - user_request_with_file_mentions, + apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor, + try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries, }; use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus}; use crate::tui::views::{ModalView, ViewAction}; @@ -1057,6 +1057,105 @@ fn try_autocomplete_file_mention_returns_false_outside_mention() { assert!(!try_autocomplete_file_mention(&mut app)); } +// ---- P2.1: @-mention popup helpers ---- +// +// `visible_mention_menu_entries` is the entries source the composer widget +// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the +// popup is open. The popup widget itself piggybacks the slash-menu render +// path (see `ComposerWidget::active_menu_entries`). + +#[test] +fn mention_popup_is_empty_when_cursor_is_not_in_a_mention() { + let mut app = create_test_app(); + app.input = "no mention here".to_string(); + app.cursor_position = app.input.chars().count(); + assert!(visible_mention_menu_entries(&app, 6).is_empty()); +} + +#[test] +fn mention_popup_lists_workspace_matches_for_cursor_partial() { + let tmpdir = TempDir::new().expect("tempdir"); + std::fs::create_dir_all(tmpdir.path().join("docs")).unwrap(); + std::fs::write(tmpdir.path().join("docs/deepseek_v4.pdf"), b"%PDF-").unwrap(); + std::fs::write(tmpdir.path().join("docs/MCP.md"), "x").unwrap(); + std::fs::write(tmpdir.path().join("README.md"), "x").unwrap(); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.input = "look at @docs/".to_string(); + app.cursor_position = app.input.chars().count(); + + let entries = visible_mention_menu_entries(&app, 6); + assert!(!entries.is_empty(), "popup should surface docs/ entries"); + assert!(entries.iter().any(|e| e.starts_with("docs/"))); + // README.md doesn't match `docs/` — confirm we didn't dump every file. + assert!(!entries.iter().any(|e| e == "README.md")); +} + +#[test] +fn mention_popup_respects_hidden_flag() { + let tmpdir = TempDir::new().expect("tempdir"); + std::fs::write(tmpdir.path().join("README.md"), "x").unwrap(); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.input = "@READ".to_string(); + app.cursor_position = app.input.chars().count(); + app.mention_menu_hidden = true; + + assert!( + visible_mention_menu_entries(&app, 6).is_empty(), + "Esc-hidden popup must not surface entries until next input edit", + ); +} + +#[test] +fn apply_mention_menu_selection_splices_selected_entry() { + let tmpdir = TempDir::new().expect("tempdir"); + std::fs::create_dir_all(tmpdir.path().join("crates/tui")).unwrap(); + std::fs::write(tmpdir.path().join("crates/tui/lib.rs"), "//").unwrap(); + std::fs::write(tmpdir.path().join("crates/tui/main.rs"), "//").unwrap(); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.input = "open @crates/tui/m".to_string(); + app.cursor_position = app.input.chars().count(); + + let entries = visible_mention_menu_entries(&app, 6); + assert!(!entries.is_empty(), "expected entries for @crates/tui/m"); + // Pick whichever entry appears at index 0; it's deterministic given the + // workspace setup. Apply it. + app.mention_menu_selected = 0; + let applied = apply_mention_menu_selection(&mut app, &entries); + assert!(applied, "apply_mention_menu_selection should report success"); + assert!( + app.input.starts_with("open @"), + "input should still start with `open @`, got: {input}", + input = app.input, + ); + // Cursor should land at the end of the spliced token. + assert_eq!(app.cursor_position, app.input.chars().count()); +} + +#[test] +fn apply_mention_menu_selection_is_noop_outside_a_mention() { + let mut app = create_test_app(); + app.input = "no @ here".to_string(); + app.cursor_position = 1; // before the @ token + let applied = apply_mention_menu_selection(&mut app, &["whatever".to_string()]); + assert!(!applied); + assert_eq!(app.input, "no @ here"); +} + +#[test] +fn apply_mention_menu_selection_with_no_entries_is_noop() { + let mut app = create_test_app(); + app.input = "@partial".to_string(); + app.cursor_position = app.input.chars().count(); + let applied = apply_mention_menu_selection(&mut app, &[]); + assert!(!applied); +} + // === CX#7 — single active cell mutated in place for parallel tool calls === /// Build a minimal successful ToolResult with the given content. diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index e45a6a8c..486ca7f1 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -203,14 +203,42 @@ pub struct ComposerWidget<'a> { app: &'a App, max_height: u16, slash_menu_entries: &'a [String], + mention_menu_entries: &'a [String], } impl<'a> ComposerWidget<'a> { - pub fn new(app: &'a App, max_height: u16, slash_menu_entries: &'a [String]) -> Self { + pub fn new( + app: &'a App, + max_height: u16, + slash_menu_entries: &'a [String], + mention_menu_entries: &'a [String], + ) -> Self { Self { app, max_height, slash_menu_entries, + mention_menu_entries, + } + } + + /// Number of popup rows below the input. Mention and slash menus are + /// mutually exclusive — the cursor can only sit inside an `@token` OR + /// a `/cmd` token, not both at once. Mention takes precedence because + /// the partial-mention check is positional and stricter than slash's + /// "starts-with-/" check. + fn active_menu_entries(&self) -> &'a [String] { + if !self.mention_menu_entries.is_empty() { + self.mention_menu_entries + } else { + self.slash_menu_entries + } + } + + fn active_menu_selected(&self) -> usize { + if !self.mention_menu_entries.is_empty() { + self.app.mention_menu_selected + } else { + self.app.slash_menu_selected } } @@ -244,7 +272,8 @@ impl Renderable for ComposerWidget<'_> { let background = Style::default().bg(self.app.ui_theme.composer_bg); let has_panel = self.has_panel(area); let inner_area = self.inner_area(area); - let menu_lines = self.slash_menu_entries.len(); + let menu_entries = self.active_menu_entries(); + let menu_lines = menu_entries.len(); let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines); let content_width = usize::from(inner_area.width.max(1)); let (visible_lines, _cursor_row, _cursor_col) = layout_input( @@ -317,12 +346,18 @@ impl Renderable for ComposerWidget<'_> { } lines.extend(input_lines); - if !self.slash_menu_entries.is_empty() { + if !menu_entries.is_empty() { let selected = self - .app - .slash_menu_selected - .min(self.slash_menu_entries.len().saturating_sub(1)); - for (idx, entry) in self.slash_menu_entries.iter().enumerate() { + .active_menu_selected() + .min(menu_entries.len().saturating_sub(1)); + // `@`-mention entries get an "@" prefix so the popup line reads + // like the actual mention the user is composing. + let prefix = if !self.mention_menu_entries.is_empty() { + "@" + } else { + "" + }; + for (idx, entry) in menu_entries.iter().enumerate() { let is_selected = idx == selected; let style = if is_selected { Style::default() @@ -336,7 +371,7 @@ impl Renderable for ComposerWidget<'_> { Span::styled(" ", Style::default()), Span::styled(marker, style), Span::styled(" ", style), - Span::styled(entry.clone(), style), + Span::styled(format!("{prefix}{entry}"), style), ])); } } @@ -352,7 +387,7 @@ impl Renderable for ComposerWidget<'_> { &self.app.input, width, self.max_height.min(self.max_height_cap()), - self.slash_menu_entries.len(), + self.active_menu_entries().len(), self.app.composer_density, self.app.composer_border, ) @@ -362,7 +397,7 @@ impl Renderable for ComposerWidget<'_> { let inner_area = self.inner_area(area); let content_width = usize::from(inner_area.width.max(1)); let input_rows_budget = - composer_input_rows_budget(inner_area.height, self.slash_menu_entries.len()); + composer_input_rows_budget(inner_area.height, self.active_menu_entries().len()); let (visible_lines, cursor_row, cursor_col) = layout_input( &self.app.input, @@ -1394,7 +1429,8 @@ mod tests { // Pin density so the test is independent of any loaded user settings. app.composer_density = ComposerDensity::Comfortable; let slash_menu_entries = Vec::::new(); - let widget = ComposerWidget::new(&app, 5, &slash_menu_entries); + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); // Use a wide area so the placeholder fits on one line (no wrapping). let area = Rect { @@ -1418,7 +1454,8 @@ mod tests { let mut app = create_test_app(); app.composer_density = ComposerDensity::Comfortable; let slash_menu_entries = Vec::::new(); - let widget = ComposerWidget::new(&app, 5, &slash_menu_entries); + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); // Narrow area forces the placeholder to wrap. let area = Rect { @@ -1444,7 +1481,8 @@ mod tests { app.composer_density = ComposerDensity::Comfortable; app.composer_border = false; let slash_menu_entries = Vec::::new(); - let widget = ComposerWidget::new(&app, 3, &slash_menu_entries); + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 3, &slash_menu_entries, &mention_menu_entries); let area = Rect { x: 0,