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
287 changes: 284 additions & 3 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ pub enum InputResult {
Queued {
text: String,
text_elements: Vec<TextElement>,
action: QueuedInputAction,
},
/// A bare slash command parsed by the composer.
///
Expand All @@ -254,6 +255,13 @@ pub enum InputResult {
None,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueuedInputAction {
Plain,
ParseSlash,
RunShell,
}

#[derive(Clone, Debug, PartialEq)]
struct AttachedImage {
placeholder: String,
Expand Down Expand Up @@ -398,6 +406,12 @@ enum ActivePopup {
Skill(SkillPopup),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashValidation {
Immediate,
Deferred,
}

const FOOTER_SPACING_HEIGHT: u16 = 0;

impl ChatComposer {
Expand Down Expand Up @@ -1389,14 +1403,48 @@ impl ChatComposer {
// before applying completion.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
if let Some(sel) = popup.selected_item() {
let selected_cmd = popup.selected_item().map(|sel| {
let CommandItem::Builtin(cmd) = sel;
cmd
});
if let Some(cmd) = selected_cmd {
if cmd == SlashCommand::Skills {
self.stage_selected_slash_command_history(cmd);
self.textarea.set_text_clearing_elements("");
return (InputResult::Command(cmd), true);
}

let selected_command_text = format!("/{}", cmd.command());
let starts_with_cmd =
first_line.trim_start().starts_with(&selected_command_text);
if !starts_with_cmd {
self.textarea
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
if !self.textarea.text().is_empty() {
self.textarea.set_cursor(self.textarea.text().len());
}
return (InputResult::None, true);
}
}
if self.is_task_running {
return self.handle_submission(/*should_queue*/ true);
}
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
..
} => {
// Treat "/" as accepting the highlighted command as text completion
// while the slash-command popup is active.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
let selected_cmd = popup.selected_item().map(|sel| {
let CommandItem::Builtin(cmd) = sel;
cmd
});
if let Some(cmd) = selected_cmd {
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
Expand Down Expand Up @@ -2169,6 +2217,14 @@ impl ChatComposer {
fn prepare_submission_text(
&mut self,
record_history: bool,
) -> Option<(String, Vec<TextElement>)> {
self.prepare_submission_text_with_options(record_history, SlashValidation::Immediate)
}

fn prepare_submission_text_with_options(
&mut self,
record_history: bool,
slash_validation: SlashValidation,
) -> Option<(String, Vec<TextElement>)> {
let mut text = self.textarea.text().to_string();
let original_input = text.clone();
Expand Down Expand Up @@ -2199,7 +2255,8 @@ impl ChatComposer {
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);

if self.slash_commands_enabled()
if slash_validation == SlashValidation::Immediate
&& self.slash_commands_enabled()
&& let Some((name, _rest, _rest_offset)) = parse_slash_name(&text)
{
let treat_as_plain_text = input_starts_with_space || name.contains('/');
Expand Down Expand Up @@ -2282,6 +2339,31 @@ impl ChatComposer {
should_queue: bool,
now: Instant,
) -> (InputResult, bool) {
if should_queue {
let raw_text = self.textarea.text();
let defer_slash_validation =
self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text);
if let Some((text, text_elements)) = self.prepare_submission_text_with_options(
/*record_history*/ true,
if defer_slash_validation {
SlashValidation::Deferred
} else {
SlashValidation::Immediate
},
) {
let action = self.queued_input_action(&text, defer_slash_validation);
return (
InputResult::Queued {
text,
text_elements,
action,
},
true,
);
}
return (InputResult::None, true);
}

// If the first line is a bare built-in slash command (no args),
// dispatch it even when the slash popup isn't visible. This preserves
// the workflow: type a prefix ("/di"), press Tab to complete to
Expand Down Expand Up @@ -2346,6 +2428,7 @@ impl ChatComposer {
InputResult::Queued {
text,
text_elements,
action: QueuedInputAction::Plain,
},
true,
)
Expand Down Expand Up @@ -2477,6 +2560,24 @@ impl ChatComposer {
true
}

fn should_parse_as_slash_on_dequeue_from_raw_text(&self, text: &str) -> bool {
self.slash_commands_enabled() && !text.starts_with(' ') && text.trim().starts_with('/')
}

fn queued_input_action(
&self,
prepared_text: &str,
defer_slash_validation: bool,
) -> QueuedInputAction {
if defer_slash_validation && prepared_text.starts_with('/') {
QueuedInputAction::ParseSlash
} else if prepared_text.starts_with('!') {
QueuedInputAction::RunShell
} else {
QueuedInputAction::Plain
}
}

/// Stage the current slash-command text for later local recall.
///
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
Expand Down Expand Up @@ -2690,7 +2791,9 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
} if !self.is_bang_shell_command() => self.handle_submission(self.is_task_running),
} if self.is_task_running || !self.is_bang_shell_command() => {
self.handle_submission(self.is_task_running)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
Expand Down Expand Up @@ -6543,6 +6646,131 @@ mod tests {
assert!(found_error, "expected error history cell to be sent");
}

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

fn assert_queued_slash(input: &str) {
let (tx, mut 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_task_running(/*running*/ true);
composer.textarea.set_text_clearing_elements(input);

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

match result {
InputResult::Queued {
text,
text_elements,
action,
} => {
assert_eq!(text, input);
assert!(text_elements.is_empty());
assert_eq!(action, QueuedInputAction::ParseSlash);
}
other => panic!("expected slash-led input to queue, got {other:?}"),
}
assert!(composer.textarea.is_empty());
assert!(
rx.try_recv().is_err(),
"queueing should not report slash errors"
);
}

assert_queued_slash("/compact");
assert_queued_slash("/review check regressions");
assert_queued_slash("/fast on");
assert_queued_slash("/does-not-exist");
}

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

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_task_running(/*running*/ true);
composer
.textarea
.set_text_clearing_elements(" /does-not-exist");

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

match result {
InputResult::Queued { text, action, .. } => {
assert_eq!(text, "/does-not-exist");
assert_eq!(action, QueuedInputAction::Plain);
}
other => panic!("expected leading-space slash input to queue, got {other:?}"),
}
}

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

fn assert_queued_shell(input: &str, expected_text: &str) {
let (tx, mut 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_task_running(/*running*/ true);
composer.textarea.set_text_clearing_elements(input);

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

match result {
InputResult::Queued {
text,
text_elements,
action,
} => {
assert_eq!(text, expected_text);
assert!(text_elements.is_empty());
assert_eq!(action, QueuedInputAction::RunShell);
}
other => panic!("expected bang shell input to queue, got {other:?}"),
}
assert!(composer.textarea.is_empty());
assert!(
rx.try_recv().is_err(),
"queueing should not show shell help immediately"
);
}

assert_queued_shell("!echo hi", "!echo hi");
assert_queued_shell("!", "!");
assert_queued_shell(" !echo hi", "!echo hi");
}

#[test]
fn slash_tab_completion_moves_cursor_to_end() {
use crossterm::event::KeyCode;
Expand All @@ -6568,6 +6796,59 @@ mod tests {
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
}

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

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_task_running(/*running*/ true);

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.textarea.text(), "/model ");
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
}

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

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, &['/', 'm']);

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

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

#[test]
fn slash_tab_then_enter_dispatches_builtin_command() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,7 @@ impl McpServerElicitationOverlay {
| InputResult::Queued {
text,
text_elements,
..
} => {
self.apply_submission_to_draft(text, text_elements);
self.validation_error = None;
Expand Down
Loading
Loading