Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion codex-rs/execpolicy/src/exec_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ impl ExecCall {

impl Display for ExecCall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.program)?;
write!(f, "{program}", program = self.program)?;
for arg in &self.args {
write!(f, " {arg}")?;
}
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ uuid = "1"
[dev-dependencies]
insta = "1.43.1"
pretty_assertions = "1"
tempfile = "3"
63 changes: 51 additions & 12 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,16 +236,51 @@ impl ChatComposer<'_> {
ctrl: false,
} => {
if let Some(cmd) = popup.selected_command() {
// Send command to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));

// Clear textarea so no residual text remains.
self.textarea.select_all();
self.textarea.cut();

// Hide popup since the command has been dispatched.
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
match cmd {
crate::bottom_pane::command_popup::CommandEntry::BuiltIn(builtin) => {
// Send command to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand(*builtin));

// Clear textarea so no residual text remains.
self.textarea.select_all();
self.textarea.cut();

// Hide popup since the command has been dispatched.
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
crate::bottom_pane::command_popup::CommandEntry::Custom { name } => {
// Determine if command already typed.
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");

let cmd_with_slash = format!("/{name}");
let starts_with_cmd =
first_line.trim_start().starts_with(&cmd_with_slash);

if !starts_with_cmd {
// Autocomplete: replace existing text with the chosen command.
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(format!("{cmd_with_slash} "));
// Keep popup hidden; user can now add args and press Enter again.
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
} else {
// Command already present -> let default handling submit the message.
self.active_popup = ActivePopup::None;
// Delegate to default handling to submit the message.
return self.handle_key_event_without_popup(KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
}
}
}
}
// Fallback to default newline handling if no command selected.
self.handle_key_event_without_popup(key_event)
Expand Down Expand Up @@ -582,16 +617,20 @@ impl ChatComposer<'_> {
.unwrap_or("");

let input_starts_with_slash = first_line.starts_with('/');

// Always show popup when text starts with a slash so users can
// discover both built-in *and* custom commands (e.g. "/project:").
let skip_popup = false;
match &mut self.active_popup {
ActivePopup::Command(popup) => {
if input_starts_with_slash {
if input_starts_with_slash && !skip_popup {
popup.on_composer_text_change(first_line.to_string());
} else {
self.active_popup = ActivePopup::None;
}
}
_ => {
if input_starts_with_slash {
if input_starts_with_slash && !skip_popup {
let mut command_popup = CommandPopup::new();
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
Expand Down
55 changes: 42 additions & 13 deletions codex-rs/tui/src/bottom_pane/command_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,33 @@ use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;

use crate::custom_slash_command::discover_custom_commands;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;

/// Entry representing either a built-in or a custom slash command.
#[derive(Clone)]
pub(super) enum CommandEntry {
BuiltIn(SlashCommand),
Custom { name: String },
}

impl CommandEntry {
pub(super) fn command(&self) -> &str {
match self {
CommandEntry::BuiltIn(cmd) => cmd.command(),
CommandEntry::Custom { name } => name.as_str(),
}
}

pub(super) fn description(&self) -> &str {
match self {
CommandEntry::BuiltIn(cmd) => cmd.description(),
CommandEntry::Custom { .. } => "Custom command",
}
}
}

const MAX_POPUP_ROWS: usize = 5;
/// Ideally this is enough to show the longest command name.
const FIRST_COLUMN_WIDTH: u16 = 20;
Expand All @@ -23,15 +47,25 @@ use ratatui::style::Modifier;

pub(crate) struct CommandPopup {
command_filter: String,
all_commands: Vec<(&'static str, SlashCommand)>,
all_commands: Vec<CommandEntry>,
selected_idx: Option<usize>,
}

impl CommandPopup {
pub(crate) fn new() -> Self {
let mut all_commands: Vec<CommandEntry> = built_in_slash_commands()
.into_iter()
.map(|(_name, cmd)| CommandEntry::BuiltIn(cmd))
.collect();

// Discover custom commands on disk and append them to the list.
for name in discover_custom_commands() {
all_commands.push(CommandEntry::Custom { name });
}

Self {
command_filter: String::new(),
all_commands: built_in_slash_commands(),
all_commands,
selected_idx: None,
}
}
Expand Down Expand Up @@ -77,21 +111,16 @@ 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<&CommandEntry> {
self.all_commands
.iter()
.filter_map(|(_name, cmd)| {
if self.command_filter.is_empty()
.filter(|cmd| {
self.command_filter.is_empty()
|| cmd
.command()
.starts_with(&self.command_filter.to_ascii_lowercase())
{
Some(cmd)
} else {
None
}
})
.collect::<Vec<&SlashCommand>>()
.collect()
}

/// Move the selection cursor one step up.
Expand Down Expand Up @@ -131,7 +160,7 @@ impl CommandPopup {
}

/// Return currently selected command, if any.
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
pub(crate) fn selected_command(&self) -> Option<&CommandEntry> {
let matches = self.filtered_commands();
self.selected_idx.and_then(|idx| matches.get(idx).copied())
}
Expand All @@ -142,7 +171,7 @@ impl WidgetRef for CommandPopup {
let matches = self.filtered_commands();

let mut rows: Vec<Row> = Vec::new();
let visible_matches: Vec<&SlashCommand> =
let visible_matches: Vec<&CommandEntry> =
matches.into_iter().take(MAX_POPUP_ROWS).collect();

if visible_matches.is_empty() {
Expand Down
20 changes: 17 additions & 3 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,24 @@ impl ChatWidget<'_> {

fn submit_user_message(&mut self, user_message: UserMessage) {
let UserMessage { text, image_paths } = user_message;
use crate::custom_slash_command::expand_custom_command;
use std::path::Path;

let mut items: Vec<InputItem> = Vec::new();

if !text.is_empty() {
items.push(InputItem::Text { text: text.clone() });
let expanded_text = if text.starts_with('/') {
// Attempt to expand custom command. If expansion fails, fall back
// to original text.
let cwd = Path::new(&self.config.cwd);
expand_custom_command(&text, cwd).unwrap_or(text.clone())
} else {
text.clone()
};

if !expanded_text.is_empty() {
items.push(InputItem::Text {
text: expanded_text.clone(),
});
}

for path in image_paths {
Expand All @@ -198,7 +212,7 @@ impl ChatWidget<'_> {
tracing::error!("failed to send message: {e}");
});

// Persist the text to cross-session message history.
// Persist the original text (the slash command, if any) to cross-session history.
if !text.is_empty() {
self.codex_op_tx
.send(Op::AddToHistory { text: text.clone() })
Expand Down
Loading