diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9eacc6052f..3cfe3ebf6e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,7 +5,6 @@ use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; -use crate::slash_command::Command; use crate::tui; use codex_core::config::Config; use codex_core::protocol::Event; @@ -29,6 +28,8 @@ use std::sync::mpsc::channel; use std::thread; use std::time::Duration; +use crate::slash_command::SlashCommand; + /// Time window for debouncing redraw requests. const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10); @@ -340,7 +341,7 @@ impl App<'_> { AppState::GitWarning { .. } => {} }, AppEvent::DispatchCommand(command) => match command { - Command::New => { + SlashCommand::New => { let new_widget = Box::new(ChatWidget::new( self.config.clone(), self.app_event_tx.clone(), @@ -351,16 +352,16 @@ impl App<'_> { self.app_state = AppState::Chat { widget: new_widget }; self.app_event_tx.send(AppEvent::RequestRedraw); } - Command::Compact => { + SlashCommand::Compact => { if let AppState::Chat { widget } = &mut self.app_state { widget.clear_token_usage(); self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } } - Command::Quit => { + SlashCommand::Quit => { break; } - Command::Diff => { + SlashCommand::Diff => { let (is_git_repo, diff_text) = match get_git_diff() { Ok(v) => v, Err(e) => { @@ -382,7 +383,7 @@ impl App<'_> { } } #[cfg(debug_assertions)] - Command::TestApproval => { + SlashCommand::TestApproval => { use std::collections::HashMap; use codex_core::protocol::ApplyPatchApprovalRequestEvent; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 45c4b9137d..44496495a2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -3,7 +3,7 @@ use codex_file_search::FileMatch; use crossterm::event::KeyEvent; use ratatui::text::Line; -use crate::slash_command::Command; +use crate::slash_command::SlashCommand; #[derive(Debug)] #[allow(clippy::large_enum_variant)] @@ -33,7 +33,7 @@ pub(crate) enum AppEvent { /// Dispatch a recognized slash command from the UI (composer) to the app /// layer so it can be handled centrally. - DispatchCommand(Command), + DispatchCommand(SlashCommand), /// Kick off an asynchronous file search for the given query (text after /// the `@`). Previous searches may be cancelled by the app layer so there diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1f50d9cdce..625d420852 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -19,7 +19,6 @@ use tui_textarea::TextArea; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; -use crate::slash_command::Command; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -56,7 +55,7 @@ pub(crate) struct ChatComposer<'a> { /// Popup state – at most one can be visible at any time. enum ActivePopup { None, - Slash(CommandPopup), + Slash(CommandPopup), File(FileSearchPopup), } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 7442d45299..21db0a7327 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -12,38 +12,24 @@ use ratatui::widgets::Table; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; -use crate::slash_command::Command; +use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; -pub trait CommandInfo: Copy { - fn command(&self) -> &'static str; - fn description(&self) -> &'static str; -} - -impl CommandInfo for Command { - fn command(&self) -> &'static str { - Command::command(*self) - } - fn description(&self) -> &'static str { - Command::description(*self) - } -} - const MAX_POPUP_ROWS: usize = 5; /// Ideally this is enough to show the longest command name. const FIRST_COLUMN_WIDTH: u16 = 20; use ratatui::style::Modifier; -pub(crate) struct CommandPopup { +pub(crate) struct CommandPopup { prefix: char, command_filter: String, - all_commands: Vec<(&'static str, C)>, + all_commands: Vec<(&'static str, SlashCommand)>, selected_idx: Option, } -impl CommandPopup { - pub(crate) fn new(prefix: char, all_commands: Vec<(&'static str, C)>) -> Self { +impl CommandPopup { + pub(crate) fn new(prefix: char, all_commands: Vec<(&'static str, SlashCommand)>) -> Self { Self { prefix, command_filter: String::new(), @@ -53,17 +39,25 @@ impl CommandPopup { } /// Update the filter string based on the current composer text. The text - /// passed in is expected to start with this popup's prefix (e.g. '/' or '@'). - /// Everything after the prefix up to the first ASCII whitespace becomes - /// the active filter that is used to narrow down the list of available commands. + /// passed in is expected to start with a leading '/'. Everything after the + /// *first* '/' on the *first* line becomes the active filter that is used + /// to narrow down the list of available commands. pub(crate) fn on_composer_text_change(&mut self, text: String) { let first_line = text.lines().next().unwrap_or(""); - if first_line.starts_with(self.prefix) { - let stripped = &first_line[self.prefix.len_utf8()..]; + + if let Some(stripped) = first_line.strip_prefix('/') { + // Extract the *first* token (sequence of non-whitespace + // characters) after the slash so that `/clear something` still + // shows the help for `/clear`. let token = stripped.trim_start(); let cmd_token = token.split_whitespace().next().unwrap_or(""); + // Update the filter keeping the original case (commands are all + // lower-case for now but this may change in the future). self.command_filter = cmd_token.to_string(); } else { + // The composer no longer starts with '/'. Reset the filter so the + // popup shows the *full* command list if it is still displayed + // for some reason. self.command_filter.clear(); } @@ -84,7 +78,7 @@ impl CommandPopup { /// Return the list of commands that match the current filter. Matching is /// performed using a *prefix* comparison on the command name. - fn filtered_commands(&self) -> Vec<&C> { + fn filtered_commands(&self) -> Vec<&SlashCommand> { self.all_commands .iter() .filter_map(|(_name, cmd)| { @@ -98,7 +92,7 @@ impl CommandPopup { None } }) - .collect::>() + .collect::>() } /// Move the selection cursor one step up. @@ -138,24 +132,25 @@ impl CommandPopup { } /// Return currently selected command, if any. - pub(crate) fn selected_command(&self) -> Option<&C> { + pub(crate) fn selected_command(&self) -> Option<&SlashCommand> { let matches = self.filtered_commands(); self.selected_idx.and_then(|idx| matches.get(idx).copied()) } } -impl CommandPopup { +impl CommandPopup { pub(crate) fn slash() -> Self { CommandPopup::new('/', built_in_slash_commands()) } } -impl WidgetRef for CommandPopup { +impl WidgetRef for CommandPopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let matches = self.filtered_commands(); let mut rows: Vec = Vec::new(); - let visible_matches: Vec<&C> = matches.into_iter().take(MAX_POPUP_ROWS).collect(); + let visible_matches: Vec<&SlashCommand> = + matches.into_iter().take(MAX_POPUP_ROWS).collect(); if visible_matches.is_empty() { rows.push(Row::new(vec![ diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bc01afe842..5d729ea577 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -532,11 +532,6 @@ impl ChatWidget<'_> { &self.token_usage } - // pub(crate) fn add_background_event(&mut self, msg: String) { - // self.add_to_history(HistoryCell::new_background_event(msg)); - // self.request_redraw(); - // } - pub(crate) fn clear_token_usage(&mut self) { self.token_usage = TokenUsage::default(); self.bottom_pane diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 9b2cc6d681..d82a16608f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -9,7 +9,7 @@ use strum_macros::IntoStaticStr; Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr, )] #[strum(serialize_all = "kebab-case")] -pub enum Command { +pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. New, @@ -20,16 +20,18 @@ pub enum Command { TestApproval, } -impl Command { +impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { - Command::New => "Start a new chat.", - Command::Compact => "Compact the chat history.", - Command::Quit => "Exit the application.", - Command::Diff => "Show git diff of the working directory (including untracked files)", + SlashCommand::New => "Start a new chat.", + SlashCommand::Compact => "Compact the chat history.", + SlashCommand::Quit => "Exit the application.", + SlashCommand::Diff => { + "Show git diff of the working directory (including untracked files)" + } #[cfg(debug_assertions)] - Command::TestApproval => "Test approval request", + SlashCommand::TestApproval => "Test approval request", } } @@ -41,6 +43,6 @@ impl Command { } /// Return all built-in commands in a Vec paired with their command string. -pub fn built_in_slash_commands() -> Vec<(&'static str, Command)> { - Command::iter().map(|c| (c.command(), c)).collect() +pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { + SlashCommand::iter().map(|c| (c.command(), c)).collect() }