diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 979213112..068674b41 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4,6 +4,7 @@ //! //! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments. //! - Routing keys to the active popup (slash commands, file search, skill/apps mentions). +//! - Promoting typed slash commands into atomic elements when the command name is completed. //! - Handling submit vs newline on Enter. //! - Turning raw key streams into explicit paste operations on platforms where terminals //! don't provide reliable bracketed paste (notably Windows). @@ -36,6 +37,8 @@ //! //! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion //! and attachment pruning, and clears pending paste state on success. +//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so +//! pasted content and text elements are preserved when extracting args. //! //! # Non-bracketed Paste Bursts //! @@ -164,6 +167,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::ops::Range; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; @@ -184,7 +188,7 @@ pub enum InputResult { text_elements: Vec, }, Command(SlashCommand), - CommandWithArgs(SlashCommand, String), + CommandWithArgs(SlashCommand, String, Vec), None, } @@ -747,6 +751,7 @@ impl ChatComposer { /// Move the cursor to the end of the current text buffer. pub(crate) fn move_cursor_to_end(&mut self) { self.textarea.set_cursor(self.textarea.text().len()); + self.sync_popups(); } pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { @@ -1235,6 +1240,7 @@ impl ChatComposer { self.handle_paste(pasted); } self.textarea.input(input); + let text_after = self.textarea.text(); self.pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); @@ -1798,7 +1804,12 @@ impl ChatComposer { /// Prepare text for submission/queuing. Returns None if submission should be suppressed. /// On success, clears pending paste payloads because placeholders have been expanded. - fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { + /// + /// When `record_history` is true, the final submission is stored for ↑/↓ recall. + fn prepare_submission_text( + &mut self, + record_history: bool, + ) -> Option<(String, Vec)> { let mut text = self.textarea.text().to_string(); let original_input = text.clone(); let original_text_elements = self.textarea.text_elements(); @@ -1896,7 +1907,7 @@ impl ChatComposer { if text.is_empty() && self.attached_images.is_empty() { return None; } - if !text.is_empty() || !self.attached_images.is_empty() { + if record_history && (!text.is_empty() || !self.attached_images.is_empty()) { let local_image_paths = self .attached_images .iter() @@ -1978,7 +1989,7 @@ impl ChatComposer { return (result, true); } - if let Some((text, text_elements)) = self.prepare_submission_text() { + if let Some((text, text_elements)) = self.prepare_submission_text(true) { if should_queue { ( InputResult::Queued { @@ -2026,6 +2037,9 @@ impl ChatComposer { self.windows_degraded_sandbox_active, ) { + if self.reject_slash_command_if_unavailable(cmd) { + return Some(InputResult::None); + } self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { @@ -2039,28 +2053,104 @@ impl ChatComposer { if !self.slash_commands_enabled() { return None; } - let original_input = self.textarea.text().to_string(); - let input_starts_with_space = original_input.starts_with(' '); - - if !input_starts_with_space { - let text = self.textarea.text().to_string(); - if let Some((name, rest, _rest_offset)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some(cmd) = slash_commands::find_builtin_command( - name, - self.collaboration_modes_enabled, - self.connectors_enabled, - self.personality_command_enabled, - self.windows_degraded_sandbox_active, - ) - && matches!(cmd, SlashCommand::Review | SlashCommand::Rename) - { - self.textarea.set_text_clearing_elements(""); - return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); - } + let text = self.textarea.text().to_string(); + if text.starts_with(' ') { + return None; } - None + + let (name, rest, rest_offset) = parse_slash_name(&text)?; + if rest.is_empty() || name.contains('/') { + return None; + } + + let cmd = slash_commands::find_builtin_command( + name, + self.collaboration_modes_enabled, + self.connectors_enabled, + self.personality_command_enabled, + self.windows_degraded_sandbox_active, + )?; + + if !cmd.supports_inline_args() { + return None; + } + if self.reject_slash_command_if_unavailable(cmd) { + return Some(InputResult::None); + } + + let mut args_elements = + Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); + let trimmed_rest = rest.trim(); + args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements); + Some(InputResult::CommandWithArgs( + cmd, + trimmed_rest.to_string(), + args_elements, + )) + } + + /// Expand pending placeholders and extract normalized inline-command args. + /// + /// Inline-arg commands are initially dispatched using the raw draft so command rejection does + /// not consume user input. Once a command is accepted, this helper performs the usual + /// submission preparation (paste expansion, element trimming) and rebases element ranges from + /// full-text offsets to command-arg offsets. + pub(crate) fn prepare_inline_args_submission( + &mut self, + record_history: bool, + ) -> Option<(String, Vec)> { + let (prepared_text, prepared_elements) = self.prepare_submission_text(record_history)?; + let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(&prepared_text)?; + let mut args_elements = Self::slash_command_args_elements( + prepared_rest, + prepared_rest_offset, + &prepared_elements, + ); + let trimmed_rest = prepared_rest.trim(); + args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements); + Some((trimmed_rest.to_string(), args_elements)) + } + + fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool { + if !self.is_task_running || cmd.available_during_task() { + return false; + } + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(message), + ))); + true + } + + /// Translate full-text element ranges into command-argument ranges. + /// + /// `rest_offset` is the byte offset where `rest` begins in the full text. + fn slash_command_args_elements( + rest: &str, + rest_offset: usize, + text_elements: &[TextElement], + ) -> Vec { + if rest.is_empty() || text_elements.is_empty() { + return Vec::new(); + } + text_elements + .iter() + .filter_map(|elem| { + if elem.byte_range.end <= rest_offset { + return None; + } + let start = elem.byte_range.start.saturating_sub(rest_offset); + let mut end = elem.byte_range.end.saturating_sub(rest_offset); + if start >= rest.len() { + return None; + } + end = end.min(rest.len()); + (start < end).then_some(elem.map_range(|_| ByteRange { start, end })) + }) + .collect() } /// Handle key event when no popup is visible. @@ -2441,6 +2531,7 @@ impl ChatComposer { } fn sync_popups(&mut self) { + self.sync_slash_command_elements(); if !self.popups_enabled() { self.active_popup = ActivePopup::None; return; @@ -2507,6 +2598,88 @@ impl ChatComposer { } } + /// Keep slash command elements aligned with the current first line. + fn sync_slash_command_elements(&mut self) { + if !self.slash_commands_enabled() { + return; + } + let text = self.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let first_line = &text[..first_line_end]; + let desired_range = self.slash_command_element_range(first_line); + // Slash commands are only valid at byte 0 of the first line. + // Any slash-shaped element not matching the current desired prefix is stale. + let mut has_desired = false; + let mut stale_ranges = Vec::new(); + for elem in self.textarea.text_elements() { + let Some(payload) = elem.placeholder(text) else { + continue; + }; + if payload.strip_prefix('/').is_none() { + continue; + } + let range = elem.byte_range.start..elem.byte_range.end; + if desired_range.as_ref() == Some(&range) { + has_desired = true; + } else { + stale_ranges.push(range); + } + } + + for range in stale_ranges { + self.textarea.remove_element_range(range); + } + + if let Some(range) = desired_range + && !has_desired + { + self.textarea.add_element_range(range); + } + } + + fn slash_command_element_range(&self, first_line: &str) -> Option> { + let (name, _rest, _rest_offset) = parse_slash_name(first_line)?; + if name.contains('/') { + return None; + } + let element_end = 1 + name.len(); + let has_space_after = first_line + .get(element_end..) + .and_then(|tail| tail.chars().next()) + .is_some_and(char::is_whitespace); + if !has_space_after { + return None; + } + if self.is_known_slash_name(name) { + Some(0..element_end) + } else { + None + } + } + + fn is_known_slash_name(&self, name: &str) -> bool { + let is_builtin = slash_commands::find_builtin_command( + name, + self.collaboration_modes_enabled, + self.connectors_enabled, + self.personality_command_enabled, + self.windows_degraded_sandbox_active, + ) + .is_some(); + if is_builtin { + return true; + } + if let Some(rest) = name.strip_prefix(PROMPTS_CMD_PREFIX) + && let Some(prompt_name) = rest.strip_prefix(':') + { + return self + .custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name); + } + false + } + /// If the cursor is currently within a slash command on the first line, /// extract the command name and the rest of the line after it. /// Returns None if the cursor is outside a slash command. @@ -4582,7 +4755,7 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "init"); } - InputResult::CommandWithArgs(_, _) => { + InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/init'") } InputResult::Submitted { text, .. } => { @@ -4596,6 +4769,49 @@ mod tests { assert!(composer.textarea.is_empty(), "composer should be cleared"); } + #[test] + fn slash_command_disabled_while_task_running_keeps_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_task_running(true); + composer + .textarea + .set_text_clearing_elements("/review these changes"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("/review these changes", composer.textarea.text()); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains("disabled while a task is in progress")); + found_error = true; + break; + } + } + assert!(found_error, "expected error history cell to be sent"); + } + #[test] fn extract_args_supports_quoted_paths_single_arg() { let args = extract_positional_args_for_prompt_line( @@ -4683,7 +4899,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), - InputResult::CommandWithArgs(_, _) => { + InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/diff'") } InputResult::Submitted { text, .. } => { @@ -4697,6 +4913,77 @@ mod tests { assert!(composer.textarea.is_empty()); } + #[test] + fn slash_command_elementizes_on_space() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_collaboration_modes_enabled(true); + + type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']); + + let text = composer.textarea.text().to_string(); + let elements = composer.textarea.text_elements(); + assert_eq!(text, "/plan "); + assert_eq!(elements.len(), 1); + assert_eq!(elements[0].placeholder(&text), Some("/plan")); + } + + #[test] + fn slash_command_elementizes_only_known_commands() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_collaboration_modes_enabled(true); + + type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']); + + let text = composer.textarea.text().to_string(); + let elements = composer.textarea.text_elements(); + assert_eq!(text, "/Users "); + assert!(elements.is_empty()); + } + + #[test] + fn slash_command_element_removed_when_not_at_start() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']); + + let text = composer.textarea.text().to_string(); + let elements = composer.textarea.text_elements(); + assert_eq!(text, "/review "); + assert_eq!(elements.len(), 1); + + composer.textarea.set_cursor(0); + type_chars_humanlike(&mut composer, &['x']); + + let text = composer.textarea.text().to_string(); + let elements = composer.textarea.text_elements(); + assert_eq!(text, "x/review "); + assert!(elements.is_empty()); + } + #[test] fn slash_mention_dispatches_command_and_inserts_at() { use crossterm::event::KeyCode; @@ -4722,7 +5009,7 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "mention"); } - InputResult::CommandWithArgs(_, _) => { + InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/mention'") } InputResult::Submitted { text, .. } => { @@ -4738,6 +5025,44 @@ mod tests { assert_eq!(composer.textarea.text(), "@"); } + #[test] + fn slash_plan_args_preserve_text_elements() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_collaboration_modes_enabled(true); + + type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']); + let placeholder = local_image_label_text(1); + composer.attach_image(PathBuf::from("/tmp/plan.png")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::CommandWithArgs(cmd, args, text_elements) => { + assert_eq!(cmd.command(), "plan"); + assert_eq!(args, placeholder); + assert_eq!(text_elements.len(), 1); + assert_eq!( + text_elements[0].placeholder(&args), + Some(placeholder.as_str()) + ); + } + _ => panic!("expected CommandWithArgs for /plan with args"), + } + } + /// Behavior: multiple paste operations can coexist; placeholders should be expanded to their /// original content on submission. #[test] diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a632fd446..6d3a6755a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -218,6 +218,12 @@ impl BottomPane { self.composer.take_mention_paths() } + /// Clear pending attachments and mention paths e.g. when a slash command doesn't submit text. + pub(crate) fn drain_pending_submission_state(&mut self) { + let _ = self.take_recent_submission_images_with_placeholders(); + let _ = self.take_mention_paths(); + } + pub fn set_steer_enabled(&mut self, enabled: bool) { self.composer.set_steer_enabled(enabled); } @@ -404,6 +410,7 @@ impl BottomPane { ) { self.composer .set_text_content(text, text_elements, local_image_paths); + self.composer.move_cursor_to_end(); self.request_redraw(); } @@ -787,6 +794,13 @@ impl BottomPane { .take_recent_submission_images_with_placeholders() } + pub(crate) fn prepare_inline_args_submission( + &mut self, + record_history: bool, + ) -> Option<(String, Vec)> { + self.composer.prepare_inline_args_submission(record_history) + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 42f7b09be..a90d5b87c 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -844,6 +844,46 @@ impl TextArea { self.set_cursor(end); } + /// Mark an existing text range as an atomic element without changing the text. + /// + /// This is used to convert already-typed tokens (like `/plan`) into elements + /// so they render and edit atomically. Overlapping or duplicate ranges are ignored. + pub fn add_element_range(&mut self, range: Range) { + let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len())); + let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len())); + if start >= end { + return; + } + if self + .elements + .iter() + .any(|e| e.range.start == start && e.range.end == end) + { + return; + } + if self + .elements + .iter() + .any(|e| start < e.range.end && end > e.range.start) + { + return; + } + self.elements.push(TextElement { range: start..end }); + self.elements.sort_by_key(|e| e.range.start); + } + + pub fn remove_element_range(&mut self, range: Range) -> bool { + let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len())); + let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len())); + if start >= end { + return false; + } + let len_before = self.elements.len(); + self.elements + .retain(|elem| elem.range.start != start || elem.range.end != end); + len_before != self.elements.len() + } + fn add_element(&mut self, range: Range) { let elem = TextElement { range }; self.elements.push(elem); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 09a7d0fc6..078fbb978 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2732,8 +2732,8 @@ impl ChatWidget { InputResult::Command(cmd) => { self.dispatch_command(cmd); } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); + InputResult::CommandWithArgs(cmd, args, text_elements) => { + self.dispatch_command_with_args(cmd, args, text_elements); } InputResult::None => {} }, @@ -2783,6 +2783,7 @@ impl ChatWidget { cmd.command() ); self.add_to_history(history_cell::new_error_event(message)); + self.bottom_pane.drain_pending_submission_state(); self.request_redraw(); return; } @@ -3019,7 +3020,16 @@ impl ChatWidget { } } - fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) { + fn dispatch_command_with_args( + &mut self, + cmd: SlashCommand, + args: String, + _text_elements: Vec, + ) { + if !cmd.supports_inline_args() { + self.dispatch_command(cmd); + return; + } if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", @@ -3033,7 +3043,12 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { SlashCommand::Rename if !trimmed.is_empty() => { - let Some(name) = codex_core::util::normalize_thread_name(trimmed) else { + let Some((prepared_args, _prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(false) + else { + return; + }; + let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else { self.add_error_message("Thread name cannot be empty.".to_string()); return; }; @@ -3042,20 +3057,50 @@ impl ChatWidget { self.request_redraw(); self.app_event_tx .send(AppEvent::CodexOp(Op::SetThreadName { name })); + self.bottom_pane.drain_pending_submission_state(); } - SlashCommand::Collab | SlashCommand::Plan => { - let _ = trimmed; + SlashCommand::Plan if !trimmed.is_empty() => { self.dispatch_command(cmd); + if self.active_mode_kind() != ModeKind::Plan { + return; + } + let Some((prepared_args, prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(true) + else { + return; + }; + let user_message = UserMessage { + text: prepared_args, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements: prepared_elements, + mention_paths: self.bottom_pane.take_mention_paths(), + }; + if self.is_session_configured() { + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + self.submit_user_message(user_message); + } else { + self.queue_user_message(user_message); + } } SlashCommand::Review if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(false) + else { + return; + }; self.submit_op(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { - instructions: trimmed.to_string(), + instructions: prepared_args, }, user_facing_hint: None, }, }); + self.bottom_pane.drain_pending_submission_state(); } _ => self.dispatch_command(cmd), } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d24504578..980d15c33 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2316,6 +2316,50 @@ async fn plan_slash_command_switches_to_plan_mode() { assert_eq!(chat.current_collaboration_mode(), &initial); } +#[tokio::test] +async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: None, + }; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + chat.bottom_pane + .set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(items.len(), 1); + assert_eq!( + items[0], + UserInput::Text { + text: "build the plan".to_string(), + text_elements: Vec::new(), + } + ); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); +} + #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4b68645ae..a71b1ddbe 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -87,6 +87,14 @@ impl SlashCommand { self.into() } + /// Whether this command supports inline args (for example `/review ...`). + pub fn supports_inline_args(self) -> bool { + matches!( + self, + SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan + ) + } + /// Whether this command can be run while a task is in progress. pub fn available_during_task(self) -> bool { match self { @@ -103,6 +111,7 @@ impl SlashCommand { | SlashCommand::ElevateSandbox | SlashCommand::Experimental | SlashCommand::Review + | SlashCommand::Plan | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Rename @@ -117,7 +126,6 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, - SlashCommand::Plan => true, SlashCommand::Collab => true, SlashCommand::Agent => true, } diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 01b5334d9..6211cbcfc 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -48,6 +48,8 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl history navigation, etc). - After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the latest text + cursor. +- When a slash command name is completed and the user types a space, the `/command` token is + promoted into a text element so it renders distinctly and edits atomically. ### History navigation (↑/↓) @@ -105,6 +107,9 @@ There are multiple submission paths, but they share the same core rules: 5. Clears pending pastes on success and suppresses submission if the final text is empty and there are no attachments. +The same preparation path is reused for slash commands with arguments (for example `/plan` and +`/review`) so pasted content and text elements are preserved when extracting args. + ### Numeric auto-submit path When the slash popup is open and the first line matches a numeric-only custom prompt with