diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3592d16651..0abaeae992 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -271,9 +271,9 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; -use crate::slash_command::FastSlashCommandArgs; -use crate::slash_command::ParsedSlashCommand; use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::FastSlashCommandArgs; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::status::RateLimitSnapshotDisplay; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; @@ -4734,11 +4734,28 @@ impl ChatWidget { } } + fn prepare_inline_command_invocation( + &mut self, + cmd: SlashCommand, + record_history: bool, + ) -> Option { + let (prepared_args, prepared_elements) = self + .bottom_pane + .prepare_inline_args_submission(record_history)?; + match cmd.parse_invocation(&prepared_args, &prepared_elements) { + Ok(invocation) => Some(invocation), + Err(err) => { + self.add_error_message(err.message()); + None + } + } + } + fn dispatch_command_with_args( &mut self, cmd: SlashCommand, args: String, - _text_elements: Vec, + text_elements: Vec, ) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -4750,17 +4767,17 @@ impl ChatWidget { return; } - match cmd.parse_invocation(&args) { - Ok(ParsedSlashCommand::Bare(_)) => { + match cmd.parse_invocation(&args, &text_elements) { + Ok(SlashCommandInvocation::Bare(_)) => { self.dispatch_command(cmd); } - Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::On)) => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::On)) => { self.set_service_tier_selection(Some(ServiceTier::Fast)); } - Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Off)) => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Off)) => { self.set_service_tier_selection(/*service_tier*/ None); } - Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Status)) => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Status)) => { let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { "on" } else { @@ -4768,16 +4785,16 @@ impl ChatWidget { }; self.add_info_message(format!("Fast mode is {status}."), /*hint*/ None); } - Ok(ParsedSlashCommand::Rename) => { + Ok(SlashCommandInvocation::Rename(_)) => { self.session_telemetry .counter("codex.thread.rename", /*inc*/ 1, &[]); - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) + let Some(SlashCommandInvocation::Rename(prepared_args)) = + self.prepare_inline_command_invocation(cmd, /*record_history*/ false) else { return; }; - let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else { + let Some(name) = codex_core::util::normalize_thread_name(&prepared_args.text) + else { self.add_error_message("Thread name cannot be empty.".to_string()); return; }; @@ -4788,14 +4805,13 @@ impl ChatWidget { .send(AppEvent::CodexOp(Op::SetThreadName { name })); self.bottom_pane.drain_pending_submission_state(); } - Ok(ParsedSlashCommand::Plan) => { + Ok(SlashCommandInvocation::Plan(_)) => { 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(/*record_history*/ true) + let Some(SlashCommandInvocation::Plan(prepared_args)) = + self.prepare_inline_command_invocation(cmd, /*record_history*/ true) else { return; }; @@ -4803,11 +4819,15 @@ impl ChatWidget { .bottom_pane .take_recent_submission_images_with_placeholders(); let remote_image_urls = self.take_remote_image_urls(); + let crate::slash_command_invocation::SlashCommandTextArg { + text, + text_elements, + } = prepared_args; let user_message = UserMessage { - text: prepared_args, + text, local_images, remote_image_urls, - text_elements: prepared_elements, + text_elements, mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), }; if self.is_session_configured() { @@ -4819,33 +4839,31 @@ impl ChatWidget { self.queue_user_message(user_message); } } - Ok(ParsedSlashCommand::Review) => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) + Ok(SlashCommandInvocation::Review(_)) => { + let Some(SlashCommandInvocation::Review(prepared_args)) = + self.prepare_inline_command_invocation(cmd, /*record_history*/ false) else { return; }; self.submit_op(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { - instructions: prepared_args, + instructions: prepared_args.text, }, user_facing_hint: None, }, }); self.bottom_pane.drain_pending_submission_state(); } - Ok(ParsedSlashCommand::SandboxReadRoot) => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) + Ok(SlashCommandInvocation::SandboxReadRoot(_)) => { + let Some(SlashCommandInvocation::SandboxReadRoot(prepared_args)) = + self.prepare_inline_command_invocation(cmd, /*record_history*/ false) else { return; }; self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { - path: prepared_args, + path: prepared_args.text, }); self.bottom_pane.drain_pending_submission_state(); } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index fbcf68c1f7..3925279834 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -4,6 +4,12 @@ use strum_macros::EnumIter; use strum_macros::EnumString; use strum_macros::IntoStaticStr; +use codex_protocol::user_input::TextElement; + +use crate::slash_command_invocation::FastSlashCommandArgs; +use crate::slash_command_invocation::SlashCommandInvocation; +use crate::slash_command_invocation::SlashCommandTextArg; + /// Commands that can be invoked by starting a message with a leading slash. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr, @@ -72,74 +78,119 @@ pub(crate) enum SlashCommandBareBehavior { OpensUi, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum FastSlashCommandArgs { - On, - Off, - Status, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ParsedSlashCommand { - Bare(SlashCommandBareBehavior), - Fast(FastSlashCommandArgs), - Rename, - Plan, - Review, - SandboxReadRoot, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SlashCommandUsageErrorKind { UnexpectedInlineArgs, InvalidInlineArgs, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SlashCommandArgsParser { - NoArgs, - Remainder(ParsedSlashCommand), - Tokens(&'static [SlashCommandTokenChoice]), +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashCommandParseInput<'a> { + pub(crate) args: &'a str, + pub(crate) text_elements: &'a [TextElement], } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct SlashCommandTokenChoice { - pub(crate) token: &'static str, - pub(crate) parsed: ParsedSlashCommand, -} +pub(crate) type SlashCommandParser = + for<'a> fn( + SlashCommand, + SlashCommandParseInput<'a>, + ) -> Result; -const FAST_TOKEN_CHOICES: &[SlashCommandTokenChoice] = &[ - SlashCommandTokenChoice { - token: "on", - parsed: ParsedSlashCommand::Fast(FastSlashCommandArgs::On), - }, - SlashCommandTokenChoice { - token: "off", - parsed: ParsedSlashCommand::Fast(FastSlashCommandArgs::Off), - }, - SlashCommandTokenChoice { - token: "status", - parsed: ParsedSlashCommand::Fast(FastSlashCommandArgs::Status), - }, -]; - -impl SlashCommandArgsParser { - pub(crate) fn parse( - self, - args: &str, - ) -> Result { - match self { - SlashCommandArgsParser::NoArgs => Err(SlashCommandUsageErrorKind::UnexpectedInlineArgs), - SlashCommandArgsParser::Remainder(parsed) => Ok(parsed), - SlashCommandArgsParser::Tokens(choices) => choices - .iter() - .find(|choice| choice.token.eq_ignore_ascii_case(args)) - .map(|choice| choice.parsed) - .ok_or(SlashCommandUsageErrorKind::InvalidInlineArgs), - } +fn parse_no_args( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + if input.args.trim().is_empty() { + Ok(SlashCommandInvocation::Bare(command)) + } else { + Err(SlashCommandUsageErrorKind::UnexpectedInlineArgs) } } +fn parse_fast( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + if !input.text_elements.is_empty() { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + match input.args.trim() { + "" => Ok(SlashCommandInvocation::Bare(command)), + args if args.eq_ignore_ascii_case("on") => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::On)) + } + args if args.eq_ignore_ascii_case("off") => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Off)) + } + args if args.eq_ignore_ascii_case("status") => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Status)) + } + _ => Err(SlashCommandUsageErrorKind::InvalidInlineArgs), + } +} + +fn parse_text_arg( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + let args = input.args.trim(); + if args.is_empty() { + return Ok(SlashCommandInvocation::Bare(command)); + } + + let arg = SlashCommandTextArg::new(args.to_string(), input.text_elements.to_vec()); + + match command { + SlashCommand::Rename => Ok(SlashCommandInvocation::Rename(arg)), + SlashCommand::Plan => Ok(SlashCommandInvocation::Plan(arg)), + SlashCommand::Review => Ok(SlashCommandInvocation::Review(arg)), + SlashCommand::SandboxReadRoot => Ok(SlashCommandInvocation::SandboxReadRoot(arg)), + _ => Err(SlashCommandUsageErrorKind::InvalidInlineArgs), + } +} + +fn parse_rich_text( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + parse_text_arg(command, input) +} + +fn parse_model( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + parse_no_args(command, input) +} + +fn parse_ui_only( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + parse_no_args(command, input) +} + +fn parse_dispatch_only( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + parse_no_args(command, input) +} + +fn parse_fast_command( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + parse_fast(command, input) +} + +fn parse_rich_text_command( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result { + parse_rich_text(command, input) +} + impl SlashCommand { pub(crate) fn spec(self) -> SlashCommandSpec { match self { @@ -150,7 +201,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/model"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_model, }, SlashCommand::Fast => SlashCommandSpec { description: "toggle Fast mode to enable fastest inference at 2X plan usage", @@ -159,7 +210,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/fast", "/fast [on|off|status]"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::Tokens(FAST_TOKEN_CHOICES), + parser: parse_fast_command, }, SlashCommand::Approvals => SlashCommandSpec { description: "choose what Codex is allowed to do", @@ -168,7 +219,7 @@ impl SlashCommand { hide_in_command_popup: true, usage_lines: &["/approvals"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Permissions => SlashCommandSpec { description: "choose what Codex is allowed to do", @@ -177,7 +228,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/permissions"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::ElevateSandbox => SlashCommandSpec { description: "set up elevated agent sandbox", @@ -186,7 +237,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/setup-default-sandbox"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::SandboxReadRoot => SlashCommandSpec { description: "let sandbox read a directory: /sandbox-add-read-dir ", @@ -195,7 +246,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/sandbox-add-read-dir "], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::Remainder(ParsedSlashCommand::SandboxReadRoot), + parser: parse_rich_text_command, }, SlashCommand::Experimental => SlashCommandSpec { description: "toggle experimental features", @@ -204,7 +255,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/experimental"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Skills => SlashCommandSpec { description: "use skills to improve how Codex performs specific tasks", @@ -213,7 +264,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/skills"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Review => SlashCommandSpec { description: "review my current changes and find issues", @@ -222,7 +273,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/review", "/review "], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::Remainder(ParsedSlashCommand::Review), + parser: parse_rich_text_command, }, SlashCommand::Rename => SlashCommandSpec { description: "rename the current thread", @@ -231,7 +282,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/rename", "/rename "], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::Remainder(ParsedSlashCommand::Rename), + parser: parse_rich_text_command, }, SlashCommand::New => SlashCommandSpec { description: "start a new chat during a conversation", @@ -240,7 +291,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/new"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Resume => SlashCommandSpec { description: "resume a saved chat", @@ -249,7 +300,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/resume"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Fork => SlashCommandSpec { description: "fork the current chat", @@ -258,7 +309,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/fork"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Init => SlashCommandSpec { description: "create an AGENTS.md file with instructions for Codex", @@ -267,7 +318,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/init"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Compact => SlashCommandSpec { description: "summarize conversation to prevent hitting the context limit", @@ -276,7 +327,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/compact"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Plan => SlashCommandSpec { description: "switch to Plan mode", @@ -285,7 +336,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/plan", "/plan <prompt>"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::Remainder(ParsedSlashCommand::Plan), + parser: parse_rich_text_command, }, SlashCommand::Collab => SlashCommandSpec { description: "change collaboration mode (experimental)", @@ -294,7 +345,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/collab"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Agent => SlashCommandSpec { description: "switch the active agent thread", @@ -303,7 +354,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/agent"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Diff => SlashCommandSpec { description: "show git diff (including untracked files)", @@ -312,7 +363,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/diff"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Copy => SlashCommandSpec { description: "copy the latest Codex output to your clipboard", @@ -321,7 +372,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/copy"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Mention => SlashCommandSpec { description: "mention a file", @@ -330,7 +381,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/mention"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Status => SlashCommandSpec { description: "show current session configuration and token usage", @@ -339,7 +390,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/status"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::DebugConfig => SlashCommandSpec { description: "show config layers and requirement sources for debugging", @@ -348,7 +399,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/debug-config"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Title => SlashCommandSpec { description: "configure which items appear in the terminal title", @@ -357,7 +408,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/title"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Statusline => SlashCommandSpec { description: "configure which items appear in the status line", @@ -366,7 +417,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/statusline"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Theme => SlashCommandSpec { description: "choose a syntax highlighting theme", @@ -375,7 +426,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/theme"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Mcp => SlashCommandSpec { description: "list configured MCP tools", @@ -384,7 +435,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/mcp"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Apps => SlashCommandSpec { description: "manage apps", @@ -393,7 +444,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/apps"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Plugins => SlashCommandSpec { description: "browse plugins", @@ -402,7 +453,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/plugins"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Logout => SlashCommandSpec { description: "log out of Codex", @@ -411,7 +462,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/logout"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Quit => SlashCommandSpec { description: "exit Codex", @@ -420,7 +471,7 @@ impl SlashCommand { hide_in_command_popup: true, usage_lines: &["/quit"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Exit => SlashCommandSpec { description: "exit Codex", @@ -429,7 +480,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/exit"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Feedback => SlashCommandSpec { description: "send logs to maintainers", @@ -438,7 +489,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/feedback"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Rollout => SlashCommandSpec { description: "print the rollout file path", @@ -447,7 +498,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/rollout"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Ps => SlashCommandSpec { description: "list background terminals", @@ -456,7 +507,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/ps"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Stop => SlashCommandSpec { description: "stop all background terminals", @@ -465,7 +516,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/stop"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Clear => SlashCommandSpec { description: "clear the terminal and start a new chat", @@ -474,7 +525,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/clear"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Personality => SlashCommandSpec { description: "choose a communication style for Codex", @@ -483,7 +534,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/personality"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Realtime => SlashCommandSpec { description: "toggle realtime voice mode (experimental)", @@ -492,7 +543,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/realtime"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Settings => SlashCommandSpec { description: "configure realtime microphone/speaker", @@ -501,7 +552,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/settings"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::TestApproval => SlashCommandSpec { description: "test approval request", @@ -510,7 +561,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/test-approval"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::MultiAgents => SlashCommandSpec { description: "switch the active agent thread", @@ -519,7 +570,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/subagents"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::MemoryDrop => SlashCommandSpec { description: "DO NOT USE", @@ -528,7 +579,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/debug-m-drop"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::MemoryUpdate => SlashCommandSpec { description: "DO NOT USE", @@ -537,7 +588,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/debug-m-update"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, } } @@ -591,7 +642,7 @@ pub(crate) struct SlashCommandSpec { pub(crate) hide_in_command_popup: bool, pub(crate) usage_lines: &'static [&'static str], pub(crate) bare_behavior: SlashCommandBareBehavior, - pub(crate) args_parser: SlashCommandArgsParser, + pub(crate) parser: SlashCommandParser, } #[cfg(test)] diff --git a/codex-rs/tui/src/slash_command_input.rs b/codex-rs/tui/src/slash_command_input.rs index 81b560c593..b64db3f739 100644 --- a/codex-rs/tui/src/slash_command_input.rs +++ b/codex-rs/tui/src/slash_command_input.rs @@ -1,6 +1,9 @@ -use crate::slash_command::ParsedSlashCommand; +use codex_protocol::user_input::TextElement; + use crate::slash_command::SlashCommand; +use crate::slash_command::SlashCommandParseInput; use crate::slash_command::SlashCommandUsageErrorKind; +use crate::slash_command_invocation::SlashCommandInvocation; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct SlashCommandUsageError { @@ -25,19 +28,29 @@ impl SlashCommand { pub(crate) fn parse_invocation( self, args: &str, - ) -> Result<ParsedSlashCommand, SlashCommandUsageError> { - let trimmed = args.trim(); + text_elements: &[TextElement], + ) -> Result<SlashCommandInvocation, SlashCommandUsageError> { let spec = self.spec(); - if trimmed.is_empty() { - return Ok(ParsedSlashCommand::Bare(spec.bare_behavior)); + if args.trim().is_empty() { + return match spec.bare_behavior { + crate::slash_command::SlashCommandBareBehavior::DispatchesDirectly + | crate::slash_command::SlashCommandBareBehavior::OpensUi => { + Ok(SlashCommandInvocation::Bare(self)) + } + }; } - spec.args_parser - .parse(trimmed) - .map_err(|kind| SlashCommandUsageError { - command: self, - kind, - }) + (spec.parser)( + self, + SlashCommandParseInput { + args, + text_elements, + }, + ) + .map_err(|kind| SlashCommandUsageError { + command: self, + kind, + }) } } @@ -46,22 +59,41 @@ mod tests { use pretty_assertions::assert_eq; use super::*; - use crate::slash_command::FastSlashCommandArgs; use crate::slash_command::SlashCommandBareBehavior; + use crate::slash_command_invocation::FastSlashCommandArgs; + use crate::slash_command_invocation::SlashCommandTextArg; #[test] fn review_bare_form_is_marked_as_ui_driven() { assert_eq!( - SlashCommand::Review.parse_invocation(""), - Ok(ParsedSlashCommand::Bare(SlashCommandBareBehavior::OpensUi)) + SlashCommand::Review.parse_invocation("", &[]), + Ok(SlashCommandInvocation::Bare(SlashCommand::Review)) + ); + assert_eq!( + SlashCommand::Review.spec().bare_behavior, + SlashCommandBareBehavior::OpensUi ); } #[test] fn fast_accepts_nonempty_inline_args() { assert_eq!( - SlashCommand::Fast.parse_invocation("status"), - Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Status)) + SlashCommand::Fast.parse_invocation("status", &[]), + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Status)) + ); + } + + #[test] + fn review_preserves_placeholder_elements() { + let placeholder = "[Image #1]".to_string(); + let text_elements = vec![TextElement::new((0..11).into(), Some(placeholder.clone()))]; + + assert_eq!( + SlashCommand::Review.parse_invocation(&placeholder, &text_elements), + Ok(SlashCommandInvocation::Review(SlashCommandTextArg::new( + placeholder, + text_elements, + ))) ); } @@ -69,7 +101,7 @@ mod tests { fn clear_rejects_unexpected_inline_args() { assert_eq!( SlashCommand::Clear - .parse_invocation("now") + .parse_invocation("now", &[]) .unwrap_err() .message(), "'/clear' does not accept inline arguments. Usage: /clear" diff --git a/codex-rs/tui/src/slash_command_invocation.rs b/codex-rs/tui/src/slash_command_invocation.rs index 2cc19ecd57..e5f65a1540 100644 --- a/codex-rs/tui/src/slash_command_invocation.rs +++ b/codex-rs/tui/src/slash_command_invocation.rs @@ -1,40 +1,166 @@ +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; + use crate::slash_command::SlashCommand; -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct SlashCommandInvocation { - command: SlashCommand, - args: Vec<String>, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FastSlashCommandArgs { + On, + Off, + Status, +} + +impl FastSlashCommandArgs { + fn as_str(self) -> &'static str { + match self { + FastSlashCommandArgs::On => "on", + FastSlashCommandArgs::Off => "off", + FastSlashCommandArgs::Status => "status", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlashCommandTextArg { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + +impl SlashCommandTextArg { + pub(crate) fn new(text: String, text_elements: Vec<TextElement>) -> Self { + Self { + text, + text_elements, + } + } + + fn with_prefix(&self, prefix: &str) -> SerializedSlashCommand { + let prefix_len = prefix.len(); + let text = if self.text.is_empty() { + prefix.to_string() + } else { + format!("{prefix} {}", self.text) + }; + let offset = prefix_len + usize::from(!self.text.is_empty()); + let text_elements = self + .text_elements + .iter() + .map(|element| { + element.map_range(|byte_range| ByteRange { + start: byte_range.start + offset, + end: byte_range.end + offset, + }) + }) + .collect(); + SerializedSlashCommand { + text, + text_elements, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum SlashCommandInvocation { + Bare(SlashCommand), + Fast(FastSlashCommandArgs), + Rename(SlashCommandTextArg), + Plan(SlashCommandTextArg), + Review(SlashCommandTextArg), + SandboxReadRoot(SlashCommandTextArg), } impl SlashCommandInvocation { pub(crate) fn bare(command: SlashCommand) -> Self { - Self { - command, - args: Vec::new(), + Self::Bare(command) + } + + pub(crate) fn command(&self) -> SlashCommand { + match self { + SlashCommandInvocation::Bare(command) => *command, + SlashCommandInvocation::Fast(_) => SlashCommand::Fast, + SlashCommandInvocation::Rename(_) => SlashCommand::Rename, + SlashCommandInvocation::Plan(_) => SlashCommand::Plan, + SlashCommandInvocation::Review(_) => SlashCommand::Review, + SlashCommandInvocation::SandboxReadRoot(_) => SlashCommand::SandboxReadRoot, + } + } + + pub(crate) fn serialize(&self) -> SerializedSlashCommand { + let command = self.command().command(); + let prefix = format!("/{command}"); + match self { + SlashCommandInvocation::Bare(_) => SerializedSlashCommand { + text: prefix, + text_elements: Vec::new(), + }, + SlashCommandInvocation::Fast(args) => SerializedSlashCommand { + text: format!("{prefix} {}", args.as_str()), + text_elements: Vec::new(), + }, + SlashCommandInvocation::Rename(arg) + | SlashCommandInvocation::Plan(arg) + | SlashCommandInvocation::Review(arg) + | SlashCommandInvocation::SandboxReadRoot(arg) => arg.with_prefix(&prefix), } } pub(crate) fn into_prefixed_string(self) -> String { - let command = self.command.command(); - let joined = match shlex::try_join( - std::iter::once(command).chain(self.args.iter().map(String::as_str)), - ) { - Ok(joined) => joined, - Err(err) => panic!("slash command invocation should serialize: {err}"), - }; - format!("/{joined}") + self.serialize().text } } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SerializedSlashCommand { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + #[cfg(test)] mod tests { - use super::*; use pretty_assertions::assert_eq; + use super::*; + #[test] fn bare_invocation_serializes_with_leading_slash() { let invocation = SlashCommandInvocation::bare(SlashCommand::Model); - assert_eq!(invocation.into_prefixed_string(), "/model"); + assert_eq!( + invocation.serialize(), + SerializedSlashCommand { + text: "/model".to_string(), + text_elements: Vec::new(), + } + ); + } + + #[test] + fn fast_invocation_serializes_canonical_token() { + let invocation = SlashCommandInvocation::Fast(FastSlashCommandArgs::Status); + + assert_eq!( + invocation.serialize(), + SerializedSlashCommand { + text: "/fast status".to_string(), + text_elements: Vec::new(), + } + ); + } + + #[test] + fn variadic_invocation_preserves_placeholder_ranges() { + let placeholder = "[Image #1]".to_string(); + let invocation = SlashCommandInvocation::Plan(SlashCommandTextArg::new( + format!("review {placeholder}"), + vec![TextElement::new((7..18).into(), Some(placeholder.clone()))], + )); + + assert_eq!( + invocation.serialize(), + SerializedSlashCommand { + text: format!("/plan review {placeholder}"), + text_elements: vec![TextElement::new((13..24).into(), Some(placeholder),)], + } + ); } } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 687a665bb5..f7e343f8f1 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -315,9 +315,9 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; -use crate::slash_command::FastSlashCommandArgs; -use crate::slash_command::ParsedSlashCommand; use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::FastSlashCommandArgs; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::status::RateLimitSnapshotDisplay; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; @@ -4881,11 +4881,28 @@ impl ChatWidget { } } + fn prepare_inline_command_invocation( + &mut self, + cmd: SlashCommand, + record_history: bool, + ) -> Option<SlashCommandInvocation> { + let (prepared_args, prepared_elements) = self + .bottom_pane + .prepare_inline_args_submission(record_history)?; + match cmd.parse_invocation(&prepared_args, &prepared_elements) { + Ok(invocation) => Some(invocation), + Err(err) => { + self.add_error_message(err.message()); + None + } + } + } + fn dispatch_command_with_args( &mut self, cmd: SlashCommand, args: String, - _text_elements: Vec<TextElement>, + text_elements: Vec<TextElement>, ) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -4897,17 +4914,17 @@ impl ChatWidget { return; } - match cmd.parse_invocation(&args) { - Ok(ParsedSlashCommand::Bare(_)) => { + match cmd.parse_invocation(&args, &text_elements) { + Ok(SlashCommandInvocation::Bare(_)) => { self.dispatch_command(cmd); } - Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::On)) => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::On)) => { self.set_service_tier_selection(Some(ServiceTier::Fast)); } - Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Off)) => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Off)) => { self.set_service_tier_selection(/*service_tier*/ None); } - Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Status)) => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Status)) => { let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { "on" } else { @@ -4915,16 +4932,16 @@ impl ChatWidget { }; self.add_info_message(format!("Fast mode is {status}."), /*hint*/ None); } - Ok(ParsedSlashCommand::Rename) => { + Ok(SlashCommandInvocation::Rename(_)) => { self.session_telemetry .counter("codex.thread.rename", /*inc*/ 1, &[]); - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) + let Some(SlashCommandInvocation::Rename(prepared_args)) = + self.prepare_inline_command_invocation(cmd, /*record_history*/ false) else { return; }; - let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else { + let Some(name) = codex_core::util::normalize_thread_name(&prepared_args.text) + else { self.add_error_message("Thread name cannot be empty.".to_string()); return; }; @@ -4934,14 +4951,13 @@ impl ChatWidget { self.app_event_tx.set_thread_name(name); self.bottom_pane.drain_pending_submission_state(); } - Ok(ParsedSlashCommand::Plan) => { + Ok(SlashCommandInvocation::Plan(_)) => { 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(/*record_history*/ true) + let Some(SlashCommandInvocation::Plan(prepared_args)) = + self.prepare_inline_command_invocation(cmd, /*record_history*/ true) else { return; }; @@ -4949,11 +4965,15 @@ impl ChatWidget { .bottom_pane .take_recent_submission_images_with_placeholders(); let remote_image_urls = self.take_remote_image_urls(); + let crate::slash_command_invocation::SlashCommandTextArg { + text, + text_elements, + } = prepared_args; let user_message = UserMessage { - text: prepared_args, + text, local_images, remote_image_urls, - text_elements: prepared_elements, + text_elements, mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), }; if self.is_session_configured() { @@ -4965,31 +4985,29 @@ impl ChatWidget { self.queue_user_message(user_message); } } - Ok(ParsedSlashCommand::Review) => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) + Ok(SlashCommandInvocation::Review(_)) => { + let Some(SlashCommandInvocation::Review(prepared_args)) = + self.prepare_inline_command_invocation(cmd, /*record_history*/ false) else { return; }; self.submit_op(AppCommand::review(ReviewRequest { target: ReviewTarget::Custom { - instructions: prepared_args, + instructions: prepared_args.text, }, user_facing_hint: None, })); self.bottom_pane.drain_pending_submission_state(); } - Ok(ParsedSlashCommand::SandboxReadRoot) => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) + Ok(SlashCommandInvocation::SandboxReadRoot(_)) => { + let Some(SlashCommandInvocation::SandboxReadRoot(prepared_args)) = + self.prepare_inline_command_invocation(cmd, /*record_history*/ false) else { return; }; self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { - path: prepared_args, + path: prepared_args.text, }); self.bottom_pane.drain_pending_submission_state(); } diff --git a/codex-rs/tui_app_server/src/slash_command.rs b/codex-rs/tui_app_server/src/slash_command.rs index d2239f59bb..01ec86e59f 100644 --- a/codex-rs/tui_app_server/src/slash_command.rs +++ b/codex-rs/tui_app_server/src/slash_command.rs @@ -4,6 +4,12 @@ use strum_macros::EnumIter; use strum_macros::EnumString; use strum_macros::IntoStaticStr; +use codex_protocol::user_input::TextElement; + +use crate::slash_command_invocation::FastSlashCommandArgs; +use crate::slash_command_invocation::SlashCommandInvocation; +use crate::slash_command_invocation::SlashCommandTextArg; + /// Commands that can be invoked by starting a message with a leading slash. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr, @@ -71,74 +77,112 @@ pub(crate) enum SlashCommandBareBehavior { OpensUi, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum FastSlashCommandArgs { - On, - Off, - Status, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ParsedSlashCommand { - Bare(SlashCommandBareBehavior), - Fast(FastSlashCommandArgs), - Rename, - Plan, - Review, - SandboxReadRoot, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SlashCommandUsageErrorKind { UnexpectedInlineArgs, InvalidInlineArgs, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SlashCommandArgsParser { - NoArgs, - Remainder(ParsedSlashCommand), - Tokens(&'static [SlashCommandTokenChoice]), +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashCommandParseInput<'a> { + pub(crate) args: &'a str, + pub(crate) text_elements: &'a [TextElement], } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct SlashCommandTokenChoice { - pub(crate) token: &'static str, - pub(crate) parsed: ParsedSlashCommand, -} +pub(crate) type SlashCommandParser = + for<'a> fn( + SlashCommand, + SlashCommandParseInput<'a>, + ) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind>; -const FAST_TOKEN_CHOICES: &[SlashCommandTokenChoice] = &[ - SlashCommandTokenChoice { - token: "on", - parsed: ParsedSlashCommand::Fast(FastSlashCommandArgs::On), - }, - SlashCommandTokenChoice { - token: "off", - parsed: ParsedSlashCommand::Fast(FastSlashCommandArgs::Off), - }, - SlashCommandTokenChoice { - token: "status", - parsed: ParsedSlashCommand::Fast(FastSlashCommandArgs::Status), - }, -]; - -impl SlashCommandArgsParser { - pub(crate) fn parse( - self, - args: &str, - ) -> Result<ParsedSlashCommand, SlashCommandUsageErrorKind> { - match self { - SlashCommandArgsParser::NoArgs => Err(SlashCommandUsageErrorKind::UnexpectedInlineArgs), - SlashCommandArgsParser::Remainder(parsed) => Ok(parsed), - SlashCommandArgsParser::Tokens(choices) => choices - .iter() - .find(|choice| choice.token.eq_ignore_ascii_case(args)) - .map(|choice| choice.parsed) - .ok_or(SlashCommandUsageErrorKind::InvalidInlineArgs), - } +fn parse_no_args( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + if input.args.trim().is_empty() { + Ok(SlashCommandInvocation::Bare(command)) + } else { + Err(SlashCommandUsageErrorKind::UnexpectedInlineArgs) } } +fn parse_fast( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + if !input.text_elements.is_empty() { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + match input.args.trim() { + "" => Ok(SlashCommandInvocation::Bare(command)), + args if args.eq_ignore_ascii_case("on") => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::On)) + } + args if args.eq_ignore_ascii_case("off") => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Off)) + } + args if args.eq_ignore_ascii_case("status") => { + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Status)) + } + _ => Err(SlashCommandUsageErrorKind::InvalidInlineArgs), + } +} + +fn parse_text_arg( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + let args = input.args.trim(); + if args.is_empty() { + return Ok(SlashCommandInvocation::Bare(command)); + } + + let arg = SlashCommandTextArg::new(args.to_string(), input.text_elements.to_vec()); + + match command { + SlashCommand::Rename => Ok(SlashCommandInvocation::Rename(arg)), + SlashCommand::Plan => Ok(SlashCommandInvocation::Plan(arg)), + SlashCommand::Review => Ok(SlashCommandInvocation::Review(arg)), + SlashCommand::SandboxReadRoot => Ok(SlashCommandInvocation::SandboxReadRoot(arg)), + _ => Err(SlashCommandUsageErrorKind::InvalidInlineArgs), + } +} + +fn parse_model( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + parse_no_args(command, input) +} + +fn parse_ui_only( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + parse_no_args(command, input) +} + +fn parse_dispatch_only( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + parse_no_args(command, input) +} + +fn parse_fast_command( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + parse_fast(command, input) +} + +fn parse_rich_text_command( + command: SlashCommand, + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + parse_text_arg(command, input) +} + impl SlashCommand { pub(crate) fn spec(self) -> SlashCommandSpec { match self { @@ -149,7 +193,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/model"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_model, }, SlashCommand::Fast => SlashCommandSpec { description: "toggle Fast mode to enable fastest inference at 2X plan usage", @@ -158,7 +202,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/fast", "/fast [on|off|status]"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::Tokens(FAST_TOKEN_CHOICES), + parser: parse_fast_command, }, SlashCommand::Approvals => SlashCommandSpec { description: "choose what Codex is allowed to do", @@ -167,7 +211,7 @@ impl SlashCommand { hide_in_command_popup: true, usage_lines: &["/approvals"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Permissions => SlashCommandSpec { description: "choose what Codex is allowed to do", @@ -176,7 +220,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/permissions"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::ElevateSandbox => SlashCommandSpec { description: "set up elevated agent sandbox", @@ -185,7 +229,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/setup-default-sandbox"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::SandboxReadRoot => SlashCommandSpec { description: "let sandbox read a directory: /sandbox-add-read-dir <absolute_path>", @@ -194,7 +238,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/sandbox-add-read-dir <absolute-path>"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::Remainder(ParsedSlashCommand::SandboxReadRoot), + parser: parse_rich_text_command, }, SlashCommand::Experimental => SlashCommandSpec { description: "toggle experimental features", @@ -203,7 +247,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/experimental"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Skills => SlashCommandSpec { description: "use skills to improve how Codex performs specific tasks", @@ -212,7 +256,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/skills"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Review => SlashCommandSpec { description: "review my current changes and find issues", @@ -221,7 +265,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/review", "/review <instructions>"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::Remainder(ParsedSlashCommand::Review), + parser: parse_rich_text_command, }, SlashCommand::Rename => SlashCommandSpec { description: "rename the current thread", @@ -230,7 +274,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/rename", "/rename <title>"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::Remainder(ParsedSlashCommand::Rename), + parser: parse_rich_text_command, }, SlashCommand::New => SlashCommandSpec { description: "start a new chat during a conversation", @@ -239,7 +283,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/new"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Resume => SlashCommandSpec { description: "resume a saved chat", @@ -248,7 +292,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/resume"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Fork => SlashCommandSpec { description: "fork the current chat", @@ -257,7 +301,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/fork"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Init => SlashCommandSpec { description: "create an AGENTS.md file with instructions for Codex", @@ -266,7 +310,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/init"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Compact => SlashCommandSpec { description: "summarize conversation to prevent hitting the context limit", @@ -275,7 +319,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/compact"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Plan => SlashCommandSpec { description: "switch to Plan mode", @@ -284,7 +328,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/plan", "/plan <prompt>"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::Remainder(ParsedSlashCommand::Plan), + parser: parse_rich_text_command, }, SlashCommand::Collab => SlashCommandSpec { description: "change collaboration mode (experimental)", @@ -293,7 +337,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/collab"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Agent => SlashCommandSpec { description: "switch the active agent thread", @@ -302,7 +346,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/agent"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Diff => SlashCommandSpec { description: "show git diff (including untracked files)", @@ -311,7 +355,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/diff"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Copy => SlashCommandSpec { description: "copy the latest Codex output to your clipboard", @@ -320,7 +364,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/copy"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Mention => SlashCommandSpec { description: "mention a file", @@ -329,7 +373,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/mention"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Status => SlashCommandSpec { description: "show current session configuration and token usage", @@ -338,7 +382,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/status"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::DebugConfig => SlashCommandSpec { description: "show config layers and requirement sources for debugging", @@ -347,7 +391,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/debug-config"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Statusline => SlashCommandSpec { description: "configure which items appear in the status line", @@ -356,7 +400,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/statusline"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Theme => SlashCommandSpec { description: "choose a syntax highlighting theme", @@ -365,7 +409,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/theme"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Mcp => SlashCommandSpec { description: "list configured MCP tools", @@ -374,7 +418,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/mcp"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Apps => SlashCommandSpec { description: "manage apps", @@ -383,7 +427,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/apps"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Plugins => SlashCommandSpec { description: "browse plugins", @@ -392,7 +436,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/plugins"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Logout => SlashCommandSpec { description: "log out of Codex", @@ -401,7 +445,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/logout"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Quit => SlashCommandSpec { description: "exit Codex", @@ -410,7 +454,7 @@ impl SlashCommand { hide_in_command_popup: true, usage_lines: &["/quit"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Exit => SlashCommandSpec { description: "exit Codex", @@ -419,7 +463,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/exit"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Feedback => SlashCommandSpec { description: "send logs to maintainers", @@ -428,7 +472,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/feedback"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Rollout => SlashCommandSpec { description: "print the rollout file path", @@ -437,7 +481,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/rollout"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Ps => SlashCommandSpec { description: "list background terminals", @@ -446,7 +490,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/ps"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Stop => SlashCommandSpec { description: "stop all background terminals", @@ -455,7 +499,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/stop"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Clear => SlashCommandSpec { description: "clear the terminal and start a new chat", @@ -464,7 +508,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/clear"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Personality => SlashCommandSpec { description: "choose a communication style for Codex", @@ -473,7 +517,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/personality"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::Realtime => SlashCommandSpec { description: "toggle realtime voice mode (experimental)", @@ -482,7 +526,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/realtime"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::Settings => SlashCommandSpec { description: "configure realtime microphone/speaker", @@ -491,7 +535,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/settings"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::TestApproval => SlashCommandSpec { description: "test approval request", @@ -500,7 +544,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/test-approval"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::MultiAgents => SlashCommandSpec { description: "switch the active agent thread", @@ -509,7 +553,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/subagents"], bare_behavior: SlashCommandBareBehavior::OpensUi, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_ui_only, }, SlashCommand::MemoryDrop => SlashCommandSpec { description: "DO NOT USE", @@ -518,7 +562,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/debug-m-drop"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, SlashCommand::MemoryUpdate => SlashCommandSpec { description: "DO NOT USE", @@ -527,7 +571,7 @@ impl SlashCommand { hide_in_command_popup: false, usage_lines: &["/debug-m-update"], bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, - args_parser: SlashCommandArgsParser::NoArgs, + parser: parse_dispatch_only, }, } } @@ -581,7 +625,7 @@ pub(crate) struct SlashCommandSpec { pub(crate) hide_in_command_popup: bool, pub(crate) usage_lines: &'static [&'static str], pub(crate) bare_behavior: SlashCommandBareBehavior, - pub(crate) args_parser: SlashCommandArgsParser, + pub(crate) parser: SlashCommandParser, } #[cfg(test)] diff --git a/codex-rs/tui_app_server/src/slash_command_input.rs b/codex-rs/tui_app_server/src/slash_command_input.rs index 81b560c593..b64db3f739 100644 --- a/codex-rs/tui_app_server/src/slash_command_input.rs +++ b/codex-rs/tui_app_server/src/slash_command_input.rs @@ -1,6 +1,9 @@ -use crate::slash_command::ParsedSlashCommand; +use codex_protocol::user_input::TextElement; + use crate::slash_command::SlashCommand; +use crate::slash_command::SlashCommandParseInput; use crate::slash_command::SlashCommandUsageErrorKind; +use crate::slash_command_invocation::SlashCommandInvocation; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct SlashCommandUsageError { @@ -25,19 +28,29 @@ impl SlashCommand { pub(crate) fn parse_invocation( self, args: &str, - ) -> Result<ParsedSlashCommand, SlashCommandUsageError> { - let trimmed = args.trim(); + text_elements: &[TextElement], + ) -> Result<SlashCommandInvocation, SlashCommandUsageError> { let spec = self.spec(); - if trimmed.is_empty() { - return Ok(ParsedSlashCommand::Bare(spec.bare_behavior)); + if args.trim().is_empty() { + return match spec.bare_behavior { + crate::slash_command::SlashCommandBareBehavior::DispatchesDirectly + | crate::slash_command::SlashCommandBareBehavior::OpensUi => { + Ok(SlashCommandInvocation::Bare(self)) + } + }; } - spec.args_parser - .parse(trimmed) - .map_err(|kind| SlashCommandUsageError { - command: self, - kind, - }) + (spec.parser)( + self, + SlashCommandParseInput { + args, + text_elements, + }, + ) + .map_err(|kind| SlashCommandUsageError { + command: self, + kind, + }) } } @@ -46,22 +59,41 @@ mod tests { use pretty_assertions::assert_eq; use super::*; - use crate::slash_command::FastSlashCommandArgs; use crate::slash_command::SlashCommandBareBehavior; + use crate::slash_command_invocation::FastSlashCommandArgs; + use crate::slash_command_invocation::SlashCommandTextArg; #[test] fn review_bare_form_is_marked_as_ui_driven() { assert_eq!( - SlashCommand::Review.parse_invocation(""), - Ok(ParsedSlashCommand::Bare(SlashCommandBareBehavior::OpensUi)) + SlashCommand::Review.parse_invocation("", &[]), + Ok(SlashCommandInvocation::Bare(SlashCommand::Review)) + ); + assert_eq!( + SlashCommand::Review.spec().bare_behavior, + SlashCommandBareBehavior::OpensUi ); } #[test] fn fast_accepts_nonempty_inline_args() { assert_eq!( - SlashCommand::Fast.parse_invocation("status"), - Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Status)) + SlashCommand::Fast.parse_invocation("status", &[]), + Ok(SlashCommandInvocation::Fast(FastSlashCommandArgs::Status)) + ); + } + + #[test] + fn review_preserves_placeholder_elements() { + let placeholder = "[Image #1]".to_string(); + let text_elements = vec![TextElement::new((0..11).into(), Some(placeholder.clone()))]; + + assert_eq!( + SlashCommand::Review.parse_invocation(&placeholder, &text_elements), + Ok(SlashCommandInvocation::Review(SlashCommandTextArg::new( + placeholder, + text_elements, + ))) ); } @@ -69,7 +101,7 @@ mod tests { fn clear_rejects_unexpected_inline_args() { assert_eq!( SlashCommand::Clear - .parse_invocation("now") + .parse_invocation("now", &[]) .unwrap_err() .message(), "'/clear' does not accept inline arguments. Usage: /clear" diff --git a/codex-rs/tui_app_server/src/slash_command_invocation.rs b/codex-rs/tui_app_server/src/slash_command_invocation.rs index 2cc19ecd57..e5f65a1540 100644 --- a/codex-rs/tui_app_server/src/slash_command_invocation.rs +++ b/codex-rs/tui_app_server/src/slash_command_invocation.rs @@ -1,40 +1,166 @@ +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; + use crate::slash_command::SlashCommand; -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct SlashCommandInvocation { - command: SlashCommand, - args: Vec<String>, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FastSlashCommandArgs { + On, + Off, + Status, +} + +impl FastSlashCommandArgs { + fn as_str(self) -> &'static str { + match self { + FastSlashCommandArgs::On => "on", + FastSlashCommandArgs::Off => "off", + FastSlashCommandArgs::Status => "status", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlashCommandTextArg { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + +impl SlashCommandTextArg { + pub(crate) fn new(text: String, text_elements: Vec<TextElement>) -> Self { + Self { + text, + text_elements, + } + } + + fn with_prefix(&self, prefix: &str) -> SerializedSlashCommand { + let prefix_len = prefix.len(); + let text = if self.text.is_empty() { + prefix.to_string() + } else { + format!("{prefix} {}", self.text) + }; + let offset = prefix_len + usize::from(!self.text.is_empty()); + let text_elements = self + .text_elements + .iter() + .map(|element| { + element.map_range(|byte_range| ByteRange { + start: byte_range.start + offset, + end: byte_range.end + offset, + }) + }) + .collect(); + SerializedSlashCommand { + text, + text_elements, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum SlashCommandInvocation { + Bare(SlashCommand), + Fast(FastSlashCommandArgs), + Rename(SlashCommandTextArg), + Plan(SlashCommandTextArg), + Review(SlashCommandTextArg), + SandboxReadRoot(SlashCommandTextArg), } impl SlashCommandInvocation { pub(crate) fn bare(command: SlashCommand) -> Self { - Self { - command, - args: Vec::new(), + Self::Bare(command) + } + + pub(crate) fn command(&self) -> SlashCommand { + match self { + SlashCommandInvocation::Bare(command) => *command, + SlashCommandInvocation::Fast(_) => SlashCommand::Fast, + SlashCommandInvocation::Rename(_) => SlashCommand::Rename, + SlashCommandInvocation::Plan(_) => SlashCommand::Plan, + SlashCommandInvocation::Review(_) => SlashCommand::Review, + SlashCommandInvocation::SandboxReadRoot(_) => SlashCommand::SandboxReadRoot, + } + } + + pub(crate) fn serialize(&self) -> SerializedSlashCommand { + let command = self.command().command(); + let prefix = format!("/{command}"); + match self { + SlashCommandInvocation::Bare(_) => SerializedSlashCommand { + text: prefix, + text_elements: Vec::new(), + }, + SlashCommandInvocation::Fast(args) => SerializedSlashCommand { + text: format!("{prefix} {}", args.as_str()), + text_elements: Vec::new(), + }, + SlashCommandInvocation::Rename(arg) + | SlashCommandInvocation::Plan(arg) + | SlashCommandInvocation::Review(arg) + | SlashCommandInvocation::SandboxReadRoot(arg) => arg.with_prefix(&prefix), } } pub(crate) fn into_prefixed_string(self) -> String { - let command = self.command.command(); - let joined = match shlex::try_join( - std::iter::once(command).chain(self.args.iter().map(String::as_str)), - ) { - Ok(joined) => joined, - Err(err) => panic!("slash command invocation should serialize: {err}"), - }; - format!("/{joined}") + self.serialize().text } } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SerializedSlashCommand { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + #[cfg(test)] mod tests { - use super::*; use pretty_assertions::assert_eq; + use super::*; + #[test] fn bare_invocation_serializes_with_leading_slash() { let invocation = SlashCommandInvocation::bare(SlashCommand::Model); - assert_eq!(invocation.into_prefixed_string(), "/model"); + assert_eq!( + invocation.serialize(), + SerializedSlashCommand { + text: "/model".to_string(), + text_elements: Vec::new(), + } + ); + } + + #[test] + fn fast_invocation_serializes_canonical_token() { + let invocation = SlashCommandInvocation::Fast(FastSlashCommandArgs::Status); + + assert_eq!( + invocation.serialize(), + SerializedSlashCommand { + text: "/fast status".to_string(), + text_elements: Vec::new(), + } + ); + } + + #[test] + fn variadic_invocation_preserves_placeholder_ranges() { + let placeholder = "[Image #1]".to_string(); + let invocation = SlashCommandInvocation::Plan(SlashCommandTextArg::new( + format!("review {placeholder}"), + vec![TextElement::new((7..18).into(), Some(placeholder.clone()))], + )); + + assert_eq!( + invocation.serialize(), + SerializedSlashCommand { + text: format!("/plan review {placeholder}"), + text_elements: vec![TextElement::new((13..24).into(), Some(placeholder),)], + } + ); } }