diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6abe624051..8e166f8287 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -13,6 +13,8 @@ use tui_textarea::Input; use tui_textarea::Key; use tui_textarea::TextArea; +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. @@ -24,9 +26,10 @@ pub enum InputResult { None, } -pub(crate) struct ChatComposer<'a> { - textarea: TextArea<'a>, -} + pub(crate) struct ChatComposer<'a> { + textarea: TextArea<'a>, + command_popup: Option, + } impl ChatComposer<'_> { pub fn new(has_input_focus: bool) -> Self { @@ -34,7 +37,10 @@ impl ChatComposer<'_> { 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, + }; this.update_border(has_input_focus); this } @@ -43,9 +49,116 @@ impl ChatComposer<'_> { self.update_border(has_focus); } - /// Handle key event when no overlay is present. + /// 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 { + // Remove popup when '/' is no longer the first character. + self.command_popup = None; + } + } + + /// 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())); + } + + // hide popup + self.command_popup = None; + } + (InputResult::None, true) + } + Input { + key: Key::Enter, + shift: false, + alt: false, + ctrl: false, + } => { + if let Some(cmd) = popup.selected_command() { + // TODO: actually run command instead of submitting it to the model. + let _command_text = format!("/{}", cmd.command()); + self.textarea.select_all(); + self.textarea.cut(); + // Hide popup since command has been handled. + 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 +182,25 @@ impl ChatComposer<'_> { self.textarea.insert_newline(); (InputResult::None, true) } - input => { - self.textarea.input(input); - (InputResult::None, true) - } + input => self.handle_input_basic(input), } } + /// 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) + } + 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 mut total = rows as u16 + BORDER_LINES; + + if let Some(popup) = &self.command_popup { + total += popup.calculate_required_height(_area); + } + + total } fn update_border(&mut self, has_focus: bool) { @@ -108,10 +230,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 0000000000..d9e7c5634b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -0,0 +1,185 @@ +use std::collections::HashMap; + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +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, + 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) { + use ratatui::style::{Color, Style}; + use ratatui::widgets::{Block, Borders, BorderType, Cell, Row, Table, Widget}; + + 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 ca606428ae..1466ddf423 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; @@ -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 c7ffe73431..4794885570 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, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e0b6274c7d..3d339d26a1 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 0000000000..96774c873d --- /dev/null +++ b/codex-rs/tui/src/slash_command.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; + +/// Command that can be invoked via the composer by starting the message with a +/// slash followed by the command name. +#[derive(Debug, Clone)] +pub struct SlashCommand { + /// Command name without the leading slash. + command: &'static str, + + /// Command description suitable for display in the UI. + description: &'static str, +} + +impl SlashCommand { + /// Return the command string without the leading slash. + pub fn command(&self) -> &str { + self.command + } + + /// Return the human-readable description for the command. + pub fn description(&self) -> &str { + self.description + } +} + +pub fn built_in_slash_commands() -> HashMap { + vec![ + SlashCommand { + command: "help", + description: "Show this help message.", + }, + SlashCommand { + command: "clear", + description: "Clear the chat history.", + }, + SlashCommand { + command: "reset", + description: "Reset the chat history.", + }, + SlashCommand { + command: "exit", + description: "Exit the application.", + }, + ] + .into_iter() + .map(|cmd| (cmd.command.to_owned(), cmd)) + .collect::>() +}