refactor(tui): extract slash-menu helpers into tui/slash_menu.rs (P1.2)

Lifts `visible_slash_menu_entries`, `apply_slash_menu_selection`, and
`try_autocomplete_slash_command` from `tui/ui.rs` into a sibling
module. Drops the now-unused `slash_completion_hints` import from
`ui.rs` (the new module imports it directly).

Kept separate from `tui::file_mention` per the audit doc — the two
popups have distinct trigger characters, ranking, and post-selection
behaviour even though they share UI scaffolding.

`ui.rs`: ~5070 → ~4990 lines.
Workspace tests: 1011/1011 still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-26 00:47:44 -05:00
parent 56308bb5d7
commit 25cfe11736
3 changed files with 100 additions and 73 deletions
+1
View File
@@ -22,6 +22,7 @@ pub mod scrolling;
pub mod selection;
pub mod session_picker;
pub mod sidebar;
pub mod slash_menu;
pub mod streaming;
pub mod transcript;
pub mod ui;
+95
View File
@@ -0,0 +1,95 @@
//! Slash-command autocomplete + popup-menu helpers.
//!
//! Extracted from `tui/ui.rs` (P1.2). The on-screen popup itself is rendered
//! by the composer widget; these helpers source the entries, apply a
//! selection, and handle Tab-completion when the popup isn't open.
//!
//! Intentionally separate from `tui::file_mention` even though both surface
//! a similar popup — the trigger characters, ranking, and post-selection
//! behaviour differ enough to keep them apart.
use crate::commands;
use super::app::App;
use super::widgets::slash_completion_hints;
/// Return the slash-menu entries the composer should display, honouring
/// `slash_menu_hidden` (set when the user dismisses the popup with Esc).
pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec<String> {
if app.slash_menu_hidden {
return Vec::new();
}
slash_completion_hints(&app.input, limit)
}
/// Apply the currently-selected slash menu entry to the composer input.
/// Optionally appends a trailing space when the command takes arguments
/// so the user can type the rest without an extra keystroke.
pub fn apply_slash_menu_selection(app: &mut App, entries: &[String], append_space: bool) -> bool {
if entries.is_empty() {
return false;
}
let selected_idx = app.slash_menu_selected.min(entries.len().saturating_sub(1));
let mut command = entries[selected_idx].clone();
if append_space
&& !command.ends_with(' ')
&& !command.contains(char::is_whitespace)
&& let Some(info) = commands::get_command_info(command.trim_start_matches('/'))
&& (info.usage.contains('<') || info.usage.contains('['))
{
command.push(' ');
}
app.input = command;
app.cursor_position = app.input.chars().count();
app.slash_menu_hidden = false;
app.status_message = Some(format!("Command selected: {}", app.input.trim_end()));
true
}
/// Tab-completion for a slash-command-like input. Extends the input to the
/// longest unambiguous prefix; if exactly one command matches, completes it
/// fully (with trailing space). On ambiguity, posts a status hint listing
/// up to five candidates.
pub fn try_autocomplete_slash_command(app: &mut App) -> bool {
if !app.input.starts_with('/') || app.input.contains(char::is_whitespace) {
return false;
}
let prefix = app.input.trim_start_matches('/');
let matches = commands::commands_matching(prefix);
if matches.is_empty() {
return false;
}
let names = matches.iter().map(|info| info.name).collect::<Vec<_>>();
let shared = crate::tui::file_mention::longest_common_prefix(&names);
if !shared.is_empty() && shared.len() > prefix.len() {
app.input = format!("/{shared}");
app.cursor_position = app.input.chars().count();
app.slash_menu_hidden = false;
app.status_message = Some(format!("Autocomplete: /{shared}"));
return true;
}
if matches.len() == 1 {
let completed = format!("/{} ", matches[0].name);
app.input = completed.clone();
app.cursor_position = completed.chars().count();
app.slash_menu_hidden = false;
app.status_message = Some(format!("Command completed: {}", completed.trim_end()));
return true;
}
let preview = matches
.iter()
.take(5)
.map(|info| format!("/{}", info.name))
.collect::<Vec<_>>()
.join(", ");
app.status_message = Some(format!("Suggestions: {preview}"));
true
}
+4 -73
View File
@@ -76,10 +76,13 @@ use super::history::{
ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output,
summarize_tool_args, summarize_tool_output,
};
use super::slash_menu::{
apply_slash_menu_selection, try_autocomplete_slash_command, visible_slash_menu_entries,
};
use super::views::{ConfigView, HelpView, ModalKind, ViewEvent};
use super::widgets::{
ChatWidget, ComposerWidget, FooterProps, FooterToast, FooterWidget, HeaderData, HeaderWidget,
Renderable, slash_completion_hints,
Renderable,
};
// === Constants ===
@@ -1531,78 +1534,6 @@ fn apply_alt_4_shortcut(app: &mut App, modifiers: KeyModifiers) {
}
}
fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec<String> {
if app.slash_menu_hidden {
return Vec::new();
}
slash_completion_hints(&app.input, limit)
}
fn apply_slash_menu_selection(app: &mut App, entries: &[String], append_space: bool) -> bool {
if entries.is_empty() {
return false;
}
let selected_idx = app.slash_menu_selected.min(entries.len().saturating_sub(1));
let mut command = entries[selected_idx].clone();
if append_space
&& !command.ends_with(' ')
&& !command.contains(char::is_whitespace)
&& let Some(info) = commands::get_command_info(command.trim_start_matches('/'))
&& (info.usage.contains('<') || info.usage.contains('['))
{
command.push(' ');
}
app.input = command;
app.cursor_position = app.input.chars().count();
app.slash_menu_hidden = false;
app.status_message = Some(format!("Command selected: {}", app.input.trim_end()));
true
}
fn try_autocomplete_slash_command(app: &mut App) -> bool {
if !app.input.starts_with('/') || app.input.contains(char::is_whitespace) {
return false;
}
let prefix = app.input.trim_start_matches('/');
let matches = commands::commands_matching(prefix);
if matches.is_empty() {
return false;
}
let names = matches.iter().map(|info| info.name).collect::<Vec<_>>();
let shared = crate::tui::file_mention::longest_common_prefix(&names);
if !shared.is_empty() && shared.len() > prefix.len() {
app.input = format!("/{shared}");
app.cursor_position = app.input.chars().count();
app.slash_menu_hidden = false;
app.status_message = Some(format!("Autocomplete: /{shared}"));
return true;
}
if matches.len() == 1 {
let completed = format!("/{} ", matches[0].name);
app.input = completed.clone();
app.cursor_position = completed.chars().count();
app.slash_menu_hidden = false;
app.status_message = Some(format!("Command completed: {}", completed.trim_end()));
return true;
}
let preview = matches
.iter()
.take(5)
.map(|info| format!("/{}", info.name))
.collect::<Vec<_>>()
.join(", ");
app.status_message = Some(format!("Suggestions: {preview}"));
true
}
async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
use crate::client::DeepSeekClient;