Skip to content
Merged
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
274 changes: 0 additions & 274 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8002,280 +8002,6 @@ mod tests {
);
}

#[test]
fn slash_completion_preserves_existing_draft_tail_for_goal() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;

let draft = "preserve this draft as the goal objective";
let draft_chars = draft.chars().collect::<Vec<_>>();

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_goal_command_enabled(/*enabled*/ true);

type_chars_humanlike(&mut composer, &draft_chars);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
type_chars_humanlike(&mut composer, &['/', 'g', 'o']);

let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));

assert_eq!(result, InputResult::None);
assert_eq!(
composer.draft.textarea.text(),
"/goal preserve this draft as the goal objective"
);
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_goal_command_enabled(/*enabled*/ true);

type_chars_humanlike(&mut composer, &draft_chars);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
type_chars_humanlike(&mut composer, &['/', 'g', 'o']);

let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

assert_eq!(
result,
InputResult::CommandWithArgs(SlashCommand::Goal, draft.to_string(), Vec::new())
);
assert_eq!(
composer.draft.textarea.text(),
"/goal preserve this draft as the goal objective"
);

let command = "/re use the text after the slash command as review instructions";
let review_args = "use the text after the slash command as review instructions";
let cursor_after_re = "/re".len();
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.draft.textarea.set_text_clearing_elements(command);
composer.draft.textarea.set_cursor(cursor_after_re);
composer.sync_popups();

let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

assert_eq!(
result,
InputResult::CommandWithArgs(SlashCommand::Review, review_args.to_string(), Vec::new())
);
assert_eq!(
composer.draft.textarea.text(),
"/review use the text after the slash command as review instructions"
);

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.draft.textarea.set_text_clearing_elements(command);
composer.draft.textarea.set_cursor(cursor_after_re);
composer.sync_popups();

let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));

assert_eq!(result, InputResult::None);
assert_eq!(
composer.draft.textarea.text(),
"/review use the text after the slash command as review instructions"
);

let make_goal_composer = |cursor| {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_goal_command_enabled(/*enabled*/ true);
composer
.draft
.textarea
.set_text_clearing_elements("/goal ship it");
composer.draft.textarea.set_cursor(cursor);
composer.sync_popups();
composer
};

for cursor in [0, 1] {
let mut composer = make_goal_composer(cursor);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));

assert_eq!(result, InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/goal ship it");
}

for cursor in [0, 1] {
let mut composer = make_goal_composer(cursor);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

assert_eq!(
result,
InputResult::CommandWithArgs(SlashCommand::Goal, "ship it".to_string(), Vec::new())
);
assert_eq!(composer.draft.textarea.text(), "/goal ship it");
}
}

#[test]
fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;

let draft = "preserve this draft only for opted-in slash commands";
let draft_chars = draft.chars().collect::<Vec<_>>();
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);

type_chars_humanlike(&mut composer, &draft_chars);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);

let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));

assert_eq!(result, InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/model ");
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
}

#[test]
fn slash_completion_does_not_turn_command_suffix_into_args() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;

let make_review_composer = || {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer
.draft
.textarea
.set_text_clearing_elements("/review");
composer.draft.textarea.set_cursor("/re".len());
composer.sync_popups();
composer
};

let mut composer = make_review_composer();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));

assert_eq!(result, InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/review ");

let mut composer = make_review_composer();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

assert_eq!(result, InputResult::Command(SlashCommand::Review));
assert!(composer.draft.textarea.is_empty());
}

#[test]
fn slash_completion_preserves_draft_tail_that_completes_command_name() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;

let draft = "view the diff";
let make_review_composer = || {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &draft.chars().collect::<Vec<_>>());
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
type_chars_humanlike(&mut composer, &['/', 'r', 'e']);
composer
};

let mut composer = make_review_composer();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));

assert_eq!(result, InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/review view the diff");

let mut composer = make_review_composer();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

assert_eq!(
result,
InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new())
);
assert_eq!(composer.draft.textarea.text(), "/review view the diff");
}

#[test]
fn slash_tab_completion_wins_over_queueing_while_task_running() {
use crossterm::event::KeyCode;
Expand Down
88 changes: 86 additions & 2 deletions codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,7 @@ impl ChatComposer {
let CommandItem::Builtin(cmd) = selected_cmd else {
return false;
};
let cmd = *cmd;
if !cmd.preserves_draft_tail_on_completion() {
if !cmd.supports_inline_args() {
Comment thread
etraut-openai marked this conversation as resolved.
return false;
}

Expand All @@ -405,6 +404,9 @@ impl ChatComposer {
.unwrap_or(first_line_end);
let typed_command_name = &text[1..command_token_end];
let rest_after_token_is_empty = text[command_token_end..].trim().is_empty();
if rest_after_token_is_empty && (cursor <= 1 || cursor >= command_token_end) {
return false;
}
let replace_end =
if cursor <= 1 || (typed_command_name == cmd.command() && rest_after_token_is_empty) {
command_token_end
Expand Down Expand Up @@ -565,3 +567,85 @@ fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)>

Some((name, rest))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;

fn test_composer() -> ChatComposer {
let (tx, _rx) = unbounded_channel::<AppEvent>();
ChatComposer::new(
/*has_input_focus*/ true,
AppEventSender::new(tx),
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
)
}

fn press(composer: &mut ChatComposer, code: KeyCode) -> InputResult {
composer
.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE))
.0
}

fn composer_with_text_at_cursor(text: &str, cursor: usize) -> ChatComposer {
let mut composer = test_composer();
composer.draft.textarea.set_text_clearing_elements(text);
composer.draft.textarea.set_cursor(cursor);
composer.sync_popups();
composer
}

fn composer_with_draft_tail(prefix: &str, draft: &str) -> ChatComposer {
composer_with_text_at_cursor(&format!("{prefix}{draft}"), prefix.len())
}

#[test]
fn slash_completion_preserves_existing_draft_tail_for_inline_arg_commands() {
let draft = "view the diff";
let expected_text = "/review view the diff";

let mut composer = composer_with_draft_tail("/re", draft);
assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
assert_eq!(composer.draft.textarea.text(), expected_text);
assert_eq!(composer.draft.textarea.cursor(), expected_text.len());

let mut composer = composer_with_draft_tail("/re", draft);
assert_eq!(
press(&mut composer, KeyCode::Enter),
InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new())
);
assert_eq!(composer.draft.textarea.text(), expected_text);
}

#[test]
fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() {
let mut composer = composer_with_draft_tail(
"/mo",
"preserve this draft only for opted-in slash commands",
);

assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/model ");
assert_eq!(composer.draft.textarea.cursor(), "/model ".len());
}

#[test]
fn slash_completion_does_not_turn_command_suffix_into_args() {
let mut composer = composer_with_text_at_cursor("/review", "/re".len());
assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/review ");

let mut composer = composer_with_text_at_cursor("/review", "/re".len());
assert_eq!(
press(&mut composer, KeyCode::Enter),
InputResult::Command(SlashCommand::Review)
);
assert!(composer.draft.textarea.is_empty());
}
}
6 changes: 0 additions & 6 deletions codex-rs/tui/src/slash_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,6 @@ impl SlashCommand {
)
}

/// Whether slash-command completion should preserve draft text after the typed command prefix
/// and use it as inline arguments for this command.
pub fn preserves_draft_tail_on_completion(self) -> bool {
matches!(self, SlashCommand::Review | SlashCommand::Goal)
}

/// Whether this command remains available inside an active side conversation.
pub fn available_in_side_conversation(self) -> bool {
matches!(
Expand Down
Loading