diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e7432bd2cd..ce234e0185 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -397,6 +397,21 @@ impl App<'_> { } } }, + AppEvent::DispatchAtCommand(at_command) => match at_command { + crate::at_command::AtCommand::Image => { + match crate::clipboard_paste::paste_image_to_temp_png() { + Ok((path, info)) => { + tracing::info!("at_command_image imported path={:?} width={} height={} format={}", path, info.width, info.height, info.encoded_format_label); + self.app_event_tx.send(AppEvent::AttachImage { path, width: info.width, height: info.height, format_label: info.encoded_format_label }); + } + Err(err) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_background_event(format!("image import failed: {err}")); + } + } + } + } + }, AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bb136565eb..f016594a7c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -4,6 +4,7 @@ use crossterm::event::KeyEvent; use ratatui::text::Line; use crate::slash_command::SlashCommand; +use crate::at_command::AtCommand; #[allow(clippy::large_enum_variant)] pub(crate) enum AppEvent { @@ -37,6 +38,8 @@ pub(crate) enum AppEvent { /// Dispatch a recognized slash command from the UI (composer) to the app /// layer so it can be handled centrally. DispatchCommand(SlashCommand), + /// Dispatch an @ command. + DispatchAtCommand(AtCommand), /// 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/at_command.rs b/codex-rs/tui/src/at_command.rs new file mode 100644 index 0000000000..3ecc6c22b8 --- /dev/null +++ b/codex-rs/tui/src/at_command.rs @@ -0,0 +1,22 @@ +use strum::IntoEnumIterator; +use strum_macros::{AsRefStr, EnumIter, EnumString, IntoStaticStr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr)] +#[strum(serialize_all = "kebab-case")] +pub enum AtCommand { + // Order is presentation order in @ popup. + Image, // import image from clipboard +} + +impl AtCommand { + pub fn description(self) -> &'static str { + match self { + AtCommand::Image => "Import an image from the system clipboard (can be used with ctrl+v).", + } + } + pub fn command(self) -> &'static str { self.into() } +} + +pub fn built_in_at_commands() -> Vec<(&'static str, AtCommand)> { + AtCommand::iter().map(|c| (c.command(), c)).collect() +} \ No newline at end of file diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f74faf2c2e..f5092e92d9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -17,6 +17,8 @@ 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::SlashCommand; // for typing +use crate::at_command::AtCommand; // for typing use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -49,8 +51,9 @@ pub(crate) struct ChatComposer<'a> { /// Popup state – at most one can be visible at any time. enum ActivePopup { None, - Command(CommandPopup), + Slash(CommandPopup), File(FileSearchPopup), + At(CommandPopup), } impl ChatComposer<'_> { @@ -140,8 +143,13 @@ impl ChatComposer<'_> { } else { self.textarea.insert_str(&pasted); } - self.sync_command_popup(); - self.sync_file_search_popup(); + self.sync_slash_command_popup(); + if !matches!(self.active_popup, ActivePopup::Slash(_)) { + self.sync_at_command_popup(); + if !matches!(self.active_popup, ActivePopup::At(_)) { + self.sync_file_search_popup(); + } + } true } @@ -181,17 +189,24 @@ impl ChatComposer<'_> { /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let result = match &mut self.active_popup { - ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), + ActivePopup::Slash(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::At(_) => self.handle_key_event_with_at_popup(key_event), ActivePopup::None => self.handle_key_event_without_popup(key_event), }; // Update (or hide/show) popup after processing the key. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { + self.sync_slash_command_popup(); + if matches!(self.active_popup, ActivePopup::Slash(_)) { self.dismissed_file_popup_token = None; } else { - self.sync_file_search_popup(); + // Try @-command popup next if slash popup not active. + self.sync_at_command_popup(); + if matches!(self.active_popup, ActivePopup::At(_)) { + self.dismissed_file_popup_token = None; + } else { + self.sync_file_search_popup(); + } } result @@ -199,7 +214,7 @@ impl ChatComposer<'_> { /// Handle key event when the slash-command popup is visible. fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - let ActivePopup::Command(popup) = &mut self.active_popup else { + let ActivePopup::Slash(popup) = &mut self.active_popup else { unreachable!(); }; @@ -301,6 +316,38 @@ impl ChatComposer<'_> { } } + /// Handle key event when the @-command popup is visible. + fn handle_key_event_with_at_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let ActivePopup::At(popup) = &mut self.active_popup else { unreachable!() }; + 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() { + self.app_event_tx.send(AppEvent::DispatchAtCommand(*cmd)); + self.textarea.select_all(); + self.textarea.cut(); + self.active_popup = ActivePopup::None; + return (InputResult::None, true); + } + self.handle_key_event_without_popup(key_event) + } + input => self.handle_input_basic(input), + } + } + /// Extract the `@token` that the cursor is currently positioned on, if any. /// /// The returned string **does not** include the leading `@`. @@ -632,7 +679,7 @@ impl ChatComposer<'_> { /// 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) { + fn sync_slash_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. @@ -645,7 +692,7 @@ impl ChatComposer<'_> { let input_starts_with_slash = first_line.starts_with('/'); match &mut self.active_popup { - ActivePopup::Command(popup) => { + ActivePopup::Slash(popup) => { if input_starts_with_slash { popup.on_composer_text_change(first_line.to_string()); } else { @@ -654,9 +701,9 @@ impl ChatComposer<'_> { } _ => { if input_starts_with_slash { - let mut command_popup = CommandPopup::new(); + let mut command_popup = CommandPopup::slash(); command_popup.on_composer_text_change(first_line.to_string()); - self.active_popup = ActivePopup::Command(command_popup); + self.active_popup = ActivePopup::Slash(command_popup); } } } @@ -698,6 +745,23 @@ impl ChatComposer<'_> { self.dismissed_file_popup_token = None; } + // NEW: Synchronize @-command popup. + fn sync_at_command_popup(&mut self) { + // Do not show if slash popup active. + if matches!(self.active_popup, ActivePopup::Slash(_)) { return; } + + let first_line = self.textarea.lines().first().map(|s| s.as_str()).unwrap_or(""); + let input_starts_with_at = first_line.starts_with('@'); + match &mut self.active_popup { + ActivePopup::At(popup) => { + if input_starts_with_at { popup.on_composer_text_change(first_line.to_string()); } else { self.active_popup = ActivePopup::None; } + } + _ => { + if input_starts_with_at { let mut popup: CommandPopup = CommandPopup::at(); popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::At(popup); } + } + } + } + fn update_border(&mut self, has_focus: bool) { struct BlockState { right_title: Line<'static>, @@ -737,7 +801,7 @@ impl ChatComposer<'_> { impl WidgetRef for &ChatComposer<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { match &self.active_popup { - ActivePopup::Command(popup) => { + ActivePopup::Slash(popup) => { let popup_height = popup.calculate_required_height(&area); // Split the provided rect so that the popup is rendered at the @@ -782,6 +846,13 @@ impl WidgetRef for &ChatComposer<'_> { ActivePopup::None => { self.textarea.render(area, buf); } + ActivePopup::At(popup) => { + let popup_height = popup.calculate_required_height(&area); + 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); + } } } } @@ -1315,4 +1386,23 @@ mod tests { assert!(composer.textarea.lines().join("\n").contains(&placeholder2) == false); assert!(composer.attached_images.is_empty()); } + + #[test] + fn at_image_command_triggers_dispatch() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::sync::mpsc::Receiver; + use crate::app_event::AppEvent; + use crate::at_command::AtCommand; + + let (tx, rx):(std::sync::mpsc::Sender, Receiver) = std::sync::mpsc::channel(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new(true, sender); + // Type '@' to open popup + composer.handle_key_event(KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE)); + // Press Enter (should dispatch Image since only option) + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + // Expect a DispatchCommand(Image) + let ev = rx.try_recv().expect("expected an event"); + match ev { AppEvent::DispatchAtCommand(AtCommand::Image) => {}, other => panic!("unexpected event: {:?}", other) } + } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index fd865047ef..c0e2f05643 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -14,6 +14,21 @@ use ratatui::widgets::WidgetRef; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; +use crate::at_command::{AtCommand, built_in_at_commands}; // NEW + +pub trait CommandInfo: Copy { + fn command(&self) -> &'static str; + fn description(&self) -> &'static str; +} + +impl CommandInfo for SlashCommand { + fn command(&self) -> &'static str { SlashCommand::command(*self) } + fn description(&self) -> &'static str { SlashCommand::description(*self) } +} +impl CommandInfo for AtCommand { + fn command(&self) -> &'static str { AtCommand::command(*self) } + fn description(&self) -> &'static str { AtCommand::description(*self) } +} const MAX_POPUP_ROWS: usize = 5; /// Ideally this is enough to show the longest command name. @@ -21,44 +36,30 @@ const FIRST_COLUMN_WIDTH: u16 = 20; use ratatui::style::Modifier; -pub(crate) struct CommandPopup { +pub(crate) struct CommandPopup { // generic + prefix: char, command_filter: String, - all_commands: Vec<(&'static str, SlashCommand)>, + all_commands: Vec<(&'static str, C)>, selected_idx: Option, } -impl CommandPopup { - pub(crate) fn new() -> Self { - Self { - command_filter: String::new(), - all_commands: built_in_slash_commands(), - selected_idx: None, - } +impl CommandPopup { + pub(crate) fn new(prefix: char, all_commands: Vec<(&'static str, C)>) -> Self { + Self { prefix, command_filter: String::new(), all_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. + /// 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 token. 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`. + if first_line.starts_with(self.prefix) { + let stripped = &first_line[self.prefix.len_utf8()..]; 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(); - } + } else { self.command_filter.clear(); } // Reset or clamp selected index based on new filtered list. let matches_len = self.filtered_commands().len(); @@ -81,7 +82,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<&SlashCommand> { + fn filtered_commands(&self) -> Vec<&C> { self.all_commands .iter() .filter_map(|(_name, cmd)| { @@ -95,7 +96,7 @@ impl CommandPopup { None } }) - .collect::>() + .collect::>() } /// Move the selection cursor one step up. @@ -135,18 +136,26 @@ impl CommandPopup { } /// Return currently selected command, if any. - pub(crate) fn selected_command(&self) -> Option<&SlashCommand> { + pub(crate) fn selected_command(&self) -> Option<&C> { let matches = self.filtered_commands(); self.selected_idx.and_then(|idx| matches.get(idx).copied()) } } -impl WidgetRef for CommandPopup { +impl CommandPopup { + pub(crate) fn slash() -> Self { CommandPopup::new('/', built_in_slash_commands()) } +} + +impl CommandPopup { + pub(crate) fn at() -> Self { CommandPopup::new('@', built_in_at_commands()) } +} + +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<&SlashCommand> = + let visible_matches: Vec<&C> = matches.into_iter().take(MAX_POPUP_ROWS).collect(); if visible_matches.is_empty() { @@ -168,7 +177,7 @@ impl WidgetRef for CommandPopup { }; rows.push(Row::new(vec![ - Cell::from(format!("/{}", cmd.command())).style(cmd_style), + Cell::from(format!("{}{}", self.prefix, cmd.command())).style(cmd_style), Cell::from(cmd.description().to_string()).style(desc_style), ])); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 695d1a8411..2b74ee263a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -22,6 +22,7 @@ mod status_indicator_view; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::InputResult; +pub(crate) use command_popup::CommandInfo; use approval_modal_view::ApprovalModalView; use status_indicator_view::StatusIndicatorView; diff --git a/codex-rs/tui/src/command.rs b/codex-rs/tui/src/command.rs new file mode 100644 index 0000000000..74f7b84c28 --- /dev/null +++ b/codex-rs/tui/src/command.rs @@ -0,0 +1,18 @@ +use crate::slash_command::SlashCommand; +use crate::at_command::AtCommand; +use crate::bottom_pane::CommandInfo; + +#[derive(Clone, Copy, Debug)] +pub enum Command { + Slash(SlashCommand), + At(AtCommand), +} + +impl CommandInfo for Command { + fn command(&self) -> &'static str { + match self { Command::Slash(s) => s.command(), Command::At(a) => a.command() } + } + fn description(&self) -> &'static str { + match self { Command::Slash(s) => s.description(), Command::At(a) => a.description() } + } +} \ No newline at end of file diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f680450ca9..da3553301d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -45,6 +45,8 @@ mod text_formatting; mod tui; mod user_approval_widget; mod clipboard_paste; +mod at_command; +mod command; pub use cli::Cli;