mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
add "@ command"
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
22
codex-rs/tui/src/at_command.rs
Normal file
22
codex-rs/tui/src/at_command.rs
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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<SlashCommand>),
|
||||
File(FileSearchPopup),
|
||||
At(CommandPopup<AtCommand>),
|
||||
}
|
||||
|
||||
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<AtCommand> = 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<AppEvent>, Receiver<AppEvent>) = 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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<C: CommandInfo> { // generic
|
||||
prefix: char,
|
||||
command_filter: String,
|
||||
all_commands: Vec<(&'static str, SlashCommand)>,
|
||||
all_commands: Vec<(&'static str, C)>,
|
||||
selected_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
command_filter: String::new(),
|
||||
all_commands: built_in_slash_commands(),
|
||||
selected_idx: None,
|
||||
}
|
||||
impl<C: CommandInfo> CommandPopup<C> {
|
||||
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::<Vec<&SlashCommand>>()
|
||||
.collect::<Vec<&C>>()
|
||||
}
|
||||
|
||||
/// 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<SlashCommand> {
|
||||
pub(crate) fn slash() -> Self { CommandPopup::new('/', built_in_slash_commands()) }
|
||||
}
|
||||
|
||||
impl CommandPopup<AtCommand> {
|
||||
pub(crate) fn at() -> Self { CommandPopup::new('@', built_in_at_commands()) }
|
||||
}
|
||||
|
||||
impl<C: CommandInfo> WidgetRef for CommandPopup<C> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let matches = self.filtered_commands();
|
||||
|
||||
let mut rows: Vec<Row> = 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),
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
18
codex-rs/tui/src/command.rs
Normal file
18
codex-rs/tui/src/command.rs
Normal file
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user