diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d67a2df7..d4abcd3d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -632,6 +632,8 @@ dependencies = [ "ratatui", "serde_json", "shlex", + "strum 0.27.1", + "strum_macros 0.27.1", "tokio", "tracing", "tracing-appender", @@ -2711,7 +2713,7 @@ dependencies = [ "itertools 0.13.0", "lru", "paste", - "strum", + "strum 0.26.3", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -3482,9 +3484,15 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", ] +[[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" + [[package]] name = "strum_macros" version = "0.26.4" @@ -3498,6 +3506,19 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.100", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 4bd23015..fa075ada 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -29,6 +29,8 @@ ratatui = { version = "0.29.0", features = [ ] } serde_json = "1" shlex = "1.3.0" +strum = "0.27.1" +strum_macros = "0.27.1" tokio = { version = "1", features = [ "io-std", "macros", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3a9c4648..5cf9dae8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,6 +3,7 @@ use crate::chatwidget::ChatWidget; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; use crate::scroll_event_helper::ScrollEventHelper; +use crate::slash_command::SlashCommand; use crate::tui; use codex_core::config::Config; use codex_core::protocol::Event; @@ -177,6 +178,14 @@ impl App<'_> { let _ = self.chat_widget.update_latest_log(line); } } + AppEvent::DispatchCommand(command) => match command { + SlashCommand::Clear => { + let _ = self.chat_widget.clear_conversation_history(); + } + SlashCommand::Quit => { + break; + } + }, } } terminal.clear()?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index dd5053cf..8fc55752 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,6 +1,8 @@ use codex_core::protocol::Event; use crossterm::event::KeyEvent; +use crate::slash_command::SlashCommand; + #[allow(clippy::large_enum_variant)] pub(crate) enum AppEvent { CodexEvent(Event), @@ -22,4 +24,8 @@ pub(crate) enum AppEvent { /// Latest formatted log line emitted by `tracing`. LatestLog(String), + + /// Dispatch a recognized slash command from the UI (composer) to the app + /// layer so it can be handled centrally. + DispatchCommand(SlashCommand), } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6abe6240..d68bd91d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -13,6 +13,12 @@ use tui_textarea::Input; use tui_textarea::Key; use tui_textarea::TextArea; +use std::sync::mpsc::Sender; + +use crate::app_event::AppEvent; + +use super::command_popup::CommandPopup; + /// Minimum number of visible text rows inside the textarea. const MIN_TEXTAREA_ROWS: usize = 1; /// Rows consumed by the border. @@ -26,15 +32,21 @@ pub enum InputResult { pub(crate) struct ChatComposer<'a> { textarea: TextArea<'a>, + command_popup: Option, + app_event_tx: Sender, } impl ChatComposer<'_> { - pub fn new(has_input_focus: bool) -> Self { + pub fn new(has_input_focus: bool, app_event_tx: Sender) -> Self { let mut textarea = TextArea::default(); textarea.set_placeholder_text("send a message"); textarea.set_cursor_line_style(ratatui::style::Style::default()); - let mut this = Self { textarea }; + let mut this = Self { + textarea, + command_popup: None, + app_event_tx, + }; this.update_border(has_input_focus); this } @@ -43,9 +55,87 @@ impl ChatComposer<'_> { self.update_border(has_focus); } - /// Handle key event when no overlay is present. + /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let result = match self.command_popup { + Some(_) => self.handle_key_event_with_popup(key_event), + None => self.handle_key_event_without_popup(key_event), + }; + + // Update (or hide/show) popup after processing the key. + self.sync_command_popup(); + + result + } + + /// Handle key event when the slash-command popup is visible. + fn handle_key_event_with_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let Some(popup) = self.command_popup.as_mut() else { + tracing::error!("handle_key_event_with_popup called without an active popup"); + return (InputResult::None, false); + }; + match key_event.into() { + Input { key: Key::Up, .. } => { + popup.move_up(); + (InputResult::None, true) + } + Input { key: Key::Down, .. } => { + popup.move_down(); + (InputResult::None, true) + } + Input { key: Key::Tab, .. } => { + if let Some(cmd) = popup.selected_command() { + let first_line = self + .textarea + .lines() + .first() + .map(|s| s.as_str()) + .unwrap_or(""); + + let starts_with_cmd = first_line + .trim_start() + .starts_with(&format!("/{}", cmd.command())); + + if !starts_with_cmd { + self.textarea.select_all(); + self.textarea.cut(); + let _ = self.textarea.insert_str(format!("/{} ", cmd.command())); + } + } + (InputResult::None, true) + } + Input { + key: Key::Enter, + shift: false, + alt: false, + ctrl: false, + } => { + if let Some(cmd) = popup.selected_command() { + // Send command to the app layer. + if let Err(e) = self.app_event_tx.send(AppEvent::DispatchCommand(*cmd)) { + tracing::error!("failed to send DispatchCommand event: {e}"); + } + + // Clear textarea so no residual text remains. + self.textarea.select_all(); + self.textarea.cut(); + + // Hide popup since the command has been dispatched. + self.command_popup = None; + return (InputResult::None, true); + } + // Fallback to default newline handling if no command selected. + self.handle_key_event_without_popup(key_event) + } + input => self.handle_input_basic(input), + } + } + + /// Handle key event when no popup is visible. + fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let input: Input = key_event.into(); + match input { Input { key: Key::Enter, shift: false, @@ -69,16 +159,52 @@ impl ChatComposer<'_> { self.textarea.insert_newline(); (InputResult::None, true) } - input => { - self.textarea.input(input); - (InputResult::None, true) - } + input => self.handle_input_basic(input), } } - pub fn calculate_required_height(&self, _area: &Rect) -> u16 { + /// Handle generic Input events that modify the textarea content. + fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) { + self.textarea.input(input); + (InputResult::None, true) + } + + /// Synchronize `self.command_popup` with the current text in the + /// textarea. This must be called after every modification that can change + /// the text so the popup is shown/updated/hidden as appropriate. + fn sync_command_popup(&mut self) { + // Inspect only the first line to decide whether to show the popup. In + // the common case (no leading slash) we avoid copying the entire + // textarea contents. + let first_line = self + .textarea + .lines() + .first() + .map(|s| s.as_str()) + .unwrap_or(""); + + if first_line.starts_with('/') { + // Create popup lazily when the user starts a slash command. + let popup = self.command_popup.get_or_insert_with(CommandPopup::new); + + // Forward *only* the first line since `CommandPopup` only needs + // the command token. + popup.on_composer_text_change(first_line.to_string()); + } else if self.command_popup.is_some() { + // Remove popup when '/' is no longer the first character. + self.command_popup = None; + } + } + + pub fn calculate_required_height(&self, area: &Rect) -> u16 { let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS); - rows as u16 + BORDER_LINES + let num_popup_rows = if let Some(popup) = &self.command_popup { + popup.calculate_required_height(area) + } else { + 0 + }; + + rows as u16 + BORDER_LINES + num_popup_rows } fn update_border(&mut self, has_focus: bool) { @@ -108,10 +234,37 @@ impl ChatComposer<'_> { .border_style(bs.border_style), ); } + + pub(crate) fn is_command_popup_visible(&self) -> bool { + self.command_popup.is_some() + } } impl WidgetRef for &ChatComposer<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - self.textarea.render(area, buf); + if let Some(popup) = &self.command_popup { + let popup_height = popup.calculate_required_height(&area); + + // Split the provided rect so that the popup is rendered at the + // *top* and the textarea occupies the remaining space below. + let popup_rect = Rect { + x: area.x, + y: area.y, + width: area.width, + height: popup_height.min(area.height), + }; + + let textarea_rect = Rect { + x: area.x, + y: area.y + popup_rect.height, + width: area.width, + height: area.height.saturating_sub(popup_rect.height), + }; + + popup.render(popup_rect, buf); + self.textarea.render(textarea_rect, buf); + } else { + self.textarea.render(area, buf); + } } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs new file mode 100644 index 00000000..419223a9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -0,0 +1,191 @@ +use std::collections::HashMap; + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Cell; +use ratatui::widgets::Row; +use ratatui::widgets::Table; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; + +use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; + +const MAX_POPUP_ROWS: usize = 5; + +use ratatui::style::Modifier; + +pub(crate) struct CommandPopup { + command_filter: String, + all_commands: HashMap<&'static str, SlashCommand>, + selected_idx: Option, +} + +impl CommandPopup { + pub(crate) fn new() -> Self { + Self { + command_filter: String::new(), + all_commands: built_in_slash_commands(), + selected_idx: None, + } + } + + /// Update the filter string based on the current composer text. The text + /// 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 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(); + } + + // Reset or clamp selected index based on new filtered list. + let matches_len = self.filtered_commands().len(); + self.selected_idx = match matches_len { + 0 => None, + _ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)), + }; + } + + /// Determine the preferred height of the popup. This is the number of + /// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the + /// table/border overhead (one line at the top and one at the bottom). + pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 { + let matches = self.filtered_commands(); + let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16; + // Account for the border added by the Block that wraps the table. + // 2 = one line at the top, one at the bottom. + row_count + 2 + } + + /// 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<&SlashCommand> { + let mut cmds: Vec<&SlashCommand> = self + .all_commands + .values() + .filter(|cmd| { + if self.command_filter.is_empty() { + true + } else { + cmd.command() + .starts_with(&self.command_filter.to_ascii_lowercase()) + } + }) + .collect(); + + // Sort the commands alphabetically so the order is stable and + // predictable. + cmds.sort_by(|a, b| a.command().cmp(b.command())); + cmds + } + + /// Move the selection cursor one step up. + pub(crate) fn move_up(&mut self) { + if let Some(len) = self.filtered_commands().len().checked_sub(1) { + if len == usize::MAX { + return; + } + } + + if let Some(idx) = self.selected_idx { + if idx > 0 { + self.selected_idx = Some(idx - 1); + } + } else if !self.filtered_commands().is_empty() { + self.selected_idx = Some(0); + } + } + + /// Move the selection cursor one step down. + pub(crate) fn move_down(&mut self) { + let matches_len = self.filtered_commands().len(); + if matches_len == 0 { + self.selected_idx = None; + return; + } + + match self.selected_idx { + Some(idx) if idx + 1 < matches_len => { + self.selected_idx = Some(idx + 1); + } + None => { + self.selected_idx = Some(0); + } + _ => {} + } + } + + /// Return currently selected command, if any. + pub(crate) fn selected_command(&self) -> Option<&SlashCommand> { + let matches = self.filtered_commands(); + self.selected_idx.and_then(|idx| matches.get(idx).copied()) + } +} + +impl WidgetRef for CommandPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let style = Style::default().bg(Color::Blue).fg(Color::White); + + let matches = self.filtered_commands(); + + let mut rows: Vec = Vec::new(); + let visible_matches: Vec<&SlashCommand> = + matches.into_iter().take(MAX_POPUP_ROWS).collect(); + + if visible_matches.is_empty() { + rows.push(Row::new(vec![ + Cell::from("").style(style), + Cell::from("No matching commands").style(style.add_modifier(Modifier::ITALIC)), + ])); + } else { + for (idx, cmd) in visible_matches.iter().enumerate() { + let highlight = Style::default().bg(Color::White).fg(Color::Blue); + let cmd_style = if Some(idx) == self.selected_idx { + highlight + } else { + style + }; + + rows.push(Row::new(vec![ + Cell::from(cmd.command().to_string()).style(cmd_style), + Cell::from(cmd.description().to_string()).style(style), + ])); + } + } + + use ratatui::layout::Constraint; + + let table = Table::new(rows, [Constraint::Length(15), Constraint::Min(10)]) + .style(style) + .column_spacing(1) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(style), + ); + + table.render(area, buf); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ca606428..33b8b9ea 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -15,6 +15,7 @@ use crate::user_approval_widget::ApprovalRequest; mod approval_modal_view; mod bottom_pane_view; mod chat_composer; +mod command_popup; mod status_indicator_view; pub(crate) use chat_composer::ChatComposer; @@ -45,7 +46,7 @@ pub(crate) struct BottomPaneParams { impl BottomPane<'_> { pub fn new(params: BottomPaneParams) -> Self { Self { - composer: ChatComposer::new(params.has_input_focus), + composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()), active_view: None, app_event_tx: params.app_event_tx, has_input_focus: params.has_input_focus, @@ -168,6 +169,11 @@ impl BottomPane<'_> { pub(crate) fn request_redraw(&self) -> Result<(), SendError> { self.app_event_tx.send(AppEvent::Redraw) } + + /// Returns true when the slash-command popup inside the composer is visible. + pub(crate) fn is_command_popup_visible(&self) -> bool { + self.active_view.is_none() && self.composer.is_command_popup_visible() + } } impl WidgetRef for &BottomPane<'_> { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c7ffe734..a63f6461 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -124,8 +124,12 @@ impl ChatWidget<'_> { &mut self, key_event: KeyEvent, ) -> std::result::Result<(), SendError> { - // Special-case : does not get dispatched to child components. - if matches!(key_event.code, crossterm::event::KeyCode::Tab) { + // Special-case : normally toggles focus between history and bottom panes. + // However, when the slash-command popup is visible we forward the key + // to the bottom pane so it can handle auto-completion. + if matches!(key_event.code, crossterm::event::KeyCode::Tab) + && !self.bottom_pane.is_command_popup_visible() + { self.input_focus = match self.input_focus { InputFocus::HistoryPane => InputFocus::BottomPane, InputFocus::BottomPane => InputFocus::HistoryPane, @@ -149,18 +153,7 @@ impl ChatWidget<'_> { InputFocus::BottomPane => { match self.bottom_pane.handle_key_event(key_event)? { InputResult::Submitted(text) => { - // Special client‑side commands start with a leading slash. - let trimmed = text.trim(); - match trimmed { - "/clear" => { - // Clear the current conversation history without exiting. - self.conversation_history.clear(); - self.request_redraw()?; - } - _ => { - self.submit_user_message(text)?; - } - } + self.submit_user_message(text)?; } InputResult::None => {} } @@ -211,6 +204,13 @@ impl ChatWidget<'_> { Ok(()) } + pub(crate) fn clear_conversation_history( + &mut self, + ) -> std::result::Result<(), SendError> { + self.conversation_history.clear(); + self.request_redraw() + } + pub(crate) fn handle_codex_event( &mut self, event: Event, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e0b6274c..3d339d26 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -26,6 +26,7 @@ mod history_cell; mod log_layer; mod markdown; mod scroll_event_helper; +mod slash_command; mod status_indicator_widget; mod tui; mod user_approval_widget; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs new file mode 100644 index 00000000..d5befd9d --- /dev/null +++ b/codex-rs/tui/src/slash_command.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; + +use strum::IntoEnumIterator; +use strum_macros::AsRefStr; // derive macro +use strum_macros::EnumIter; +use strum_macros::EnumString; +use strum_macros::IntoStaticStr; + +/// Commands that can be invoked by starting a message with a leading slash. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr)] +#[strum(serialize_all = "kebab-case")] +pub enum SlashCommand { + Clear, + Quit, +} + +impl SlashCommand { + /// User-visible description shown in the popup. + pub fn description(self) -> &'static str { + match self { + SlashCommand::Clear => "Clear the chat history.", + SlashCommand::Quit => "Exit the application.", + } + } + + /// Command string without the leading '/'. Provided for compatibility with + /// existing code that expects a method named `command()`. + pub fn command(self) -> &'static str { + self.into() + } +} + +/// Return all built-in commands in a HashMap keyed by their command string. +pub fn built_in_slash_commands() -> HashMap<&'static str, SlashCommand> { + SlashCommand::iter().map(|c| (c.command(), c)).collect() +}