Skip to content

Commit 124a09e

Browse files
authored
fix: handle /review arguments in TUI (#8823)
Handle /review <instructions> in the TUI and TUI2 by routing it as a custom review command instead of plain text, wiring command dispatch and adding composer coverage so typing /review text starts a review directly rather than posting a message. User impact: /review with arguments now kicks off the review flow, previously it would just forward as a plain command and not actually start a review.
1 parent a590523 commit 124a09e

File tree

4 files changed

+180
-0
lines changed

4 files changed

+180
-0
lines changed

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
7373
pub enum InputResult {
7474
Submitted(String),
7575
Command(SlashCommand),
76+
CommandWithArgs(SlashCommand, String),
7677
None,
7778
}
7879

@@ -1274,6 +1275,18 @@ impl ChatComposer {
12741275
}
12751276
}
12761277

1278+
if !input_starts_with_space
1279+
&& let Some((name, rest)) = parse_slash_name(&text)
1280+
&& !rest.is_empty()
1281+
&& !name.contains('/')
1282+
&& let Some((_n, cmd)) = built_in_slash_commands()
1283+
.into_iter()
1284+
.find(|(command_name, _)| *command_name == name)
1285+
&& cmd == SlashCommand::Review
1286+
{
1287+
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
1288+
}
1289+
12771290
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
12781291
Ok(expanded) => expanded,
12791292
Err(err) => {
@@ -2841,6 +2854,9 @@ mod tests {
28412854
InputResult::Command(cmd) => {
28422855
assert_eq!(cmd.command(), "init");
28432856
}
2857+
InputResult::CommandWithArgs(_, _) => {
2858+
panic!("expected command dispatch without args for '/init'")
2859+
}
28442860
InputResult::Submitted(text) => {
28452861
panic!("expected command dispatch, but composer submitted literal text: {text}")
28462862
}
@@ -2849,6 +2865,44 @@ mod tests {
28492865
assert!(composer.textarea.is_empty(), "composer should be cleared");
28502866
}
28512867

2868+
#[test]
2869+
fn slash_review_with_args_dispatches_command_with_args() {
2870+
use crossterm::event::KeyCode;
2871+
use crossterm::event::KeyEvent;
2872+
use crossterm::event::KeyModifiers;
2873+
2874+
let (tx, _rx) = unbounded_channel::<AppEvent>();
2875+
let sender = AppEventSender::new(tx);
2876+
let mut composer = ChatComposer::new(
2877+
true,
2878+
sender,
2879+
false,
2880+
"Ask Codex to do anything".to_string(),
2881+
false,
2882+
);
2883+
2884+
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
2885+
type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']);
2886+
2887+
let (result, _needs_redraw) =
2888+
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2889+
2890+
match result {
2891+
InputResult::CommandWithArgs(cmd, args) => {
2892+
assert_eq!(cmd, SlashCommand::Review);
2893+
assert_eq!(args, "fix this");
2894+
}
2895+
InputResult::Command(cmd) => {
2896+
panic!("expected args for '/review', got bare command: {cmd:?}")
2897+
}
2898+
InputResult::Submitted(text) => {
2899+
panic!("expected command dispatch, got literal submit: {text}")
2900+
}
2901+
InputResult::None => panic!("expected CommandWithArgs result for '/review'"),
2902+
}
2903+
assert!(composer.textarea.is_empty(), "composer should be cleared");
2904+
}
2905+
28522906
#[test]
28532907
fn extract_args_supports_quoted_paths_single_arg() {
28542908
let args = extract_positional_args_for_prompt_line(
@@ -2914,6 +2968,9 @@ mod tests {
29142968
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
29152969
match result {
29162970
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
2971+
InputResult::CommandWithArgs(_, _) => {
2972+
panic!("expected command dispatch without args for '/diff'")
2973+
}
29172974
InputResult::Submitted(text) => {
29182975
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
29192976
}
@@ -2947,6 +3004,9 @@ mod tests {
29473004
InputResult::Command(cmd) => {
29483005
assert_eq!(cmd.command(), "mention");
29493006
}
3007+
InputResult::CommandWithArgs(_, _) => {
3008+
panic!("expected command dispatch without args for '/mention'")
3009+
}
29503010
InputResult::Submitted(text) => {
29513011
panic!("expected command dispatch, but composer submitted literal text: {text}")
29523012
}

codex-rs/tui/src/chatwidget.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,9 @@ impl ChatWidget {
16411641
InputResult::Command(cmd) => {
16421642
self.dispatch_command(cmd);
16431643
}
1644+
InputResult::CommandWithArgs(cmd, args) => {
1645+
self.dispatch_command_with_args(cmd, args);
1646+
}
16441647
InputResult::None => {}
16451648
}
16461649
}
@@ -1837,6 +1840,33 @@ impl ChatWidget {
18371840
}
18381841
}
18391842

1843+
fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) {
1844+
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
1845+
let message = format!(
1846+
"'/{}' is disabled while a task is in progress.",
1847+
cmd.command()
1848+
);
1849+
self.add_to_history(history_cell::new_error_event(message));
1850+
self.request_redraw();
1851+
return;
1852+
}
1853+
1854+
let trimmed = args.trim();
1855+
match cmd {
1856+
SlashCommand::Review if !trimmed.is_empty() => {
1857+
self.submit_op(Op::Review {
1858+
review_request: ReviewRequest {
1859+
target: ReviewTarget::Custom {
1860+
instructions: trimmed.to_string(),
1861+
},
1862+
user_facing_hint: None,
1863+
},
1864+
});
1865+
}
1866+
_ => self.dispatch_command(cmd),
1867+
}
1868+
}
1869+
18401870
pub(crate) fn handle_paste(&mut self, text: String) {
18411871
self.bottom_pane.handle_paste(text);
18421872
}

codex-rs/tui2/src/bottom_pane/chat_composer.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
7676
pub enum InputResult {
7777
Submitted(String),
7878
Command(SlashCommand),
79+
CommandWithArgs(SlashCommand, String),
7980
None,
8081
}
8182

@@ -1191,6 +1192,18 @@ impl ChatComposer {
11911192
}
11921193
}
11931194

1195+
if !input_starts_with_space
1196+
&& let Some((name, rest)) = parse_slash_name(&text)
1197+
&& !rest.is_empty()
1198+
&& !name.contains('/')
1199+
&& let Some((_n, cmd)) = built_in_slash_commands()
1200+
.into_iter()
1201+
.find(|(command_name, _)| *command_name == name)
1202+
&& cmd == SlashCommand::Review
1203+
{
1204+
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
1205+
}
1206+
11941207
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
11951208
Ok(expanded) => expanded,
11961209
Err(err) => {
@@ -2754,6 +2767,9 @@ mod tests {
27542767
InputResult::Command(cmd) => {
27552768
assert_eq!(cmd.command(), "init");
27562769
}
2770+
InputResult::CommandWithArgs(_, _) => {
2771+
panic!("expected command dispatch without args for '/init'")
2772+
}
27572773
InputResult::Submitted(text) => {
27582774
panic!("expected command dispatch, but composer submitted literal text: {text}")
27592775
}
@@ -2762,6 +2778,44 @@ mod tests {
27622778
assert!(composer.textarea.is_empty(), "composer should be cleared");
27632779
}
27642780

2781+
#[test]
2782+
fn slash_review_with_args_dispatches_command_with_args() {
2783+
use crossterm::event::KeyCode;
2784+
use crossterm::event::KeyEvent;
2785+
use crossterm::event::KeyModifiers;
2786+
2787+
let (tx, _rx) = unbounded_channel::<AppEvent>();
2788+
let sender = AppEventSender::new(tx);
2789+
let mut composer = ChatComposer::new(
2790+
true,
2791+
sender,
2792+
false,
2793+
"Ask Codex to do anything".to_string(),
2794+
false,
2795+
);
2796+
2797+
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
2798+
type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']);
2799+
2800+
let (result, _needs_redraw) =
2801+
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2802+
2803+
match result {
2804+
InputResult::CommandWithArgs(cmd, args) => {
2805+
assert_eq!(cmd, SlashCommand::Review);
2806+
assert_eq!(args, "fix this");
2807+
}
2808+
InputResult::Command(cmd) => {
2809+
panic!("expected args for '/review', got bare command: {cmd:?}")
2810+
}
2811+
InputResult::Submitted(text) => {
2812+
panic!("expected command dispatch, got literal submit: {text}")
2813+
}
2814+
InputResult::None => panic!("expected CommandWithArgs result for '/review'"),
2815+
}
2816+
assert!(composer.textarea.is_empty(), "composer should be cleared");
2817+
}
2818+
27652819
#[test]
27662820
fn extract_args_supports_quoted_paths_single_arg() {
27672821
let args = extract_positional_args_for_prompt_line(
@@ -2827,6 +2881,9 @@ mod tests {
28272881
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
28282882
match result {
28292883
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
2884+
InputResult::CommandWithArgs(_, _) => {
2885+
panic!("expected command dispatch without args for '/diff'")
2886+
}
28302887
InputResult::Submitted(text) => {
28312888
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
28322889
}
@@ -2860,6 +2917,9 @@ mod tests {
28602917
InputResult::Command(cmd) => {
28612918
assert_eq!(cmd.command(), "mention");
28622919
}
2920+
InputResult::CommandWithArgs(_, _) => {
2921+
panic!("expected command dispatch without args for '/mention'")
2922+
}
28632923
InputResult::Submitted(text) => {
28642924
panic!("expected command dispatch, but composer submitted literal text: {text}")
28652925
}

codex-rs/tui2/src/chatwidget.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1500,6 +1500,9 @@ impl ChatWidget {
15001500
InputResult::Command(cmd) => {
15011501
self.dispatch_command(cmd);
15021502
}
1503+
InputResult::CommandWithArgs(cmd, args) => {
1504+
self.dispatch_command_with_args(cmd, args);
1505+
}
15031506
InputResult::None => {}
15041507
}
15051508
}
@@ -1665,6 +1668,33 @@ impl ChatWidget {
16651668
}
16661669
}
16671670

1671+
fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) {
1672+
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
1673+
let message = format!(
1674+
"'/{}' is disabled while a task is in progress.",
1675+
cmd.command()
1676+
);
1677+
self.add_to_history(history_cell::new_error_event(message));
1678+
self.request_redraw();
1679+
return;
1680+
}
1681+
1682+
let trimmed = args.trim();
1683+
match cmd {
1684+
SlashCommand::Review if !trimmed.is_empty() => {
1685+
self.submit_op(Op::Review {
1686+
review_request: ReviewRequest {
1687+
target: ReviewTarget::Custom {
1688+
instructions: trimmed.to_string(),
1689+
},
1690+
user_facing_hint: None,
1691+
},
1692+
});
1693+
}
1694+
_ => self.dispatch_command(cmd),
1695+
}
1696+
}
1697+
16681698
pub(crate) fn handle_paste(&mut self, text: String) {
16691699
self.bottom_pane.handle_paste(text);
16701700
}

0 commit comments

Comments
 (0)