From bcb3555e70dbe03982e8fd4e82deeedd45f6e47b Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Mar 2026 10:45:50 -0700 Subject: [PATCH] tui: reject repeated model modifiers Track whether /model effort and scope modifiers were provided so repeated sentinel values like default and global are rejected consistently. Validation: cargo test -p codex-tui; just fix -p codex-tui; just fmt Co-authored-by: Codex --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/chatwidget.rs | 29 +++++++++++----------------- codex-rs/tui/src/chatwidget/tests.rs | 26 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 75168c4b1e..bb7dd198b4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -881,6 +881,7 @@ impl App { resume_config.clone(), target_session.path.clone(), self.auth_manager.clone(), + None, ) .await { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 15551f0269..704ccdb1a3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5671,41 +5671,34 @@ impl ChatWidget { fn parse_model_selection_args( args: &str, ) -> Result<(String, Option, ModelSelectionScope), String> { + const MODEL_USAGE: &str = "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]"; let mut tokens = args.split_whitespace(); let Some(model) = tokens.next() else { - return Err( - "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" - .to_string(), - ); + return Err(MODEL_USAGE.to_string()); }; let mut effort = None; + let mut saw_effort = false; let mut scope = ModelSelectionScope::Global; + let mut saw_scope = false; for token in tokens { if let Some(parsed_effort) = Self::parse_model_reasoning_effort_token(token) { - if effort.is_some() { - return Err( - "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" - .to_string(), - ); + if saw_effort { + return Err(MODEL_USAGE.to_string()); } + saw_effort = true; effort = parsed_effort; continue; } if let Some(parsed_scope) = Self::parse_model_scope_token(token) { - if scope != ModelSelectionScope::Global { - return Err( - "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" - .to_string(), - ); + if saw_scope { + return Err(MODEL_USAGE.to_string()); } + saw_scope = true; scope = parsed_scope; continue; } - return Err( - "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" - .to_string(), - ); + return Err(MODEL_USAGE.to_string()); } Ok((model.to_string(), effort, scope)) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fa033b6e7c..6734ccb930 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8089,6 +8089,32 @@ async fn single_reasoning_option_skips_selection() { ); } +#[test] +fn model_parser_rejects_repeated_effort_when_first_token_is_default() { + let parsed = ChatWidget::parse_model_selection_args("gpt-5 default high"); + + assert_eq!( + parsed, + Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ) + ); +} + +#[test] +fn model_parser_rejects_repeated_scope_when_first_token_is_global() { + let parsed = ChatWidget::parse_model_selection_args("gpt-5 global plan-only"); + + assert_eq!( + parsed, + Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ) + ); +} + #[tokio::test] async fn feedback_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;