feat(tui): wire up @-mention popup end-to-end (P2.1)

The audit doc claimed the wiring was "in place" but only the App state
fields existed (`mention_menu_selected`, `mention_menu_hidden`) — no
helpers, no widget rendering, no key handling. Building it out fully so
the popup actually shows when the user types `@` in the composer and
Up/Down/Enter/Tab/Esc behave the way the slash menu does.

What's new:

1. `file_mention::visible_mention_menu_entries(app, limit)` — the entries
   source. Returns `Vec<String>` from the workspace walk, gated on the
   `mention_menu_hidden` flag and on the cursor being inside an `@token`.

2. `file_mention::apply_mention_menu_selection(app, entries)` — splices
   the selected entry into the input via the existing `replace_file_mention`,
   resets `mention_menu_hidden`, surfaces a status confirmation.

3. `ComposerWidget::new(app, max_height, slash_entries, mention_entries)`
   — second menu slot. The widget renders whichever slice is non-empty,
   addressed by the matching selected index. Mention entries get an `@`
   prefix so the popup row reads like the actual mention being composed.
   Mention takes precedence (positional check is stricter than slash's
   "starts-with-/").

4. ui.rs key handler:
   - Up/Down navigate `mention_menu_selected` when the popup is open.
   - Enter applies `apply_mention_menu_selection` instead of submitting.
   - Tab applies the selection (then falls through to the existing slash /
     command-completion / file-mention chain).
   - Esc hides the popup until the next input edit (`insert_str` already
     resets `mention_menu_hidden`, so typing re-opens it).

6 new tests in `ui/tests.rs`:
- mention_popup_is_empty_when_cursor_is_not_in_a_mention
- mention_popup_lists_workspace_matches_for_cursor_partial
- mention_popup_respects_hidden_flag
- apply_mention_menu_selection_splices_selected_entry
- apply_mention_menu_selection_is_noop_outside_a_mention
- apply_mention_menu_selection_with_no_entries_is_noop

Also fixes a stray duplicate `#[cfg(...)]` and an unused-doc-comment
warning that landed when the parse-counter went thread-local — back to
baseline 7 clippy warnings.

948 → 954 tests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-26 00:09:04 -05:00
parent 06355e3aea
commit 2185b8c3c6
5 changed files with 269 additions and 26 deletions
+54
View File
@@ -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 `@<partial>` token.
/// - The workspace walk produced no candidates.
///
/// Mirrors `visible_slash_menu_entries` so the composer widget can treat
/// both menus identically (one `Vec<String>` 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<String> {
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 `@<partial>` 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
+5 -9
View File
@@ -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<u64> = const { Cell::new(0) };
+58 -2
View File
@@ -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])
+101 -2
View File
@@ -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.
+51 -13
View File
@@ -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::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries);
let mention_menu_entries = Vec::<String>::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::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries);
let mention_menu_entries = Vec::<String>::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::<String>::new();
let widget = ComposerWidget::new(&app, 3, &slash_menu_entries);
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 3, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,