diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b3f8d880277..24e026f0573 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -965,7 +965,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -974,6 +983,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -2624,6 +2639,7 @@ dependencies = [ "libc", "pathdiff", "pretty_assertions", + "proptest", "pulldown-cmark", "rand 0.9.2", "ratatui", @@ -2716,6 +2732,7 @@ dependencies = [ "libc", "pathdiff", "pretty_assertions", + "proptest", "pulldown-cmark", "rand 0.9.2", "ratatui", @@ -5305,7 +5322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -5661,7 +5678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a1cbf952127589f2851ab2046af368fd20645491bb4b376f04b7f94d7a9837b" dependencies = [ "ascii-canvas", - "bit-set", + "bit-set 0.5.3", "diff", "ena", "is-terminal", @@ -7345,12 +7362,16 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags 2.10.0", "num-traits", "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax 0.8.8", + "rusty-fork", + "tempfile", "unarray", ] @@ -7420,6 +7441,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -8384,6 +8411,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustyline" version = "14.0.0" @@ -9963,7 +10002,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg 0.4.21", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6d768d69634..9b36717e3f4 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -237,6 +237,7 @@ pathdiff = "0.2" portable-pty = "0.9.0" predicates = "3" pretty_assertions = "1.4.1" +proptest = "1.7.0" pulldown-cmark = "0.10" quick-xml = "0.38.4" rand = "0.9" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 8013b1325ed..5f11d3c1289 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -142,6 +142,7 @@ assert_matches = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } pretty_assertions = { workspace = true } +proptest = { workspace = true } rand = { workspace = true } serial_test = { workspace = true } vt100 = { workspace = true } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8116cf972b2..f4281c4acfd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -192,6 +192,7 @@ use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; +use crate::slash_command::SlashCommandInvocation; use crate::style::user_message_style; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; @@ -1423,12 +1424,13 @@ impl ChatComposer { return (InputResult::Command(cmd), true); } - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); + let bare_command = + SlashCommandInvocation::bare(cmd).into_prefixed_string(); + let starts_with_cmd = + first_line.trim_start().starts_with(&bare_command); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("{bare_command} ")); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); @@ -2566,10 +2568,6 @@ impl ChatComposer { } let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; - - if !cmd.supports_inline_args() { - return None; - } if self.reject_slash_command_if_unavailable(cmd) { return Some(InputResult::None); } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index e7269c38efd..4c865ecc9e0 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -14,11 +14,6 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; -// Hide alias commands in the default popup list so each unique action appears once. -// `quit` is an alias of `exit`, so we skip `quit` here. -// `approvals` is an alias of `permissions`. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; - /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { @@ -145,7 +140,7 @@ impl CommandPopup { if filter.is_empty() { // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { - if ALIAS_COMMANDS.contains(cmd) { + if cmd.hide_in_command_popup() { continue; } out.push((CommandItem::Builtin(*cmd), None)); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c8d9e3b61bd..efb3173e52d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -271,7 +271,13 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; +use crate::slash_command::FastArgs; +use crate::slash_command::FastSlashCommandArgs; +use crate::slash_command::FeedbackArgs; use crate::slash_command::SlashCommand; +use crate::slash_command::SlashCommandInvocation; +use crate::slash_command::SlashTextArg; +use crate::slash_command::StatuslineArgs; use crate::status::RateLimitSnapshotDisplay; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; @@ -4404,17 +4410,7 @@ impl ChatWidget { } match cmd { SlashCommand::Feedback => { - if !self.config.feedback_enabled { - let params = crate::bottom_pane::feedback_disabled_params(); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - return; - } - // Step 1: pick a category (UI built in feedback_view) - let params = - crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); + self.open_feedback_picker_or_disabled_message(); } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); @@ -4732,16 +4728,42 @@ impl ChatWidget { } } + fn open_feedback_picker_or_disabled_message(&mut self) { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return; + } + + let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + + 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.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.", @@ -4752,43 +4774,40 @@ impl ChatWidget { return; } - let trimmed = args.trim(); - match cmd { - SlashCommand::Fast => { - if trimmed.is_empty() { - self.dispatch_command(cmd); - return; - } - match trimmed.to_ascii_lowercase().as_str() { - "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), - "off" => self.set_service_tier_selection(/*service_tier*/ None), - "status" => { - let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) - { - "on" - } else { - "off" - }; - self.add_info_message( - format!("Fast mode is {status}."), - /*hint*/ None, - ); - } - _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); - } - } + match cmd.parse_invocation(&args, &text_elements) { + Ok(SlashCommandInvocation::Bare(_)) => { + self.dispatch_command(cmd); + } + Ok(SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::On, + })) => { + self.set_service_tier_selection(Some(ServiceTier::Fast)); + } + Ok(SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Off, + })) => { + self.set_service_tier_selection(/*service_tier*/ None); + } + Ok(SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Status, + })) => { + let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + "on" + } else { + "off" + }; + self.add_info_message(format!("Fast mode is {status}."), /*hint*/ None); } - SlashCommand::Rename if !trimmed.is_empty() => { + 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.title.text) + else { self.add_error_message("Thread name cannot be empty.".to_string()); return; }; @@ -4799,14 +4818,13 @@ impl ChatWidget { .send(AppEvent::CodexOp(Op::SetThreadName { name })); self.bottom_pane.drain_pending_submission_state(); } - SlashCommand::Plan if !trimmed.is_empty() => { + 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; }; @@ -4814,11 +4832,15 @@ impl ChatWidget { .bottom_pane .take_recent_submission_images_with_placeholders(); let remote_image_urls = self.take_remote_image_urls(); + let SlashTextArg { + text, + text_elements, + } = prepared_args.prompt; 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() { @@ -4830,37 +4852,50 @@ impl ChatWidget { 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(/*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.instructions.text, }, user_facing_hint: None, }, }); self.bottom_pane.drain_pending_submission_state(); } - SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { - 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.path, }); self.bottom_pane.drain_pending_submission_state(); } - _ => self.dispatch_command(cmd), + Ok(SlashCommandInvocation::Feedback(FeedbackArgs { category })) => { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return; + } + self.app_event_tx + .send(AppEvent::OpenFeedbackConsent { category }); + } + Ok(SlashCommandInvocation::Statusline(StatuslineArgs { items })) => { + self.app_event_tx.send(AppEvent::StatusLineSetup { items }); + } + Err(err) => { + self.add_error_message(err.message()); + } } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 54162006911..20946dd5d9b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -122,6 +122,7 @@ mod session_log; mod shimmer; mod skills_helpers; mod slash_command; +mod slash_command_protocol; mod status; mod status_indicator_widget; mod streaming; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb9c..780f122f5da 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -1,13 +1,25 @@ -use strum::IntoEnumIterator; use strum_macros::AsRefStr; -use strum_macros::EnumIter; use strum_macros::EnumString; use strum_macros::IntoStaticStr; +use crate::app_event::FeedbackCategory; +use crate::bottom_pane::StatusLineItem; +use crate::slash_command_protocol::SlashArgsSchema; +use crate::slash_command_protocol::SlashCommandParseInput; +use crate::slash_command_protocol::SlashCommandUsageErrorKind; +use crate::slash_command_protocol::SlashSerializedText; +pub(crate) use crate::slash_command_protocol::SlashTextArg; +use crate::slash_command_protocol::enum_choice; +use crate::slash_command_protocol::from_str_value; +use crate::slash_command_protocol::list; +use crate::slash_command_protocol::named_or_positional; +use crate::slash_command_protocol::positional; +use crate::slash_command_protocol::remainder; +use crate::slash_command_protocol::string; +use crate::slash_command_protocol::text; + /// Commands that can be invoked by starting a message with a leading slash. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, AsRefStr, IntoStaticStr)] #[strum(serialize_all = "kebab-case")] pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so @@ -32,7 +44,6 @@ pub enum SlashCommand { Plan, Collab, Agent, - // Undo, Diff, Copy, Mention, @@ -59,165 +70,1472 @@ pub enum SlashCommand { TestApproval, #[strum(serialize = "subagents")] MultiAgents, - // Debugging commands. #[strum(serialize = "debug-m-drop")] MemoryDrop, #[strum(serialize = "debug-m-update")] MemoryUpdate, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SlashCommandBareBehavior { + DispatchesDirectly, + OpensUi, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SlashCommandUsageError { + command: SlashCommand, + kind: SlashCommandUsageErrorKind, +} + +impl SlashCommandUsageError { + pub(crate) fn message(self) -> String { + let usage = self.command.usage_lines().join(" | "); + match self.kind { + SlashCommandUsageErrorKind::UnexpectedInlineArgs => format!( + "'/{}' does not accept inline arguments. Usage: {usage}", + self.command.command() + ), + SlashCommandUsageErrorKind::InvalidInlineArgs => format!("Usage: {usage}"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FastSlashCommandArgs { + On, + Off, + Status, +} + +const FAST_MODE_CHOICES: &[(&str, FastSlashCommandArgs)] = &[ + ("on", FastSlashCommandArgs::On), + ("off", FastSlashCommandArgs::Off), + ("status", FastSlashCommandArgs::Status), +]; + +const FEEDBACK_CATEGORY_CHOICES: &[(&str, FeedbackCategory)] = &[ + ("bad-result", FeedbackCategory::BadResult), + ("good-result", FeedbackCategory::GoodResult), + ("bug", FeedbackCategory::Bug), + ("safety-check", FeedbackCategory::SafetyCheck), + ("other", FeedbackCategory::Other), +]; + +pub(crate) trait SlashCommandInlineArgs: Sized { + const USAGE_LINES: &'static [&'static str]; + fn args_schema() -> Box>; + + fn into_invocation(self) -> SlashCommandInvocation; + + fn parse_inline(input: SlashCommandParseInput<'_>) -> Result { + let args_schema = Self::args_schema(); + let mut parser = crate::slash_command_protocol::SlashArgsParser::new(input)?; + let value = args_schema.parse(&mut parser)?; + args_schema.finish(parser)?; + Ok(value) + } + + fn serialize_inline(&self) -> SlashSerializedText { + let args_schema = Self::args_schema(); + let mut serializer = crate::slash_command_protocol::SlashArgsSerializer::default(); + args_schema.serialize(self, &mut serializer); + serializer.finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FastArgs { + pub(crate) mode: FastSlashCommandArgs, +} + +impl SlashCommandInlineArgs for FastArgs { + const USAGE_LINES: &'static [&'static str] = &["/fast", "/fast [on|off|status]"]; + + fn args_schema() -> Box> { + Box::new( + positional(enum_choice(FAST_MODE_CHOICES).ascii_case_insensitive()) + .map_result(|mode| Ok(Self { mode }), |args| args.mode), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Fast(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RenameArgs { + pub(crate) title: SlashTextArg, +} + +impl SlashCommandInlineArgs for RenameArgs { + const USAGE_LINES: &'static [&'static str] = &["/rename", "/rename "]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + remainder(text()).map_result(|title| Ok(Self { title }), |args| args.title.clone()), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Rename(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PlanArgs { + pub(crate) prompt: SlashTextArg, +} + +impl SlashCommandInlineArgs for PlanArgs { + const USAGE_LINES: &'static [&'static str] = &["/plan", "/plan <prompt>"]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + remainder(text()).map_result(|prompt| Ok(Self { prompt }), |args| args.prompt.clone()), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Plan(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ReviewArgs { + pub(crate) instructions: SlashTextArg, +} + +impl SlashCommandInlineArgs for ReviewArgs { + const USAGE_LINES: &'static [&'static str] = &["/review", "/review <instructions>"]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new(remainder(text()).map_result( + |instructions| Ok(Self { instructions }), + |args| args.instructions.clone(), + )) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Review(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SandboxReadRootArgs { + pub(crate) path: String, +} + +impl SlashCommandInlineArgs for SandboxReadRootArgs { + const USAGE_LINES: &'static [&'static str] = &[ + "/sandbox-add-read-dir <absolute-path>", + "/sandbox-add-read-dir --path=<absolute-path>", + ]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + named_or_positional("path", string()) + .map_result(|path| Ok(Self { path }), |args| args.path.clone()), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::SandboxReadRoot(self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FeedbackArgs { + pub(crate) category: FeedbackCategory, +} + +impl SlashCommandInlineArgs for FeedbackArgs { + const USAGE_LINES: &'static [&'static str] = &[ + "/feedback", + "/feedback <bad-result|good-result|bug|safety-check|other>", + ]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + positional(enum_choice(FEEDBACK_CATEGORY_CHOICES)) + .map_result(|category| Ok(Self { category }), |args| args.category), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Feedback(self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StatuslineArgs { + pub(crate) items: Vec<StatusLineItem>, +} + +impl SlashCommandInlineArgs for StatuslineArgs { + const USAGE_LINES: &'static [&'static str] = &["/statusline", "/statusline <item>..."]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new(list(from_str_value::<StatusLineItem>()).map_result( + |items| { + if items.is_empty() { + Err(SlashCommandUsageErrorKind::InvalidInlineArgs) + } else { + Ok(Self { items }) + } + }, + |args| args.items.clone(), + )) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Statusline(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum SlashCommandInvocation { + Bare(SlashCommand), + Fast(FastArgs), + Rename(RenameArgs), + Plan(PlanArgs), + Review(ReviewArgs), + SandboxReadRoot(SandboxReadRootArgs), + Feedback(FeedbackArgs), + Statusline(StatuslineArgs), +} + +impl SlashCommandInvocation { + pub(crate) fn bare(command: SlashCommand) -> Self { + Self::Bare(command) + } + + pub(crate) fn command(&self) -> SlashCommand { + match self { + Self::Bare(command) => *command, + Self::Fast(_) => SlashCommand::Fast, + Self::Rename(_) => SlashCommand::Rename, + Self::Plan(_) => SlashCommand::Plan, + Self::Review(_) => SlashCommand::Review, + Self::SandboxReadRoot(_) => SlashCommand::SandboxReadRoot, + Self::Feedback(_) => SlashCommand::Feedback, + Self::Statusline(_) => SlashCommand::Statusline, + } + } + + pub(crate) fn serialize(&self) -> SlashSerializedText { + let prefix = format!("/{}", self.command().command()); + match self { + Self::Bare(_) => SlashSerializedText::empty().with_prefix(&prefix), + Self::Fast(args) => args.serialize_inline().with_prefix(&prefix), + Self::Rename(args) => args.serialize_inline().with_prefix(&prefix), + Self::Plan(args) => args.serialize_inline().with_prefix(&prefix), + Self::Review(args) => args.serialize_inline().with_prefix(&prefix), + Self::SandboxReadRoot(args) => args.serialize_inline().with_prefix(&prefix), + Self::Feedback(args) => args.serialize_inline().with_prefix(&prefix), + Self::Statusline(args) => args.serialize_inline().with_prefix(&prefix), + } + } + + pub(crate) fn into_prefixed_string(self) -> String { + self.serialize().text + } +} + +pub(crate) type SlashCommandInlineParser = + for<'a> fn( + SlashCommandParseInput<'a>, + ) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind>; + +pub(crate) struct SlashCommandSpec { + pub(crate) command: SlashCommand, + pub(crate) description: &'static str, + pub(crate) available_during_task: bool, + pub(crate) is_disabled: bool, + pub(crate) hide_in_command_popup: bool, + pub(crate) usage_lines: &'static [&'static str], + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) bare_behavior: SlashCommandBareBehavior, + pub(crate) parse_inline: SlashCommandInlineParser, +} + +fn reject_inline_args( + _input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + Err(SlashCommandUsageErrorKind::UnexpectedInlineArgs) +} + +fn parse_typed_inline<T>( + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> +where + T: SlashCommandInlineArgs, +{ + T::parse_inline(input).map(T::into_invocation) +} + +// ===== /model ===== +const MODEL_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Model, + description: "choose what model and reasoning effort to use", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/model"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /fast ===== +const FAST_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Fast, + description: "toggle Fast mode to enable fastest inference at 2X plan usage", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: FastArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: parse_typed_inline::<FastArgs>, +}; + +// ===== /approvals ===== +const APPROVALS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Approvals, + description: "choose what Codex is allowed to do", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: true, + usage_lines: &["/approvals"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /permissions ===== +const PERMISSIONS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Permissions, + description: "choose what Codex is allowed to do", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/permissions"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /setup-default-sandbox ===== +const ELEVATE_SANDBOX_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::ElevateSandbox, + description: "set up elevated agent sandbox", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/setup-default-sandbox"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /sandbox-add-read-dir ===== +const SANDBOX_READ_ROOT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::SandboxReadRoot, + description: "let sandbox read a directory: /sandbox-add-read-dir <absolute_path>", + available_during_task: false, + is_disabled: !cfg!(target_os = "windows"), + hide_in_command_popup: false, + usage_lines: SandboxReadRootArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: parse_typed_inline::<SandboxReadRootArgs>, +}; + +// ===== /experimental ===== +const EXPERIMENTAL_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Experimental, + description: "toggle experimental features", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/experimental"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /skills ===== +const SKILLS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Skills, + description: "use skills to improve how Codex performs specific tasks", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/skills"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /review ===== +const REVIEW_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Review, + description: "review my current changes and find issues", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: ReviewArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: parse_typed_inline::<ReviewArgs>, +}; + +// ===== /rename ===== +const RENAME_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Rename, + description: "rename the current thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: RenameArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: parse_typed_inline::<RenameArgs>, +}; + +// ===== /new ===== +const NEW_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::New, + description: "start a new chat during a conversation", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/new"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /resume ===== +const RESUME_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Resume, + description: "resume a saved chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/resume"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /fork ===== +const FORK_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Fork, + description: "fork the current chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/fork"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /init ===== +const INIT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Init, + description: "create an AGENTS.md file with instructions for Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/init"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /compact ===== +const COMPACT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Compact, + description: "summarize conversation to prevent hitting the context limit", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/compact"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /plan ===== +const PLAN_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Plan, + description: "switch to Plan mode", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: PlanArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: parse_typed_inline::<PlanArgs>, +}; + +// ===== /collab ===== +const COLLAB_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Collab, + description: "change collaboration mode (experimental)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/collab"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /agent ===== +const AGENT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Agent, + description: "switch the active agent thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/agent"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /diff ===== +const DIFF_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Diff, + description: "show git diff (including untracked files)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/diff"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /copy ===== +const COPY_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Copy, + description: "copy the latest Codex output to your clipboard", + available_during_task: true, + is_disabled: cfg!(target_os = "android"), + hide_in_command_popup: false, + usage_lines: &["/copy"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /mention ===== +const MENTION_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Mention, + description: "mention a file", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/mention"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /status ===== +const STATUS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Status, + description: "show current session configuration and token usage", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/status"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /debug-config ===== +const DEBUG_CONFIG_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::DebugConfig, + description: "show config layers and requirement sources for debugging", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/debug-config"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /title ===== +const TITLE_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Title, + description: "configure which items appear in the terminal title", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/title"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /statusline ===== +const STATUSLINE_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Statusline, + description: "configure which items appear in the status line", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: StatuslineArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: parse_typed_inline::<StatuslineArgs>, +}; + +// ===== /theme ===== +const THEME_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Theme, + description: "choose a syntax highlighting theme", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/theme"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /mcp ===== +const MCP_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Mcp, + description: "list configured MCP tools", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/mcp"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /apps ===== +const APPS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Apps, + description: "manage apps", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/apps"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /plugins ===== +const PLUGINS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Plugins, + description: "browse plugins", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/plugins"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /logout ===== +const LOGOUT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Logout, + description: "log out of Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/logout"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /quit ===== +const QUIT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Quit, + description: "exit Codex", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: true, + usage_lines: &["/quit"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /exit ===== +const EXIT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Exit, + description: "exit Codex", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/exit"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /feedback ===== +const FEEDBACK_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Feedback, + description: "send logs to maintainers", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: FeedbackArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: parse_typed_inline::<FeedbackArgs>, +}; + +// ===== /rollout ===== +const ROLLOUT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Rollout, + description: "print the rollout file path", + available_during_task: true, + is_disabled: !cfg!(debug_assertions), + hide_in_command_popup: false, + usage_lines: &["/rollout"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /ps ===== +const PS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Ps, + description: "list background terminals", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/ps"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /stop ===== +const STOP_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Stop, + description: "stop all background terminals", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/stop"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /clear ===== +const CLEAR_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Clear, + description: "clear the terminal and start a new chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/clear"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /personality ===== +const PERSONALITY_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Personality, + description: "choose a communication style for Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/personality"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /realtime ===== +const REALTIME_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Realtime, + description: "toggle realtime voice mode (experimental)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/realtime"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /settings ===== +const SETTINGS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Settings, + description: "configure realtime microphone/speaker", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/settings"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /test-approval ===== +const TEST_APPROVAL_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::TestApproval, + description: "test approval request", + available_during_task: true, + is_disabled: !cfg!(debug_assertions), + hide_in_command_popup: false, + usage_lines: &["/test-approval"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /subagents ===== +const MULTI_AGENTS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::MultiAgents, + description: "switch the active agent thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/subagents"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /debug-m-drop ===== +const MEMORY_DROP_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::MemoryDrop, + description: "DO NOT USE", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/debug-m-drop"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /debug-m-update ===== +const MEMORY_UPDATE_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::MemoryUpdate, + description: "DO NOT USE", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/debug-m-update"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ + MODEL_SPEC, + FAST_SPEC, + APPROVALS_SPEC, + PERMISSIONS_SPEC, + ELEVATE_SANDBOX_SPEC, + SANDBOX_READ_ROOT_SPEC, + EXPERIMENTAL_SPEC, + SKILLS_SPEC, + REVIEW_SPEC, + RENAME_SPEC, + NEW_SPEC, + RESUME_SPEC, + FORK_SPEC, + INIT_SPEC, + COMPACT_SPEC, + PLAN_SPEC, + COLLAB_SPEC, + AGENT_SPEC, + DIFF_SPEC, + COPY_SPEC, + MENTION_SPEC, + STATUS_SPEC, + DEBUG_CONFIG_SPEC, + TITLE_SPEC, + STATUSLINE_SPEC, + THEME_SPEC, + MCP_SPEC, + APPS_SPEC, + PLUGINS_SPEC, + LOGOUT_SPEC, + QUIT_SPEC, + EXIT_SPEC, + FEEDBACK_SPEC, + ROLLOUT_SPEC, + PS_SPEC, + STOP_SPEC, + CLEAR_SPEC, + PERSONALITY_SPEC, + REALTIME_SPEC, + SETTINGS_SPEC, + TEST_APPROVAL_SPEC, + MULTI_AGENTS_SPEC, + MEMORY_DROP_SPEC, + MEMORY_UPDATE_SPEC, +]; + impl SlashCommand { + fn spec(self) -> &'static SlashCommandSpec { + match SLASH_COMMAND_SPECS.iter().find(|spec| spec.command == self) { + Some(spec) => spec, + None => panic!("every slash command must have a registered spec"), + } + } + + pub(crate) fn parse_invocation( + self, + args: &str, + text_elements: &[codex_protocol::user_input::TextElement], + ) -> Result<SlashCommandInvocation, SlashCommandUsageError> { + if args.trim().is_empty() { + return Ok(SlashCommandInvocation::Bare(self)); + } + + (self.spec().parse_inline)(SlashCommandParseInput { + args, + text_elements, + }) + .map_err(|kind| SlashCommandUsageError { + command: self, + kind, + }) + } + /// User-visible description shown in the popup. pub fn description(self) -> &'static str { - match self { - SlashCommand::Feedback => "send logs to maintainers", - SlashCommand::New => "start a new chat during a conversation", - SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", - SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", - SlashCommand::Review => "review my current changes and find issues", - SlashCommand::Rename => "rename the current thread", - SlashCommand::Resume => "resume a saved chat", - SlashCommand::Clear => "clear the terminal and start a new chat", - SlashCommand::Fork => "fork the current chat", - // SlashCommand::Undo => "ask Codex to undo a turn", - SlashCommand::Quit | SlashCommand::Exit => "exit Codex", - SlashCommand::Diff => "show git diff (including untracked files)", - SlashCommand::Copy => "copy the latest Codex output to your clipboard", - SlashCommand::Mention => "mention a file", - SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", - SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", - SlashCommand::Title => "configure which items appear in the terminal title", - SlashCommand::Statusline => "configure which items appear in the status line", - SlashCommand::Theme => "choose a syntax highlighting theme", - SlashCommand::Ps => "list background terminals", - SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", - SlashCommand::Model => "choose what model and reasoning effort to use", - SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", - SlashCommand::Personality => "choose a communication style for Codex", - SlashCommand::Realtime => "toggle realtime voice mode (experimental)", - SlashCommand::Settings => "configure realtime microphone/speaker", - SlashCommand::Plan => "switch to Plan mode", - SlashCommand::Collab => "change collaboration mode (experimental)", - SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", - SlashCommand::Approvals => "choose what Codex is allowed to do", - SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::ElevateSandbox => "set up elevated agent sandbox", - SlashCommand::SandboxReadRoot => { - "let sandbox read a directory: /sandbox-add-read-dir <absolute_path>" - } - SlashCommand::Experimental => "toggle experimental features", - SlashCommand::Mcp => "list configured MCP tools", - SlashCommand::Apps => "manage apps", - SlashCommand::Plugins => "browse plugins", - SlashCommand::Logout => "log out of Codex", - SlashCommand::Rollout => "print the rollout file path", - SlashCommand::TestApproval => "test approval request", - } + self.spec().description } - /// Command string without the leading '/'. Provided for compatibility with - /// existing code that expects a method named `command()`. + /// Command string without the leading '/'. pub fn command(self) -> &'static str { 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 - | SlashCommand::Fast - | SlashCommand::SandboxReadRoot - ) + /// User-visible usage forms for this command. + pub(crate) fn usage_lines(self) -> &'static [&'static str] { + self.spec().usage_lines } /// Whether this command can be run while a task is in progress. pub fn available_during_task(self) -> bool { - match self { - SlashCommand::New - | SlashCommand::Resume - | SlashCommand::Fork - | SlashCommand::Init - | SlashCommand::Compact - // | SlashCommand::Undo - | SlashCommand::Model - | SlashCommand::Fast - | SlashCommand::Personality - | SlashCommand::Approvals - | SlashCommand::Permissions - | SlashCommand::ElevateSandbox - | SlashCommand::SandboxReadRoot - | SlashCommand::Experimental - | SlashCommand::Review - | SlashCommand::Plan - | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, - SlashCommand::Diff - | SlashCommand::Copy - | SlashCommand::Rename - | SlashCommand::Mention - | SlashCommand::Skills - | SlashCommand::Status - | SlashCommand::DebugConfig - | SlashCommand::Ps - | SlashCommand::Stop - | SlashCommand::Mcp - | SlashCommand::Apps - | SlashCommand::Plugins - | SlashCommand::Feedback - | SlashCommand::Quit - | SlashCommand::Exit => true, - SlashCommand::Rollout => true, - SlashCommand::TestApproval => true, - SlashCommand::Realtime => true, - SlashCommand::Settings => true, - SlashCommand::Collab => true, - SlashCommand::Agent | SlashCommand::MultiAgents => true, - SlashCommand::Statusline => false, - SlashCommand::Theme => false, - SlashCommand::Title => false, - } + self.spec().available_during_task } - fn is_visible(self) -> bool { - match self { - SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), - SlashCommand::Copy => !cfg!(target_os = "android"), - SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), - _ => true, - } + pub(crate) fn hide_in_command_popup(self) -> bool { + self.spec().hide_in_command_popup } } /// Return all built-in commands in a Vec paired with their command string. pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { - SlashCommand::iter() - .filter(|command| command.is_visible()) - .map(|c| (c.command(), c)) + SLASH_COMMAND_SPECS + .iter() + .filter(|spec| !spec.is_disabled) + .map(|spec| (spec.command.command(), spec.command)) .collect() } #[cfg(test)] mod tests { + use codex_protocol::user_input::ByteRange; + use codex_protocol::user_input::TextElement; use pretty_assertions::assert_eq; + use proptest::prelude::*; + use proptest::sample::select; + use proptest::test_runner::Config as ProptestConfig; + use proptest::test_runner::TestCaseError; + use proptest::test_runner::TestRunner; use std::str::FromStr; - use super::SlashCommand; + use super::*; + + fn placeholder_text_arg(placeholder: &str) -> SlashTextArg { + SlashTextArg::new( + placeholder.to_string(), + vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )], + ) + } + + fn shift_text_element_left(element: &TextElement, offset: usize) -> Option<TextElement> { + if element.byte_range.end <= offset { + return None; + } + let start = element.byte_range.start.saturating_sub(offset); + let end = element.byte_range.end.saturating_sub(offset); + (start < end).then(|| element.map_range(|_| ByteRange { start, end })) + } + + fn serialized_args( + invocation: &SlashCommandInvocation, + ) -> (String, Vec<codex_protocol::user_input::TextElement>) { + let serialized = invocation.serialize(); + let prefix = format!("/{}", invocation.command().command()); + let args = serialized + .text + .strip_prefix(&prefix) + .expect("serialized invocation should start with command prefix"); + let Some(args) = args.strip_prefix(' ') else { + return (String::new(), Vec::new()); + }; + let offset = prefix.len() + 1; + let elements = serialized + .text_elements + .iter() + .filter_map(|element| shift_text_element_left(element, offset)) + .collect(); + (args.to_string(), elements) + } + + fn token_text_strategy() -> BoxedStrategy<String> { + prop_oneof![ + proptest::string::string_regex("[A-Za-z0-9._/-]{1,16}").unwrap(), + ( + proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}").unwrap(), + proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}").unwrap(), + ) + .prop_map(|(lhs, rhs)| format!("{lhs} {rhs}")), + proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}[\"'][A-Za-z0-9._/-]{1,8}") + .unwrap(), + ] + .boxed() + } + + fn plain_text_arg_strategy() -> BoxedStrategy<SlashTextArg> { + proptest::string::string_regex( + "[A-Za-z0-9][A-Za-z0-9._/'\"-]{0,10}( [A-Za-z0-9][A-Za-z0-9._/'\"-]{0,10}){0,3}", + ) + .unwrap() + .prop_map(|text| SlashTextArg::new(text, Vec::new())) + .boxed() + } + + fn placeholder_text_arg_strategy() -> BoxedStrategy<SlashTextArg> { + ( + proptest::string::string_regex("[A-Za-z]{0,6}").unwrap(), + select(vec!["[Image #1]".to_string(), "[Image #12]".to_string()]), + proptest::string::string_regex("[A-Za-z]{0,6}").unwrap(), + ) + .prop_map(|(prefix, placeholder, suffix)| { + let mut text = String::new(); + if !prefix.is_empty() { + text.push_str(&prefix); + text.push(' '); + } + let start = text.len(); + text.push_str(&placeholder); + let end = text.len(); + if !suffix.is_empty() { + text.push(' '); + text.push_str(&suffix); + } + SlashTextArg::new( + text, + vec![TextElement::new((start..end).into(), Some(placeholder))], + ) + }) + .boxed() + } + + fn text_arg_strategy() -> BoxedStrategy<SlashTextArg> { + prop_oneof![plain_text_arg_strategy(), placeholder_text_arg_strategy(),].boxed() + } + + fn string_arg_strategy() -> BoxedStrategy<String> { + token_text_strategy().boxed() + } + + impl SlashCommand { + fn roundtrip_test_invocations(self) -> Vec<SlashCommandInvocation> { + let bare = SlashCommandInvocation::Bare(self); + match self { + SlashCommand::Fast => vec![ + bare, + SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::On, + }), + SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Off, + }), + SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Status, + }), + ], + SlashCommand::SandboxReadRoot => vec![ + bare, + SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs { + path: "/tmp/test-dir".to_string(), + }), + ], + SlashCommand::Review => vec![ + bare, + SlashCommandInvocation::Review(ReviewArgs { + instructions: placeholder_text_arg("[Image #1]"), + }), + ], + SlashCommand::Rename => vec![ + bare, + SlashCommandInvocation::Rename(RenameArgs { + title: SlashTextArg::new("ship it".to_string(), Vec::new()), + }), + ], + SlashCommand::Plan => vec![ + bare, + SlashCommandInvocation::Plan(PlanArgs { + prompt: SlashTextArg::new("investigate flaky test".to_string(), Vec::new()), + }), + ], + SlashCommand::Statusline => vec![ + bare, + SlashCommandInvocation::Statusline(StatuslineArgs { + items: vec![StatusLineItem::ModelName, StatusLineItem::CurrentDir], + }), + ], + SlashCommand::Feedback => vec![ + bare, + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::BadResult, + }), + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::GoodResult, + }), + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::Bug, + }), + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::SafetyCheck, + }), + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::Other, + }), + ], + SlashCommand::Model + | SlashCommand::Approvals + | SlashCommand::Permissions + | SlashCommand::ElevateSandbox + | SlashCommand::Experimental + | SlashCommand::Skills + | SlashCommand::New + | SlashCommand::Resume + | SlashCommand::Fork + | SlashCommand::Init + | SlashCommand::Compact + | SlashCommand::Collab + | SlashCommand::Agent + | SlashCommand::Diff + | SlashCommand::Copy + | SlashCommand::Mention + | SlashCommand::Status + | SlashCommand::DebugConfig + | SlashCommand::Title + | SlashCommand::Theme + | SlashCommand::Mcp + | SlashCommand::Apps + | SlashCommand::Plugins + | SlashCommand::Logout + | SlashCommand::Quit + | SlashCommand::Exit + | SlashCommand::Rollout + | SlashCommand::Ps + | SlashCommand::Stop + | SlashCommand::Clear + | SlashCommand::Personality + | SlashCommand::Realtime + | SlashCommand::Settings + | SlashCommand::TestApproval + | SlashCommand::MultiAgents + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate => vec![bare], + } + } + + fn roundtrip_strategy(self) -> BoxedStrategy<SlashCommandInvocation> { + let bare = Just(SlashCommandInvocation::Bare(self)); + match self { + SlashCommand::Fast => prop_oneof![ + bare, + select(vec![ + FastSlashCommandArgs::On, + FastSlashCommandArgs::Off, + FastSlashCommandArgs::Status, + ]) + .prop_map(|mode| SlashCommandInvocation::Fast(FastArgs { mode })), + ] + .boxed(), + SlashCommand::SandboxReadRoot => prop_oneof![ + bare, + string_arg_strategy().prop_map(|path| { + SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs { path }) + }), + ] + .boxed(), + SlashCommand::Review => prop_oneof![ + bare, + text_arg_strategy().prop_map(|instructions| { + SlashCommandInvocation::Review(ReviewArgs { instructions }) + }), + ] + .boxed(), + SlashCommand::Rename => prop_oneof![ + bare, + plain_text_arg_strategy() + .prop_map(|title| { SlashCommandInvocation::Rename(RenameArgs { title }) }), + ] + .boxed(), + SlashCommand::Plan => prop_oneof![ + bare, + text_arg_strategy() + .prop_map(|prompt| { SlashCommandInvocation::Plan(PlanArgs { prompt }) }), + ] + .boxed(), + SlashCommand::Statusline => { + let items = vec![ + StatusLineItem::ModelName, + StatusLineItem::ModelWithReasoning, + StatusLineItem::CurrentDir, + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ContextRemaining, + StatusLineItem::ContextUsed, + StatusLineItem::FiveHourLimit, + StatusLineItem::WeeklyLimit, + StatusLineItem::CodexVersion, + StatusLineItem::ContextWindowSize, + StatusLineItem::UsedTokens, + StatusLineItem::TotalInputTokens, + StatusLineItem::TotalOutputTokens, + StatusLineItem::SessionId, + StatusLineItem::FastMode, + ]; + prop_oneof![ + bare, + proptest::collection::vec(select(items), 1..5).prop_map(|items| { + SlashCommandInvocation::Statusline(StatuslineArgs { items }) + }), + ] + .boxed() + } + SlashCommand::Feedback => prop_oneof![ + bare, + select(vec![ + FeedbackCategory::BadResult, + FeedbackCategory::GoodResult, + FeedbackCategory::Bug, + FeedbackCategory::SafetyCheck, + FeedbackCategory::Other, + ]) + .prop_map(|category| { + SlashCommandInvocation::Feedback(FeedbackArgs { category }) + }), + ] + .boxed(), + SlashCommand::Model + | SlashCommand::Approvals + | SlashCommand::Permissions + | SlashCommand::ElevateSandbox + | SlashCommand::Experimental + | SlashCommand::Skills + | SlashCommand::New + | SlashCommand::Resume + | SlashCommand::Fork + | SlashCommand::Init + | SlashCommand::Compact + | SlashCommand::Collab + | SlashCommand::Agent + | SlashCommand::Diff + | SlashCommand::Copy + | SlashCommand::Mention + | SlashCommand::Status + | SlashCommand::DebugConfig + | SlashCommand::Title + | SlashCommand::Theme + | SlashCommand::Mcp + | SlashCommand::Apps + | SlashCommand::Plugins + | SlashCommand::Logout + | SlashCommand::Quit + | SlashCommand::Exit + | SlashCommand::Rollout + | SlashCommand::Ps + | SlashCommand::Stop + | SlashCommand::Clear + | SlashCommand::Personality + | SlashCommand::Realtime + | SlashCommand::Settings + | SlashCommand::TestApproval + | SlashCommand::MultiAgents + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate => bare.boxed(), + } + } + } #[test] - fn stop_command_is_canonical_name() { - assert_eq!(SlashCommand::Stop.command(), "stop"); + fn all_registered_commands_roundtrip_from_serialized_text() { + for spec in SLASH_COMMAND_SPECS { + for invocation in spec.command.roundtrip_test_invocations() { + let (args, text_elements) = serialized_args(&invocation); + assert_eq!( + spec.command.parse_invocation(&args, &text_elements), + Ok(invocation.clone()), + "roundtrip failed for /{} with serialized {:?}", + spec.command.command(), + invocation.serialize().text + ); + } + } + } + + #[test] + fn all_registered_commands_proptest_roundtrip_from_serialized_text() { + for spec in SLASH_COMMAND_SPECS { + let command = spec.command; + let mut runner = TestRunner::new(ProptestConfig { + cases: 24, + failure_persistence: None, + ..ProptestConfig::default() + }); + runner + .run(&command.roundtrip_strategy(), |invocation| { + let serialized = invocation.serialize(); + let (args, text_elements) = serialized_args(&invocation); + let reparsed = + command + .parse_invocation(&args, &text_elements) + .map_err(|err| { + TestCaseError::fail(format!( + "roundtrip parse failed for /{} from {:?}: {err:?}", + command.command(), + serialized.text + )) + })?; + prop_assert_eq!(reparsed, invocation); + Ok(()) + }) + .unwrap_or_else(|err| { + panic!( + "property roundtrip failed for /{}: {err}", + command.command() + ) + }); + } + } + + #[test] + fn approvals_alias_is_hidden_from_command_popup() { + assert!(SlashCommand::Approvals.hide_in_command_popup()); } #[test] fn clean_alias_parses_to_stop_command() { assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop)); } + + #[test] + fn stop_command_is_canonical_name() { + assert_eq!(SlashCommand::Stop.command(), "stop"); + } + + #[test] + fn fast_usage_lists_bare_and_arg_forms() { + assert_eq!( + SlashCommand::Fast.usage_lines(), + ["/fast", "/fast [on|off|status]"] + ); + } + + #[test] + fn clear_usage_is_bare_only() { + assert_eq!(SlashCommand::Clear.usage_lines(), ["/clear"]); + } + + #[test] + fn review_bare_form_is_marked_as_ui_driven() { + assert_eq!( + 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(SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Status, + })) + ); + } + + #[test] + fn feedback_accepts_category_arg() { + assert_eq!( + SlashCommand::Feedback.parse_invocation("bug", &[]), + Ok(SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::Bug, + })) + ); + } + + #[test] + fn statusline_accepts_variadic_item_list() { + assert_eq!( + SlashCommand::Statusline.parse_invocation("model-name current-dir", &[]), + Ok(SlashCommandInvocation::Statusline(StatuslineArgs { + items: vec![StatusLineItem::ModelName, StatusLineItem::CurrentDir], + })) + ); + } + + #[test] + fn sandbox_read_root_accepts_named_path_arg() { + let invocation = SlashCommand::SandboxReadRoot + .parse_invocation("--path='/tmp/test dir'", &[]) + .unwrap(); + + assert_eq!( + invocation, + SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs { + path: "/tmp/test dir".to_string(), + }) + ); + assert_eq!( + invocation.serialize().text, + "/sandbox-add-read-dir '/tmp/test dir'" + ); + } + + #[test] + fn review_preserves_placeholder_elements() { + let placeholder = "[Image #1]".to_string(); + let text_elements = vec![codex_protocol::user_input::TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.clone()), + )]; + + assert_eq!( + SlashCommand::Review.parse_invocation(&placeholder, &text_elements), + Ok(SlashCommandInvocation::Review(ReviewArgs { + instructions: SlashTextArg::new(placeholder, text_elements), + })) + ); + } + + #[test] + fn clear_rejects_unexpected_inline_args() { + assert_eq!( + SlashCommand::Clear + .parse_invocation("now", &[]) + .unwrap_err() + .message(), + "'/clear' does not accept inline arguments. Usage: /clear" + ); + } + + #[test] + fn plan_serialization_preserves_placeholder_ranges() { + let placeholder = "[Image #1]".to_string(); + let invocation = SlashCommandInvocation::Plan(PlanArgs { + prompt: SlashTextArg::new( + format!("review {placeholder}"), + vec![codex_protocol::user_input::TextElement::new( + (7..18).into(), + Some(placeholder.clone()), + )], + ), + }); + + assert_eq!( + invocation.serialize(), + SlashSerializedText { + text: format!("/plan review {placeholder}"), + text_elements: vec![codex_protocol::user_input::TextElement::new( + (13..24).into(), + Some(placeholder), + )], + } + ); + } } diff --git a/codex-rs/tui/src/slash_command_protocol.rs b/codex-rs/tui/src/slash_command_protocol.rs new file mode 100644 index 00000000000..8fe62a6c4ce --- /dev/null +++ b/codex-rs/tui/src/slash_command_protocol.rs @@ -0,0 +1,981 @@ +use std::collections::HashMap; +use std::marker::PhantomData; +use std::str::FromStr; + +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; +use shlex::Shlex; +use shlex::try_join; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SlashCommandUsageErrorKind { + UnexpectedInlineArgs, + InvalidInlineArgs, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashCommandParseInput<'a> { + pub(crate) args: &'a str, + pub(crate) text_elements: &'a [TextElement], +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlashSerializedText { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + +impl SlashSerializedText { + pub(crate) fn empty() -> Self { + Self { + text: String::new(), + text_elements: Vec::new(), + } + } + + pub(crate) fn with_prefix(&self, prefix: &str) -> Self { + if self.text.is_empty() { + return Self { + text: prefix.to_string(), + text_elements: Vec::new(), + }; + } + + let offset = prefix.len() + 1; + Self { + text: format!("{prefix} {}", self.text), + text_elements: shift_text_elements_right(&self.text_elements, offset), + } + } + + #[allow(dead_code)] + fn prepend_inline(&self, prefix: &str) -> Self { + if prefix.is_empty() { + return self.clone(); + } + + Self { + text: format!("{prefix}{}", self.text), + text_elements: shift_text_elements_right(&self.text_elements, prefix.len()), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlashTokenArg { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + +impl SlashTokenArg { + pub(crate) fn new(text: String, text_elements: Vec<TextElement>) -> Self { + Self { + text, + text_elements, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlashTextArg { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + +impl SlashTextArg { + pub(crate) fn new(text: String, text_elements: Vec<TextElement>) -> Self { + Self { + text, + text_elements, + } + } +} + +pub(crate) trait SlashTokenValueSpec<T> { + fn parse_token(&self, token: SlashTokenArg) -> Result<T, SlashCommandUsageErrorKind>; + fn serialize_token(&self, value: &T) -> SlashTokenArg; +} + +pub(crate) trait SlashTextValueSpec<T> { + fn parse_text(&self, text: SlashTextArg) -> Result<T, SlashCommandUsageErrorKind>; + fn serialize_text(&self, value: &T) -> SlashTextArg; +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashTokenSpec; + +#[allow(dead_code)] +pub(crate) fn token() -> SlashTokenSpec { + SlashTokenSpec +} + +impl SlashTokenValueSpec<SlashTokenArg> for SlashTokenSpec { + fn parse_token( + &self, + token: SlashTokenArg, + ) -> Result<SlashTokenArg, SlashCommandUsageErrorKind> { + Ok(token) + } + + fn serialize_token(&self, value: &SlashTokenArg) -> SlashTokenArg { + value.clone() + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashStringSpec; + +pub(crate) fn string() -> SlashStringSpec { + SlashStringSpec +} + +impl SlashTokenValueSpec<String> for SlashStringSpec { + fn parse_token(&self, token: SlashTokenArg) -> Result<String, SlashCommandUsageErrorKind> { + Ok(token.text) + } + + fn serialize_token(&self, value: &String) -> SlashTokenArg { + SlashTokenArg::new(value.clone(), Vec::new()) + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashTextSpec; + +pub(crate) fn text() -> SlashTextSpec { + SlashTextSpec +} + +impl SlashTextValueSpec<SlashTextArg> for SlashTextSpec { + fn parse_text(&self, text: SlashTextArg) -> Result<SlashTextArg, SlashCommandUsageErrorKind> { + Ok(text) + } + + fn serialize_text(&self, value: &SlashTextArg) -> SlashTextArg { + value.clone() + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashEnumChoiceSpec<T: 'static> { + choices: &'static [(&'static str, T)], + ascii_case_insensitive: bool, +} + +pub(crate) fn enum_choice<T>(choices: &'static [(&'static str, T)]) -> SlashEnumChoiceSpec<T> +where + T: Clone + PartialEq + 'static, +{ + SlashEnumChoiceSpec { + choices, + ascii_case_insensitive: false, + } +} + +impl<T> SlashEnumChoiceSpec<T> { + pub(crate) fn ascii_case_insensitive(mut self) -> Self { + self.ascii_case_insensitive = true; + self + } +} + +impl<T> SlashTokenValueSpec<T> for SlashEnumChoiceSpec<T> +where + T: Clone + PartialEq + 'static, +{ + fn parse_token(&self, token: SlashTokenArg) -> Result<T, SlashCommandUsageErrorKind> { + self.choices + .iter() + .find_map(|(literal, value)| { + let matches = if self.ascii_case_insensitive { + token.text.eq_ignore_ascii_case(literal) + } else { + token.text == *literal + }; + matches.then(|| value.clone()) + }) + .ok_or(SlashCommandUsageErrorKind::InvalidInlineArgs) + } + + fn serialize_token(&self, value: &T) -> SlashTokenArg { + let literal = match self + .choices + .iter() + .find_map(|(literal, choice)| (choice == value).then_some(*literal)) + { + Some(literal) => literal, + None => panic!("missing enum choice serializer mapping"), + }; + SlashTokenArg::new(literal.to_string(), Vec::new()) + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashFromStrSpec<T> { + _phantom: PhantomData<T>, +} + +pub(crate) fn from_str_value<T>() -> SlashFromStrSpec<T> +where + T: FromStr + ToString, +{ + SlashFromStrSpec { + _phantom: PhantomData, + } +} + +impl<T> SlashTokenValueSpec<T> for SlashFromStrSpec<T> +where + T: FromStr + ToString, +{ + fn parse_token(&self, token: SlashTokenArg) -> Result<T, SlashCommandUsageErrorKind> { + token + .text + .parse() + .map_err(|_| SlashCommandUsageErrorKind::InvalidInlineArgs) + } + + fn serialize_token(&self, value: &T) -> SlashTokenArg { + SlashTokenArg::new(value.to_string(), Vec::new()) + } +} + +#[derive(Debug)] +pub(crate) struct SlashArgsParser<'a> { + input: SlashCommandParseInput<'a>, + positionals: Vec<SlashTokenArg>, + next_positional: usize, + named: HashMap<String, SlashTokenArg>, + duplicates: HashMap<String, usize>, +} + +impl<'a> SlashArgsParser<'a> { + pub(crate) fn new( + input: SlashCommandParseInput<'a>, + ) -> Result<Self, SlashCommandUsageErrorKind> { + let mut positionals = Vec::new(); + let mut named = HashMap::new(); + let mut duplicates = HashMap::new(); + + for token in tokenize_with_elements(input.args, input.text_elements)? { + if let Some((key, value)) = split_named_arg(&token) { + if named.insert(key.clone(), value).is_some() { + *duplicates.entry(key).or_default() += 1; + } + } else if token.text.starts_with("--") { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } else { + positionals.push(token); + } + } + + Ok(Self { + input, + positionals, + next_positional: 0, + named, + duplicates, + }) + } + + pub(crate) fn positional<T, S>(&mut self, spec: &S) -> Result<T, SlashCommandUsageErrorKind> + where + S: SlashTokenValueSpec<T>, + { + let Some(token) = self.positionals.get(self.next_positional).cloned() else { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + }; + self.next_positional += 1; + spec.parse_token(token) + } + + #[allow(dead_code)] + pub(crate) fn optional_positional<T, S>( + &mut self, + spec: &S, + ) -> Result<Option<T>, SlashCommandUsageErrorKind> + where + S: SlashTokenValueSpec<T>, + { + if self.next_positional >= self.positionals.len() { + Ok(None) + } else { + self.positional(spec).map(Some) + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn positional_list<T, S>( + &mut self, + spec: &S, + ) -> Result<Vec<T>, SlashCommandUsageErrorKind> + where + S: SlashTokenValueSpec<T>, + { + let mut values = Vec::new(); + while self.next_positional < self.positionals.len() { + values.push(self.positional(spec)?); + } + Ok(values) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn named<T, S>( + &mut self, + key: &'static str, + spec: &S, + ) -> Result<Option<T>, SlashCommandUsageErrorKind> + where + S: SlashTokenValueSpec<T>, + { + if self.duplicates.contains_key(key) { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + let Some(value) = self.named.remove(key) else { + return Ok(None); + }; + spec.parse_token(value).map(Some) + } + + pub(crate) fn remainder<T, S>(&self, spec: &S) -> Result<Option<T>, SlashCommandUsageErrorKind> + where + S: SlashTextValueSpec<T>, + { + parse_remainder_text_arg(self.input.args, self.input.text_elements) + .map(|value| spec.parse_text(value)) + .transpose() + } + + pub(crate) fn required_remainder<T, S>(&self, spec: &S) -> Result<T, SlashCommandUsageErrorKind> + where + S: SlashTextValueSpec<T>, + { + self.remainder(spec)? + .ok_or(SlashCommandUsageErrorKind::InvalidInlineArgs) + } + + pub(crate) fn finish(self) -> Result<(), SlashCommandUsageErrorKind> { + if self.next_positional != self.positionals.len() { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + if !self.named.is_empty() || !self.duplicates.is_empty() { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + Ok(()) + } +} + +#[derive(Debug, Default)] +pub(crate) struct SlashArgsSerializer { + fragments: Vec<SlashSerializedText>, +} + +impl SlashArgsSerializer { + pub(crate) fn positional<T, S>(&mut self, value: &T, spec: &S) + where + S: SlashTokenValueSpec<T>, + { + self.fragments + .push(serialize_token(&spec.serialize_token(value))); + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn list<T, I, S>(&mut self, values: I, spec: &S) + where + I: IntoIterator<Item = T>, + S: SlashTokenValueSpec<T>, + { + for value in values { + self.positional(&value, spec); + } + } + + #[allow(dead_code)] + pub(crate) fn named<T, S>(&mut self, key: &'static str, value: &T, spec: &S) + where + S: SlashTokenValueSpec<T>, + { + let serialized_value = serialize_token(&spec.serialize_token(value)); + self.fragments + .push(serialized_value.prepend_inline(&format!("--{key}="))); + } + + pub(crate) fn remainder<T, S>(&mut self, value: &T, spec: &S) + where + S: SlashTextValueSpec<T>, + { + let serialized = spec.serialize_text(value); + if remainder_can_roundtrip_raw(&serialized) { + self.fragments.push(SlashSerializedText { + text: serialized.text.clone(), + text_elements: serialized.text_elements, + }); + } else { + self.fragments.push(serialize_token(&SlashTokenArg::new( + serialized.text.clone(), + serialized.text_elements, + ))); + } + } + + pub(crate) fn finish(self) -> SlashSerializedText { + join_serialized_fragments(self.fragments) + } +} + +pub(crate) trait SlashArgsSchema<T> { + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<T, SlashCommandUsageErrorKind>; + + fn serialize(&self, value: &T, serializer: &mut SlashArgsSerializer); + + fn finish<'a>(&self, parser: SlashArgsParser<'a>) -> Result<(), SlashCommandUsageErrorKind> { + parser.finish() + } + + fn map_result<U, P, S>( + self, + parse_map: P, + serialize_map: S, + ) -> SlashMapResultSchema<Self, P, S, T, U> + where + Self: Sized, + P: Fn(T) -> Result<U, SlashCommandUsageErrorKind>, + S: Fn(&U) -> T, + { + SlashMapResultSchema { + inner: self, + parse_map, + serialize_map, + _phantom: PhantomData, + } + } +} + +pub(crate) struct SlashMapResultSchema<C, P, S, T, U> { + inner: C, + parse_map: P, + serialize_map: S, + _phantom: PhantomData<fn(T) -> U>, +} + +impl<C, P, S, T, U> SlashArgsSchema<U> for SlashMapResultSchema<C, P, S, T, U> +where + C: SlashArgsSchema<T>, + P: Fn(T) -> Result<U, SlashCommandUsageErrorKind>, + S: Fn(&U) -> T, +{ + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<U, SlashCommandUsageErrorKind> { + let parsed = self.inner.parse(parser)?; + (self.parse_map)(parsed) + } + + fn serialize(&self, value: &U, serializer: &mut SlashArgsSerializer) { + let mapped = (self.serialize_map)(value); + self.inner.serialize(&mapped, serializer); + } + + fn finish<'a>(&self, parser: SlashArgsParser<'a>) -> Result<(), SlashCommandUsageErrorKind> { + self.inner.finish(parser) + } +} + +pub(crate) struct SlashPositionalSchema<S> { + spec: S, +} + +pub(crate) fn positional<S>(spec: S) -> SlashPositionalSchema<S> { + SlashPositionalSchema { spec } +} + +impl<T, S> SlashArgsSchema<T> for SlashPositionalSchema<S> +where + S: SlashTokenValueSpec<T>, +{ + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<T, SlashCommandUsageErrorKind> { + parser.positional(&self.spec) + } + + fn serialize(&self, value: &T, serializer: &mut SlashArgsSerializer) { + serializer.positional(value, &self.spec); + } +} + +pub(crate) struct SlashListSchema<S> { + spec: S, +} + +pub(crate) fn list<S>(spec: S) -> SlashListSchema<S> { + SlashListSchema { spec } +} + +impl<T, S> SlashArgsSchema<Vec<T>> for SlashListSchema<S> +where + T: Clone, + S: SlashTokenValueSpec<T>, +{ + fn parse<'a>( + &self, + parser: &mut SlashArgsParser<'a>, + ) -> Result<Vec<T>, SlashCommandUsageErrorKind> { + parser.positional_list(&self.spec) + } + + fn serialize(&self, value: &Vec<T>, serializer: &mut SlashArgsSerializer) { + serializer.list(value.iter().cloned(), &self.spec); + } +} + +#[allow(dead_code)] +pub(crate) struct SlashNamedSchema<S> { + key: &'static str, + spec: S, +} + +#[allow(dead_code)] +pub(crate) fn named<S>(key: &'static str, spec: S) -> SlashNamedSchema<S> { + SlashNamedSchema { key, spec } +} + +impl<T, S> SlashArgsSchema<Option<T>> for SlashNamedSchema<S> +where + S: SlashTokenValueSpec<T>, +{ + fn parse<'a>( + &self, + parser: &mut SlashArgsParser<'a>, + ) -> Result<Option<T>, SlashCommandUsageErrorKind> { + parser.named(self.key, &self.spec) + } + + fn serialize(&self, value: &Option<T>, serializer: &mut SlashArgsSerializer) { + if let Some(value) = value { + serializer.named(self.key, value, &self.spec); + } + } +} + +pub(crate) struct SlashNamedOrPositionalSchema<S> { + key: &'static str, + spec: S, +} + +pub(crate) fn named_or_positional<S>( + key: &'static str, + spec: S, +) -> SlashNamedOrPositionalSchema<S> { + SlashNamedOrPositionalSchema { key, spec } +} + +impl<T, S> SlashArgsSchema<T> for SlashNamedOrPositionalSchema<S> +where + S: SlashTokenValueSpec<T>, +{ + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<T, SlashCommandUsageErrorKind> { + match parser.named(self.key, &self.spec)? { + Some(value) => Ok(value), + None => parser.positional(&self.spec), + } + } + + fn serialize(&self, value: &T, serializer: &mut SlashArgsSerializer) { + serializer.positional(value, &self.spec); + } +} + +pub(crate) struct SlashRemainderSchema<S> { + spec: S, +} + +pub(crate) fn remainder<S>(spec: S) -> SlashRemainderSchema<S> { + SlashRemainderSchema { spec } +} + +impl<T, S> SlashArgsSchema<T> for SlashRemainderSchema<S> +where + S: SlashTextValueSpec<T>, +{ + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<T, SlashCommandUsageErrorKind> { + parser.required_remainder(&self.spec) + } + + fn serialize(&self, value: &T, serializer: &mut SlashArgsSerializer) { + serializer.remainder(value, &self.spec); + } + + fn finish<'a>(&self, _parser: SlashArgsParser<'a>) -> Result<(), SlashCommandUsageErrorKind> { + Ok(()) + } +} + +fn trim_text_arg(text: &str, text_elements: &[TextElement]) -> Option<SlashTextArg> { + let trimmed_start = text.len() - text.trim_start().len(); + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + let trimmed_end = trimmed_start + trimmed.len(); + let mut elements = Vec::new(); + for element in text_elements { + let start = element.byte_range.start.max(trimmed_start); + let end = element.byte_range.end.min(trimmed_end); + if start < end { + elements.push(element.map_range(|_| ByteRange { + start: start - trimmed_start, + end: end - trimmed_start, + })); + } + } + + Some(SlashTextArg::new(trimmed.to_string(), elements)) +} + +fn parse_remainder_text_arg(text: &str, text_elements: &[TextElement]) -> Option<SlashTextArg> { + let trimmed = trim_text_arg(text, text_elements)?; + match tokenize_with_elements(&trimmed.text, &trimmed.text_elements) { + Ok(tokens) => match tokens.as_slice() { + [token] => Some(SlashTextArg::new( + token.text.clone(), + token.text_elements.clone(), + )), + _ => Some(trimmed), + }, + _ => Some(trimmed), + } +} + +fn remainder_can_roundtrip_raw(value: &SlashTextArg) -> bool { + match tokenize_with_elements(&value.text, &value.text_elements) { + Ok(tokens) if tokens.len() == 1 => { + tokens[0] == SlashTokenArg::new(value.text.clone(), value.text_elements.clone()) + } + Ok(_) => true, + Err(_) => false, + } +} + +fn split_named_arg(token: &SlashTokenArg) -> Option<(String, SlashTokenArg)> { + let rest = token.text.strip_prefix("--")?; + let (key, value) = rest.split_once('=')?; + if key.is_empty() { + return None; + } + let value_offset = 2 + key.len() + 1; + let value_elements = token + .text_elements + .iter() + .filter_map(|element| shift_text_element_left(element, value_offset)) + .collect(); + Some(( + key.to_string(), + SlashTokenArg::new(value.to_string(), value_elements), + )) +} + +fn tokenize_with_elements( + text: &str, + text_elements: &[TextElement], +) -> Result<Vec<SlashTokenArg>, SlashCommandUsageErrorKind> { + let mut elements = text_elements.to_vec(); + elements.sort_by_key(|element| element.byte_range.start); + let (text_for_shlex, replacements) = replace_text_elements_with_sentinels(text, &elements); + let mut lexer = Shlex::new(&text_for_shlex); + let tokens: Vec<String> = lexer.by_ref().collect(); + if lexer.had_error { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + Ok(tokens + .into_iter() + .map(|token| { + let restored = restore_sentinels_in_fragment(token, &replacements); + SlashTokenArg::new(restored.text, restored.text_elements) + }) + .collect()) +} + +fn serialize_token(token: &SlashTokenArg) -> SlashSerializedText { + if token.text.is_empty() { + return SlashSerializedText::empty(); + } + + let (token_for_shlex, replacements) = + replace_text_elements_with_sentinels(&token.text, &token.text_elements); + let quoted = try_join([token_for_shlex.as_str()]) + .unwrap_or_else(|_| shell_quote_token(&token_for_shlex)); + restore_sentinels_in_fragment(quoted, &replacements) +} + +fn shell_quote_token(token: &str) -> String { + if token.is_empty() { + return "''".to_string(); + } + + let mut quoted = String::from("'"); + for ch in token.chars() { + if ch == '\'' { + quoted.push_str("'\"'\"'"); + } else { + quoted.push(ch); + } + } + quoted.push('\''); + quoted +} + +fn join_serialized_fragments(fragments: Vec<SlashSerializedText>) -> SlashSerializedText { + let mut text = String::new(); + let mut text_elements = Vec::new(); + + for fragment in fragments + .into_iter() + .filter(|fragment| !fragment.text.is_empty()) + { + let offset = if text.is_empty() { 0 } else { 1 }; + if offset == 1 { + text.push(' '); + } + let fragment_offset = text.len(); + text.push_str(&fragment.text); + text_elements.extend(shift_text_elements_right( + &fragment.text_elements, + fragment_offset, + )); + } + + SlashSerializedText { + text, + text_elements, + } +} + +fn shift_text_element_left(element: &TextElement, offset: usize) -> Option<TextElement> { + if element.byte_range.end <= offset { + return None; + } + let start = element.byte_range.start.saturating_sub(offset); + let end = element.byte_range.end.saturating_sub(offset); + (start < end).then(|| element.map_range(|_| ByteRange { start, end })) +} + +fn shift_text_elements_right(elements: &[TextElement], offset: usize) -> Vec<TextElement> { + elements + .iter() + .map(|element| { + element.map_range(|byte_range| ByteRange { + start: byte_range.start + offset, + end: byte_range.end + offset, + }) + }) + .collect() +} + +#[derive(Debug, Clone)] +struct ElementReplacement { + sentinel: String, + text: String, + placeholder: Option<String>, +} + +fn replace_text_elements_with_sentinels( + text: &str, + text_elements: &[TextElement], +) -> (String, Vec<ElementReplacement>) { + let mut out = String::with_capacity(text.len()); + let mut replacements = Vec::new(); + let mut cursor = 0; + let text_len = text.len(); + + for (idx, element) in text_elements.iter().enumerate() { + let start = element.byte_range.start.clamp(cursor, text_len); + let end = element.byte_range.end.clamp(start, text_len); + out.push_str(&text[cursor..start]); + let mut sentinel = format!("__CODEX_ELEM_{idx}__"); + while text.contains(&sentinel) { + sentinel.push('_'); + } + out.push_str(&sentinel); + let replacement_text = text + .get(start..end) + .or_else(|| element.placeholder(text)) + .unwrap_or_default() + .to_string(); + replacements.push(ElementReplacement { + sentinel, + text: replacement_text, + placeholder: element.placeholder(text).map(str::to_string), + }); + cursor = end; + } + + out.push_str(&text[cursor..]); + (out, replacements) +} + +fn restore_sentinels_in_fragment( + fragment: String, + replacements: &[ElementReplacement], +) -> SlashSerializedText { + if replacements.is_empty() { + return SlashSerializedText { + text: fragment, + text_elements: Vec::new(), + }; + } + + let mut out = String::with_capacity(fragment.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + while cursor < fragment.len() { + let Some((offset, replacement)) = next_replacement(&fragment, cursor, replacements) else { + out.push_str(&fragment[cursor..]); + break; + }; + let start_in_fragment = cursor + offset; + out.push_str(&fragment[cursor..start_in_fragment]); + let start = out.len(); + out.push_str(&replacement.text); + let end = out.len(); + if start < end { + out_elements.push(TextElement::new( + ByteRange { start, end }, + replacement.placeholder.clone(), + )); + } + cursor = start_in_fragment + replacement.sentinel.len(); + } + + SlashSerializedText { + text: out, + text_elements: out_elements, + } +} + +fn next_replacement<'a>( + text: &str, + cursor: usize, + replacements: &'a [ElementReplacement], +) -> Option<(usize, &'a ElementReplacement)> { + replacements + .iter() + .filter_map(|replacement| { + text[cursor..] + .find(&replacement.sentinel) + .map(|offset| (offset, replacement)) + }) + .min_by_key(|(offset, _)| *offset) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Switch { + On, + Off, + } + + const SWITCH_CHOICES: &[(&str, Switch)] = &[("on", Switch::On), ("off", Switch::Off)]; + + #[test] + fn parser_supports_positional_list_and_named_args() { + let mut parser = SlashArgsParser::new(SlashCommandParseInput { + args: "on first second --path=\"some dir\"", + text_elements: &[], + }) + .unwrap(); + + assert_eq!( + parser.positional(&enum_choice(SWITCH_CHOICES)), + Ok(Switch::On) + ); + assert_eq!( + parser.positional_list(&string()), + Ok(vec!["first".to_string(), "second".to_string()]) + ); + assert_eq!( + parser.named("path", &string()), + Ok(Some("some dir".to_string())) + ); + assert_eq!(parser.finish(), Ok(())); + } + + #[test] + fn parser_supports_optional_positional_args() { + let mut parser = SlashArgsParser::new(SlashCommandParseInput { + args: "on", + text_elements: &[], + }) + .unwrap(); + + assert_eq!( + parser.positional(&enum_choice(SWITCH_CHOICES)), + Ok(Switch::On) + ); + assert_eq!(parser.optional_positional(&string()), Ok(None)); + assert_eq!(parser.finish(), Ok(())); + } + + #[test] + fn serializer_stably_formats_named_args_after_positionals() { + let mut serializer = SlashArgsSerializer::default(); + serializer.positional(&Switch::On, &enum_choice(SWITCH_CHOICES)); + serializer.list(["first".to_string(), "second".to_string()], &string()); + serializer.named("path", &"some dir".to_string(), &string()); + + assert_eq!( + serializer.finish(), + SlashSerializedText { + text: "on first second --path='some dir'".to_string(), + text_elements: Vec::new(), + } + ); + } + + #[test] + fn remainder_preserves_placeholder_ranges() { + let placeholder = "[Image #1]".to_string(); + let prompt = SlashTextArg::new( + format!("review {placeholder}"), + vec![TextElement::new((7..18).into(), Some(placeholder.clone()))], + ); + let mut serializer = SlashArgsSerializer::default(); + serializer.remainder(&prompt, &text()); + + assert_eq!( + serializer.finish(), + SlashSerializedText { + text: format!("review {placeholder}"), + text_elements: vec![TextElement::new((7..18).into(), Some(placeholder))], + } + ); + } + + #[test] + fn remainder_quotes_shell_sensitive_text_when_needed() { + let prompt = SlashTextArg::new("a\"\" a\"".to_string(), Vec::new()); + let mut serializer = SlashArgsSerializer::default(); + serializer.remainder(&prompt, &text()); + + assert_eq!( + serializer.finish(), + SlashSerializedText { + text: "'a\"\" a\"'".to_string(), + text_elements: Vec::new(), + } + ); + assert_eq!( + SlashArgsParser::new(SlashCommandParseInput { + args: "'a\"\" a\"'", + text_elements: &[], + }) + .unwrap() + .required_remainder(&text()), + Ok(prompt) + ); + } +} diff --git a/codex-rs/tui_app_server/Cargo.toml b/codex-rs/tui_app_server/Cargo.toml index 88660420517..f800cce85f7 100644 --- a/codex-rs/tui_app_server/Cargo.toml +++ b/codex-rs/tui_app_server/Cargo.toml @@ -145,6 +145,7 @@ assert_matches = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } pretty_assertions = { workspace = true } +proptest = { workspace = true } rand = { workspace = true } serial_test = { workspace = true } vt100 = { workspace = true } diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index 0e1f3e08d6d..eb43d94a7c0 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -192,6 +192,7 @@ use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; +use crate::slash_command::SlashCommandInvocation; use crate::style::user_message_style; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; @@ -1423,12 +1424,13 @@ impl ChatComposer { return (InputResult::Command(cmd), true); } - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); + let bare_command = + SlashCommandInvocation::bare(cmd).into_prefixed_string(); + let starts_with_cmd = + first_line.trim_start().starts_with(&bare_command); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("{bare_command} ")); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); @@ -2580,10 +2582,6 @@ impl ChatComposer { } let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; - - if !cmd.supports_inline_args() { - return None; - } if self.reject_slash_command_if_unavailable(cmd) { return Some(InputResult::None); } diff --git a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs index 36cc87668cb..2ef24cad4ee 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs @@ -14,11 +14,6 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; -// Hide alias commands in the default popup list so each unique action appears once. -// `quit` is an alias of `exit`, so we skip `quit` here. -// `approvals` is an alias of `permissions`. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; - /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { @@ -146,7 +141,7 @@ impl CommandPopup { if filter.is_empty() { // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { - if ALIAS_COMMANDS.contains(cmd) { + if cmd.hide_in_command_popup() { continue; } out.push((CommandItem::Builtin(*cmd), None)); diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 0d53ae3f7a6..1e3d249e80f 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -315,7 +315,13 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; +use crate::slash_command::FastArgs; +use crate::slash_command::FastSlashCommandArgs; +use crate::slash_command::FeedbackArgs; use crate::slash_command::SlashCommand; +use crate::slash_command::SlashCommandInvocation; +use crate::slash_command::SlashTextArg; +use crate::slash_command::StatuslineArgs; use crate::status::RateLimitSnapshotDisplay; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; @@ -4561,17 +4567,7 @@ impl ChatWidget { } match cmd { SlashCommand::Feedback => { - if !self.config.feedback_enabled { - let params = crate::bottom_pane::feedback_disabled_params(); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - return; - } - // Step 1: pick a category (UI built in feedback_view) - let params = - crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); + self.open_feedback_picker_or_disabled_message(); } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); @@ -4879,16 +4875,42 @@ impl ChatWidget { } } + fn open_feedback_picker_or_disabled_message(&mut self) { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return; + } + + let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + + 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.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.", @@ -4899,43 +4921,40 @@ impl ChatWidget { return; } - let trimmed = args.trim(); - match cmd { - SlashCommand::Fast => { - if trimmed.is_empty() { - self.dispatch_command(cmd); - return; - } - match trimmed.to_ascii_lowercase().as_str() { - "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), - "off" => self.set_service_tier_selection(/*service_tier*/ None), - "status" => { - let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) - { - "on" - } else { - "off" - }; - self.add_info_message( - format!("Fast mode is {status}."), - /*hint*/ None, - ); - } - _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); - } - } + match cmd.parse_invocation(&args, &text_elements) { + Ok(SlashCommandInvocation::Bare(_)) => { + self.dispatch_command(cmd); + } + Ok(SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::On, + })) => { + self.set_service_tier_selection(Some(ServiceTier::Fast)); + } + Ok(SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Off, + })) => { + self.set_service_tier_selection(/*service_tier*/ None); + } + Ok(SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Status, + })) => { + let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + "on" + } else { + "off" + }; + self.add_info_message(format!("Fast mode is {status}."), /*hint*/ None); } - SlashCommand::Rename if !trimmed.is_empty() => { + 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.title.text) + else { self.add_error_message("Thread name cannot be empty.".to_string()); return; }; @@ -4945,14 +4964,13 @@ impl ChatWidget { self.app_event_tx.set_thread_name(name); self.bottom_pane.drain_pending_submission_state(); } - SlashCommand::Plan if !trimmed.is_empty() => { + 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; }; @@ -4960,11 +4978,15 @@ impl ChatWidget { .bottom_pane .take_recent_submission_images_with_placeholders(); let remote_image_urls = self.take_remote_image_urls(); + let SlashTextArg { + text, + text_elements, + } = prepared_args.prompt; 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() { @@ -4976,35 +4998,48 @@ impl ChatWidget { 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(/*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.instructions.text, }, user_facing_hint: None, })); self.bottom_pane.drain_pending_submission_state(); } - SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { - 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.path, }); self.bottom_pane.drain_pending_submission_state(); } - _ => self.dispatch_command(cmd), + Ok(SlashCommandInvocation::Feedback(FeedbackArgs { category })) => { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return; + } + self.app_event_tx + .send(AppEvent::OpenFeedbackConsent { category }); + } + Ok(SlashCommandInvocation::Statusline(StatuslineArgs { items })) => { + self.app_event_tx.send(AppEvent::StatusLineSetup { items }); + } + Err(err) => { + self.add_error_message(err.message()); + } } } diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 17e309d5fbd..a4f11504234 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -136,6 +136,7 @@ mod session_log; mod shimmer; mod skills_helpers; mod slash_command; +mod slash_command_protocol; mod status; mod status_indicator_widget; mod streaming; diff --git a/codex-rs/tui_app_server/src/slash_command.rs b/codex-rs/tui_app_server/src/slash_command.rs index 22812040021..59cdb6c2b64 100644 --- a/codex-rs/tui_app_server/src/slash_command.rs +++ b/codex-rs/tui_app_server/src/slash_command.rs @@ -1,13 +1,25 @@ -use strum::IntoEnumIterator; use strum_macros::AsRefStr; -use strum_macros::EnumIter; use strum_macros::EnumString; use strum_macros::IntoStaticStr; +use crate::app_event::FeedbackCategory; +use crate::bottom_pane::StatusLineItem; +use crate::slash_command_protocol::SlashArgsSchema; +use crate::slash_command_protocol::SlashCommandParseInput; +use crate::slash_command_protocol::SlashCommandUsageErrorKind; +use crate::slash_command_protocol::SlashSerializedText; +pub(crate) use crate::slash_command_protocol::SlashTextArg; +use crate::slash_command_protocol::enum_choice; +use crate::slash_command_protocol::from_str_value; +use crate::slash_command_protocol::list; +use crate::slash_command_protocol::named_or_positional; +use crate::slash_command_protocol::positional; +use crate::slash_command_protocol::remainder; +use crate::slash_command_protocol::string; +use crate::slash_command_protocol::text; + /// Commands that can be invoked by starting a message with a leading slash. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, AsRefStr, IntoStaticStr)] #[strum(serialize_all = "kebab-case")] pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so @@ -32,7 +44,6 @@ pub enum SlashCommand { Plan, Collab, Agent, - // Undo, Diff, Copy, Mention, @@ -58,163 +69,1457 @@ pub enum SlashCommand { TestApproval, #[strum(serialize = "subagents")] MultiAgents, - // Debugging commands. #[strum(serialize = "debug-m-drop")] MemoryDrop, #[strum(serialize = "debug-m-update")] MemoryUpdate, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SlashCommandBareBehavior { + DispatchesDirectly, + OpensUi, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SlashCommandUsageError { + command: SlashCommand, + kind: SlashCommandUsageErrorKind, +} + +impl SlashCommandUsageError { + pub(crate) fn message(self) -> String { + let usage = self.command.usage_lines().join(" | "); + match self.kind { + SlashCommandUsageErrorKind::UnexpectedInlineArgs => format!( + "'/{}' does not accept inline arguments. Usage: {usage}", + self.command.command() + ), + SlashCommandUsageErrorKind::InvalidInlineArgs => format!("Usage: {usage}"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FastSlashCommandArgs { + On, + Off, + Status, +} + +const FAST_MODE_CHOICES: &[(&str, FastSlashCommandArgs)] = &[ + ("on", FastSlashCommandArgs::On), + ("off", FastSlashCommandArgs::Off), + ("status", FastSlashCommandArgs::Status), +]; + +const FEEDBACK_CATEGORY_CHOICES: &[(&str, FeedbackCategory)] = &[ + ("bad-result", FeedbackCategory::BadResult), + ("good-result", FeedbackCategory::GoodResult), + ("bug", FeedbackCategory::Bug), + ("safety-check", FeedbackCategory::SafetyCheck), + ("other", FeedbackCategory::Other), +]; + +pub(crate) trait SlashCommandInlineArgs: Sized { + const USAGE_LINES: &'static [&'static str]; + fn args_schema() -> Box<dyn SlashArgsSchema<Self>>; + + fn into_invocation(self) -> SlashCommandInvocation; + + fn parse_inline(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { + let args_schema = Self::args_schema(); + let mut parser = crate::slash_command_protocol::SlashArgsParser::new(input)?; + let value = args_schema.parse(&mut parser)?; + args_schema.finish(parser)?; + Ok(value) + } + + fn serialize_inline(&self) -> SlashSerializedText { + let args_schema = Self::args_schema(); + let mut serializer = crate::slash_command_protocol::SlashArgsSerializer::default(); + args_schema.serialize(self, &mut serializer); + serializer.finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FastArgs { + pub(crate) mode: FastSlashCommandArgs, +} + +impl SlashCommandInlineArgs for FastArgs { + const USAGE_LINES: &'static [&'static str] = &["/fast", "/fast [on|off|status]"]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + positional(enum_choice(FAST_MODE_CHOICES).ascii_case_insensitive()) + .map_result(|mode| Ok(Self { mode }), |args| args.mode), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Fast(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RenameArgs { + pub(crate) title: SlashTextArg, +} + +impl SlashCommandInlineArgs for RenameArgs { + const USAGE_LINES: &'static [&'static str] = &["/rename", "/rename <title>"]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + remainder(text()).map_result(|title| Ok(Self { title }), |args| args.title.clone()), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Rename(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PlanArgs { + pub(crate) prompt: SlashTextArg, +} + +impl SlashCommandInlineArgs for PlanArgs { + const USAGE_LINES: &'static [&'static str] = &["/plan", "/plan <prompt>"]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + remainder(text()).map_result(|prompt| Ok(Self { prompt }), |args| args.prompt.clone()), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Plan(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ReviewArgs { + pub(crate) instructions: SlashTextArg, +} + +impl SlashCommandInlineArgs for ReviewArgs { + const USAGE_LINES: &'static [&'static str] = &["/review", "/review <instructions>"]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new(remainder(text()).map_result( + |instructions| Ok(Self { instructions }), + |args| args.instructions.clone(), + )) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Review(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SandboxReadRootArgs { + pub(crate) path: String, +} + +impl SlashCommandInlineArgs for SandboxReadRootArgs { + const USAGE_LINES: &'static [&'static str] = &[ + "/sandbox-add-read-dir <absolute-path>", + "/sandbox-add-read-dir --path=<absolute-path>", + ]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + named_or_positional("path", string()) + .map_result(|path| Ok(Self { path }), |args| args.path.clone()), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::SandboxReadRoot(self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FeedbackArgs { + pub(crate) category: FeedbackCategory, +} + +impl SlashCommandInlineArgs for FeedbackArgs { + const USAGE_LINES: &'static [&'static str] = &[ + "/feedback", + "/feedback <bad-result|good-result|bug|safety-check|other>", + ]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new( + positional(enum_choice(FEEDBACK_CATEGORY_CHOICES)) + .map_result(|category| Ok(Self { category }), |args| args.category), + ) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Feedback(self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StatuslineArgs { + pub(crate) items: Vec<StatusLineItem>, +} + +impl SlashCommandInlineArgs for StatuslineArgs { + const USAGE_LINES: &'static [&'static str] = &["/statusline", "/statusline <item>..."]; + + fn args_schema() -> Box<dyn SlashArgsSchema<Self>> { + Box::new(list(from_str_value::<StatusLineItem>()).map_result( + |items| { + if items.is_empty() { + Err(SlashCommandUsageErrorKind::InvalidInlineArgs) + } else { + Ok(Self { items }) + } + }, + |args| args.items.clone(), + )) + } + + fn into_invocation(self) -> SlashCommandInvocation { + SlashCommandInvocation::Statusline(self) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum SlashCommandInvocation { + Bare(SlashCommand), + Fast(FastArgs), + Rename(RenameArgs), + Plan(PlanArgs), + Review(ReviewArgs), + SandboxReadRoot(SandboxReadRootArgs), + Feedback(FeedbackArgs), + Statusline(StatuslineArgs), +} + +impl SlashCommandInvocation { + pub(crate) fn bare(command: SlashCommand) -> Self { + Self::Bare(command) + } + + pub(crate) fn command(&self) -> SlashCommand { + match self { + Self::Bare(command) => *command, + Self::Fast(_) => SlashCommand::Fast, + Self::Rename(_) => SlashCommand::Rename, + Self::Plan(_) => SlashCommand::Plan, + Self::Review(_) => SlashCommand::Review, + Self::SandboxReadRoot(_) => SlashCommand::SandboxReadRoot, + Self::Feedback(_) => SlashCommand::Feedback, + Self::Statusline(_) => SlashCommand::Statusline, + } + } + + pub(crate) fn serialize(&self) -> SlashSerializedText { + let prefix = format!("/{}", self.command().command()); + match self { + Self::Bare(_) => SlashSerializedText::empty().with_prefix(&prefix), + Self::Fast(args) => args.serialize_inline().with_prefix(&prefix), + Self::Rename(args) => args.serialize_inline().with_prefix(&prefix), + Self::Plan(args) => args.serialize_inline().with_prefix(&prefix), + Self::Review(args) => args.serialize_inline().with_prefix(&prefix), + Self::SandboxReadRoot(args) => args.serialize_inline().with_prefix(&prefix), + Self::Feedback(args) => args.serialize_inline().with_prefix(&prefix), + Self::Statusline(args) => args.serialize_inline().with_prefix(&prefix), + } + } + + pub(crate) fn into_prefixed_string(self) -> String { + self.serialize().text + } +} + +pub(crate) type SlashCommandInlineParser = + for<'a> fn( + SlashCommandParseInput<'a>, + ) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind>; + +pub(crate) struct SlashCommandSpec { + pub(crate) command: SlashCommand, + pub(crate) description: &'static str, + pub(crate) available_during_task: bool, + pub(crate) is_disabled: bool, + pub(crate) hide_in_command_popup: bool, + pub(crate) usage_lines: &'static [&'static str], + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) bare_behavior: SlashCommandBareBehavior, + pub(crate) parse_inline: SlashCommandInlineParser, +} + +fn reject_inline_args( + _input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> { + Err(SlashCommandUsageErrorKind::UnexpectedInlineArgs) +} + +fn parse_typed_inline<T>( + input: SlashCommandParseInput<'_>, +) -> Result<SlashCommandInvocation, SlashCommandUsageErrorKind> +where + T: SlashCommandInlineArgs, +{ + T::parse_inline(input).map(T::into_invocation) +} + +// ===== /model ===== +const MODEL_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Model, + description: "choose what model and reasoning effort to use", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/model"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /fast ===== +const FAST_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Fast, + description: "toggle Fast mode to enable fastest inference at 2X plan usage", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: FastArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: parse_typed_inline::<FastArgs>, +}; + +// ===== /approvals ===== +const APPROVALS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Approvals, + description: "choose what Codex is allowed to do", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: true, + usage_lines: &["/approvals"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /permissions ===== +const PERMISSIONS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Permissions, + description: "choose what Codex is allowed to do", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/permissions"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /setup-default-sandbox ===== +const ELEVATE_SANDBOX_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::ElevateSandbox, + description: "set up elevated agent sandbox", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/setup-default-sandbox"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /sandbox-add-read-dir ===== +const SANDBOX_READ_ROOT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::SandboxReadRoot, + description: "let sandbox read a directory: /sandbox-add-read-dir <absolute_path>", + available_during_task: false, + is_disabled: !cfg!(target_os = "windows"), + hide_in_command_popup: false, + usage_lines: SandboxReadRootArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: parse_typed_inline::<SandboxReadRootArgs>, +}; + +// ===== /experimental ===== +const EXPERIMENTAL_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Experimental, + description: "toggle experimental features", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/experimental"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /skills ===== +const SKILLS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Skills, + description: "use skills to improve how Codex performs specific tasks", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/skills"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /review ===== +const REVIEW_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Review, + description: "review my current changes and find issues", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: ReviewArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: parse_typed_inline::<ReviewArgs>, +}; + +// ===== /rename ===== +const RENAME_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Rename, + description: "rename the current thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: RenameArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: parse_typed_inline::<RenameArgs>, +}; + +// ===== /new ===== +const NEW_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::New, + description: "start a new chat during a conversation", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/new"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /resume ===== +const RESUME_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Resume, + description: "resume a saved chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/resume"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /fork ===== +const FORK_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Fork, + description: "fork the current chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/fork"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /init ===== +const INIT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Init, + description: "create an AGENTS.md file with instructions for Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/init"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /compact ===== +const COMPACT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Compact, + description: "summarize conversation to prevent hitting the context limit", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/compact"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /plan ===== +const PLAN_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Plan, + description: "switch to Plan mode", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: PlanArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: parse_typed_inline::<PlanArgs>, +}; + +// ===== /collab ===== +const COLLAB_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Collab, + description: "change collaboration mode (experimental)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/collab"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /agent ===== +const AGENT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Agent, + description: "switch the active agent thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/agent"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /diff ===== +const DIFF_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Diff, + description: "show git diff (including untracked files)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/diff"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /copy ===== +const COPY_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Copy, + description: "copy the latest Codex output to your clipboard", + available_during_task: true, + is_disabled: cfg!(target_os = "android"), + hide_in_command_popup: false, + usage_lines: &["/copy"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /mention ===== +const MENTION_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Mention, + description: "mention a file", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/mention"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /status ===== +const STATUS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Status, + description: "show current session configuration and token usage", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/status"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /debug-config ===== +const DEBUG_CONFIG_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::DebugConfig, + description: "show config layers and requirement sources for debugging", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/debug-config"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /statusline ===== +const STATUSLINE_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Statusline, + description: "configure which items appear in the status line", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: StatuslineArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: parse_typed_inline::<StatuslineArgs>, +}; + +// ===== /theme ===== +const THEME_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Theme, + description: "choose a syntax highlighting theme", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/theme"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /mcp ===== +const MCP_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Mcp, + description: "list configured MCP tools", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/mcp"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /apps ===== +const APPS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Apps, + description: "manage apps", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/apps"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /plugins ===== +const PLUGINS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Plugins, + description: "browse plugins", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/plugins"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /logout ===== +const LOGOUT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Logout, + description: "log out of Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/logout"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /quit ===== +const QUIT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Quit, + description: "exit Codex", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: true, + usage_lines: &["/quit"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /exit ===== +const EXIT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Exit, + description: "exit Codex", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/exit"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /feedback ===== +const FEEDBACK_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Feedback, + description: "send logs to maintainers", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: FeedbackArgs::USAGE_LINES, + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: parse_typed_inline::<FeedbackArgs>, +}; + +// ===== /rollout ===== +const ROLLOUT_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Rollout, + description: "print the rollout file path", + available_during_task: true, + is_disabled: !cfg!(debug_assertions), + hide_in_command_popup: false, + usage_lines: &["/rollout"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /ps ===== +const PS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Ps, + description: "list background terminals", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/ps"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /stop ===== +const STOP_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Stop, + description: "stop all background terminals", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/stop"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /clear ===== +const CLEAR_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Clear, + description: "clear the terminal and start a new chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/clear"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /personality ===== +const PERSONALITY_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Personality, + description: "choose a communication style for Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/personality"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /realtime ===== +const REALTIME_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Realtime, + description: "toggle realtime voice mode (experimental)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/realtime"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /settings ===== +const SETTINGS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::Settings, + description: "configure realtime microphone/speaker", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/settings"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /test-approval ===== +const TEST_APPROVAL_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::TestApproval, + description: "test approval request", + available_during_task: true, + is_disabled: !cfg!(debug_assertions), + hide_in_command_popup: false, + usage_lines: &["/test-approval"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /subagents ===== +const MULTI_AGENTS_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::MultiAgents, + description: "switch the active agent thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/subagents"], + bare_behavior: SlashCommandBareBehavior::OpensUi, + parse_inline: reject_inline_args, +}; + +// ===== /debug-m-drop ===== +const MEMORY_DROP_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::MemoryDrop, + description: "DO NOT USE", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/debug-m-drop"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +// ===== /debug-m-update ===== +const MEMORY_UPDATE_SPEC: SlashCommandSpec = SlashCommandSpec { + command: SlashCommand::MemoryUpdate, + description: "DO NOT USE", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + usage_lines: &["/debug-m-update"], + bare_behavior: SlashCommandBareBehavior::DispatchesDirectly, + parse_inline: reject_inline_args, +}; + +const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ + MODEL_SPEC, + FAST_SPEC, + APPROVALS_SPEC, + PERMISSIONS_SPEC, + ELEVATE_SANDBOX_SPEC, + SANDBOX_READ_ROOT_SPEC, + EXPERIMENTAL_SPEC, + SKILLS_SPEC, + REVIEW_SPEC, + RENAME_SPEC, + NEW_SPEC, + RESUME_SPEC, + FORK_SPEC, + INIT_SPEC, + COMPACT_SPEC, + PLAN_SPEC, + COLLAB_SPEC, + AGENT_SPEC, + DIFF_SPEC, + COPY_SPEC, + MENTION_SPEC, + STATUS_SPEC, + DEBUG_CONFIG_SPEC, + STATUSLINE_SPEC, + THEME_SPEC, + MCP_SPEC, + APPS_SPEC, + PLUGINS_SPEC, + LOGOUT_SPEC, + QUIT_SPEC, + EXIT_SPEC, + FEEDBACK_SPEC, + ROLLOUT_SPEC, + PS_SPEC, + STOP_SPEC, + CLEAR_SPEC, + PERSONALITY_SPEC, + REALTIME_SPEC, + SETTINGS_SPEC, + TEST_APPROVAL_SPEC, + MULTI_AGENTS_SPEC, + MEMORY_DROP_SPEC, + MEMORY_UPDATE_SPEC, +]; + impl SlashCommand { + fn spec(self) -> &'static SlashCommandSpec { + match SLASH_COMMAND_SPECS.iter().find(|spec| spec.command == self) { + Some(spec) => spec, + None => panic!("every slash command must have a registered spec"), + } + } + + pub(crate) fn parse_invocation( + self, + args: &str, + text_elements: &[codex_protocol::user_input::TextElement], + ) -> Result<SlashCommandInvocation, SlashCommandUsageError> { + if args.trim().is_empty() { + return Ok(SlashCommandInvocation::Bare(self)); + } + + (self.spec().parse_inline)(SlashCommandParseInput { + args, + text_elements, + }) + .map_err(|kind| SlashCommandUsageError { + command: self, + kind, + }) + } + /// User-visible description shown in the popup. pub fn description(self) -> &'static str { - match self { - SlashCommand::Feedback => "send logs to maintainers", - SlashCommand::New => "start a new chat during a conversation", - SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", - SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", - SlashCommand::Review => "review my current changes and find issues", - SlashCommand::Rename => "rename the current thread", - SlashCommand::Resume => "resume a saved chat", - SlashCommand::Clear => "clear the terminal and start a new chat", - SlashCommand::Fork => "fork the current chat", - // SlashCommand::Undo => "ask Codex to undo a turn", - SlashCommand::Quit | SlashCommand::Exit => "exit Codex", - SlashCommand::Diff => "show git diff (including untracked files)", - SlashCommand::Copy => "copy the latest Codex output to your clipboard", - SlashCommand::Mention => "mention a file", - SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", - SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", - SlashCommand::Statusline => "configure which items appear in the status line", - SlashCommand::Theme => "choose a syntax highlighting theme", - SlashCommand::Ps => "list background terminals", - SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", - SlashCommand::Model => "choose what model and reasoning effort to use", - SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", - SlashCommand::Personality => "choose a communication style for Codex", - SlashCommand::Realtime => "toggle realtime voice mode (experimental)", - SlashCommand::Settings => "configure realtime microphone/speaker", - SlashCommand::Plan => "switch to Plan mode", - SlashCommand::Collab => "change collaboration mode (experimental)", - SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", - SlashCommand::Approvals => "choose what Codex is allowed to do", - SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::ElevateSandbox => "set up elevated agent sandbox", - SlashCommand::SandboxReadRoot => { - "let sandbox read a directory: /sandbox-add-read-dir <absolute_path>" - } - SlashCommand::Experimental => "toggle experimental features", - SlashCommand::Mcp => "list configured MCP tools", - SlashCommand::Apps => "manage apps", - SlashCommand::Plugins => "browse plugins", - SlashCommand::Logout => "log out of Codex", - SlashCommand::Rollout => "print the rollout file path", - SlashCommand::TestApproval => "test approval request", - } + self.spec().description } - /// Command string without the leading '/'. Provided for compatibility with - /// existing code that expects a method named `command()`. + /// Command string without the leading '/'. pub fn command(self) -> &'static str { 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 - | SlashCommand::Fast - | SlashCommand::SandboxReadRoot - ) + /// User-visible usage forms for this command. + pub(crate) fn usage_lines(self) -> &'static [&'static str] { + self.spec().usage_lines } /// Whether this command can be run while a task is in progress. pub fn available_during_task(self) -> bool { - match self { - SlashCommand::New - | SlashCommand::Resume - | SlashCommand::Fork - | SlashCommand::Init - | SlashCommand::Compact - // | SlashCommand::Undo - | SlashCommand::Model - | SlashCommand::Fast - | SlashCommand::Personality - | SlashCommand::Approvals - | SlashCommand::Permissions - | SlashCommand::ElevateSandbox - | SlashCommand::SandboxReadRoot - | SlashCommand::Experimental - | SlashCommand::Review - | SlashCommand::Plan - | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, - SlashCommand::Diff - | SlashCommand::Copy - | SlashCommand::Rename - | SlashCommand::Mention - | SlashCommand::Skills - | SlashCommand::Status - | SlashCommand::DebugConfig - | SlashCommand::Ps - | SlashCommand::Stop - | SlashCommand::Mcp - | SlashCommand::Apps - | SlashCommand::Plugins - | SlashCommand::Feedback - | SlashCommand::Quit - | SlashCommand::Exit => true, - SlashCommand::Rollout => true, - SlashCommand::TestApproval => true, - SlashCommand::Realtime => true, - SlashCommand::Settings => true, - SlashCommand::Collab => true, - SlashCommand::Agent | SlashCommand::MultiAgents => true, - SlashCommand::Statusline => false, - SlashCommand::Theme => false, - } + self.spec().available_during_task } - fn is_visible(self) -> bool { - match self { - SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), - SlashCommand::Copy => !cfg!(target_os = "android"), - SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), - _ => true, - } + pub(crate) fn hide_in_command_popup(self) -> bool { + self.spec().hide_in_command_popup } } /// Return all built-in commands in a Vec paired with their command string. pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { - SlashCommand::iter() - .filter(|command| command.is_visible()) - .map(|c| (c.command(), c)) + SLASH_COMMAND_SPECS + .iter() + .filter(|spec| !spec.is_disabled) + .map(|spec| (spec.command.command(), spec.command)) .collect() } #[cfg(test)] mod tests { + use codex_protocol::user_input::ByteRange; + use codex_protocol::user_input::TextElement; use pretty_assertions::assert_eq; + use proptest::prelude::*; + use proptest::sample::select; + use proptest::test_runner::Config as ProptestConfig; + use proptest::test_runner::TestCaseError; + use proptest::test_runner::TestRunner; use std::str::FromStr; - use super::SlashCommand; + use super::*; + + fn placeholder_text_arg(placeholder: &str) -> SlashTextArg { + SlashTextArg::new( + placeholder.to_string(), + vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )], + ) + } + + fn shift_text_element_left(element: &TextElement, offset: usize) -> Option<TextElement> { + if element.byte_range.end <= offset { + return None; + } + let start = element.byte_range.start.saturating_sub(offset); + let end = element.byte_range.end.saturating_sub(offset); + (start < end).then(|| element.map_range(|_| ByteRange { start, end })) + } + + fn serialized_args( + invocation: &SlashCommandInvocation, + ) -> (String, Vec<codex_protocol::user_input::TextElement>) { + let serialized = invocation.serialize(); + let prefix = format!("/{}", invocation.command().command()); + let args = serialized + .text + .strip_prefix(&prefix) + .expect("serialized invocation should start with command prefix"); + let Some(args) = args.strip_prefix(' ') else { + return (String::new(), Vec::new()); + }; + let offset = prefix.len() + 1; + let elements = serialized + .text_elements + .iter() + .filter_map(|element| shift_text_element_left(element, offset)) + .collect(); + (args.to_string(), elements) + } + + fn token_text_strategy() -> BoxedStrategy<String> { + prop_oneof![ + proptest::string::string_regex("[A-Za-z0-9._/-]{1,16}").unwrap(), + ( + proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}").unwrap(), + proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}").unwrap(), + ) + .prop_map(|(lhs, rhs)| format!("{lhs} {rhs}")), + proptest::string::string_regex("[A-Za-z0-9._/-]{1,8}[\"'][A-Za-z0-9._/-]{1,8}") + .unwrap(), + ] + .boxed() + } + + fn plain_text_arg_strategy() -> BoxedStrategy<SlashTextArg> { + proptest::string::string_regex( + "[A-Za-z0-9][A-Za-z0-9._/'\"-]{0,10}( [A-Za-z0-9][A-Za-z0-9._/'\"-]{0,10}){0,3}", + ) + .unwrap() + .prop_map(|text| SlashTextArg::new(text, Vec::new())) + .boxed() + } + + fn placeholder_text_arg_strategy() -> BoxedStrategy<SlashTextArg> { + ( + proptest::string::string_regex("[A-Za-z]{0,6}").unwrap(), + select(vec!["[Image #1]".to_string(), "[Image #12]".to_string()]), + proptest::string::string_regex("[A-Za-z]{0,6}").unwrap(), + ) + .prop_map(|(prefix, placeholder, suffix)| { + let mut text = String::new(); + if !prefix.is_empty() { + text.push_str(&prefix); + text.push(' '); + } + let start = text.len(); + text.push_str(&placeholder); + let end = text.len(); + if !suffix.is_empty() { + text.push(' '); + text.push_str(&suffix); + } + SlashTextArg::new( + text, + vec![TextElement::new((start..end).into(), Some(placeholder))], + ) + }) + .boxed() + } + + fn text_arg_strategy() -> BoxedStrategy<SlashTextArg> { + prop_oneof![plain_text_arg_strategy(), placeholder_text_arg_strategy(),].boxed() + } + + fn string_arg_strategy() -> BoxedStrategy<String> { + token_text_strategy().boxed() + } + + impl SlashCommand { + fn roundtrip_test_invocations(self) -> Vec<SlashCommandInvocation> { + let bare = SlashCommandInvocation::Bare(self); + match self { + SlashCommand::Fast => vec![ + bare, + SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::On, + }), + SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Off, + }), + SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Status, + }), + ], + SlashCommand::SandboxReadRoot => vec![ + bare, + SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs { + path: "/tmp/test-dir".to_string(), + }), + ], + SlashCommand::Review => vec![ + bare, + SlashCommandInvocation::Review(ReviewArgs { + instructions: placeholder_text_arg("[Image #1]"), + }), + ], + SlashCommand::Rename => vec![ + bare, + SlashCommandInvocation::Rename(RenameArgs { + title: SlashTextArg::new("ship it".to_string(), Vec::new()), + }), + ], + SlashCommand::Plan => vec![ + bare, + SlashCommandInvocation::Plan(PlanArgs { + prompt: SlashTextArg::new("investigate flaky test".to_string(), Vec::new()), + }), + ], + SlashCommand::Statusline => vec![ + bare, + SlashCommandInvocation::Statusline(StatuslineArgs { + items: vec![StatusLineItem::ModelName, StatusLineItem::CurrentDir], + }), + ], + SlashCommand::Feedback => vec![ + bare, + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::BadResult, + }), + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::GoodResult, + }), + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::Bug, + }), + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::SafetyCheck, + }), + SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::Other, + }), + ], + SlashCommand::Model + | SlashCommand::Approvals + | SlashCommand::Permissions + | SlashCommand::ElevateSandbox + | SlashCommand::Experimental + | SlashCommand::Skills + | SlashCommand::New + | SlashCommand::Resume + | SlashCommand::Fork + | SlashCommand::Init + | SlashCommand::Compact + | SlashCommand::Collab + | SlashCommand::Agent + | SlashCommand::Diff + | SlashCommand::Copy + | SlashCommand::Mention + | SlashCommand::Status + | SlashCommand::DebugConfig + | SlashCommand::Theme + | SlashCommand::Mcp + | SlashCommand::Apps + | SlashCommand::Plugins + | SlashCommand::Logout + | SlashCommand::Quit + | SlashCommand::Exit + | SlashCommand::Rollout + | SlashCommand::Ps + | SlashCommand::Stop + | SlashCommand::Clear + | SlashCommand::Personality + | SlashCommand::Realtime + | SlashCommand::Settings + | SlashCommand::TestApproval + | SlashCommand::MultiAgents + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate => vec![bare], + } + } + + fn roundtrip_strategy(self) -> BoxedStrategy<SlashCommandInvocation> { + let bare = Just(SlashCommandInvocation::Bare(self)); + match self { + SlashCommand::Fast => prop_oneof![ + bare, + select(vec![ + FastSlashCommandArgs::On, + FastSlashCommandArgs::Off, + FastSlashCommandArgs::Status, + ]) + .prop_map(|mode| SlashCommandInvocation::Fast(FastArgs { mode })), + ] + .boxed(), + SlashCommand::SandboxReadRoot => prop_oneof![ + bare, + string_arg_strategy().prop_map(|path| { + SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs { path }) + }), + ] + .boxed(), + SlashCommand::Review => prop_oneof![ + bare, + text_arg_strategy().prop_map(|instructions| { + SlashCommandInvocation::Review(ReviewArgs { instructions }) + }), + ] + .boxed(), + SlashCommand::Rename => prop_oneof![ + bare, + plain_text_arg_strategy() + .prop_map(|title| { SlashCommandInvocation::Rename(RenameArgs { title }) }), + ] + .boxed(), + SlashCommand::Plan => prop_oneof![ + bare, + text_arg_strategy() + .prop_map(|prompt| { SlashCommandInvocation::Plan(PlanArgs { prompt }) }), + ] + .boxed(), + SlashCommand::Statusline => { + let items = vec![ + StatusLineItem::ModelName, + StatusLineItem::ModelWithReasoning, + StatusLineItem::CurrentDir, + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ContextRemaining, + StatusLineItem::ContextUsed, + StatusLineItem::FiveHourLimit, + StatusLineItem::WeeklyLimit, + StatusLineItem::CodexVersion, + StatusLineItem::ContextWindowSize, + StatusLineItem::UsedTokens, + StatusLineItem::TotalInputTokens, + StatusLineItem::TotalOutputTokens, + StatusLineItem::SessionId, + StatusLineItem::FastMode, + ]; + prop_oneof![ + bare, + proptest::collection::vec(select(items), 1..5).prop_map(|items| { + SlashCommandInvocation::Statusline(StatuslineArgs { items }) + }), + ] + .boxed() + } + SlashCommand::Feedback => prop_oneof![ + bare, + select(vec![ + FeedbackCategory::BadResult, + FeedbackCategory::GoodResult, + FeedbackCategory::Bug, + FeedbackCategory::SafetyCheck, + FeedbackCategory::Other, + ]) + .prop_map(|category| { + SlashCommandInvocation::Feedback(FeedbackArgs { category }) + }), + ] + .boxed(), + SlashCommand::Model + | SlashCommand::Approvals + | SlashCommand::Permissions + | SlashCommand::ElevateSandbox + | SlashCommand::Experimental + | SlashCommand::Skills + | SlashCommand::New + | SlashCommand::Resume + | SlashCommand::Fork + | SlashCommand::Init + | SlashCommand::Compact + | SlashCommand::Collab + | SlashCommand::Agent + | SlashCommand::Diff + | SlashCommand::Copy + | SlashCommand::Mention + | SlashCommand::Status + | SlashCommand::DebugConfig + | SlashCommand::Theme + | SlashCommand::Mcp + | SlashCommand::Apps + | SlashCommand::Plugins + | SlashCommand::Logout + | SlashCommand::Quit + | SlashCommand::Exit + | SlashCommand::Rollout + | SlashCommand::Ps + | SlashCommand::Stop + | SlashCommand::Clear + | SlashCommand::Personality + | SlashCommand::Realtime + | SlashCommand::Settings + | SlashCommand::TestApproval + | SlashCommand::MultiAgents + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate => bare.boxed(), + } + } + } #[test] - fn stop_command_is_canonical_name() { - assert_eq!(SlashCommand::Stop.command(), "stop"); + fn all_registered_commands_proptest_roundtrip_from_serialized_text() { + for spec in SLASH_COMMAND_SPECS { + let command = spec.command; + let mut runner = TestRunner::new(ProptestConfig { + cases: 24, + failure_persistence: None, + ..ProptestConfig::default() + }); + runner + .run(&command.roundtrip_strategy(), |invocation| { + let serialized = invocation.serialize(); + let (args, text_elements) = serialized_args(&invocation); + let reparsed = + command + .parse_invocation(&args, &text_elements) + .map_err(|err| { + TestCaseError::fail(format!( + "roundtrip parse failed for /{} from {:?}: {err:?}", + command.command(), + serialized.text + )) + })?; + prop_assert_eq!(reparsed, invocation); + Ok(()) + }) + .unwrap_or_else(|err| { + panic!( + "property roundtrip failed for /{}: {err}", + command.command() + ) + }); + } + } + + #[test] + fn all_registered_commands_roundtrip_from_serialized_text() { + for spec in SLASH_COMMAND_SPECS { + for invocation in spec.command.roundtrip_test_invocations() { + let (args, text_elements) = serialized_args(&invocation); + assert_eq!( + spec.command.parse_invocation(&args, &text_elements), + Ok(invocation.clone()), + "roundtrip failed for /{} with serialized {:?}", + spec.command.command(), + invocation.serialize().text + ); + } + } + } + + #[test] + fn approvals_alias_is_hidden_from_command_popup() { + assert!(SlashCommand::Approvals.hide_in_command_popup()); } #[test] fn clean_alias_parses_to_stop_command() { assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop)); } + + #[test] + fn stop_command_is_canonical_name() { + assert_eq!(SlashCommand::Stop.command(), "stop"); + } + + #[test] + fn fast_usage_lists_bare_and_arg_forms() { + assert_eq!( + SlashCommand::Fast.usage_lines(), + ["/fast", "/fast [on|off|status]"] + ); + } + + #[test] + fn clear_usage_is_bare_only() { + assert_eq!(SlashCommand::Clear.usage_lines(), ["/clear"]); + } + + #[test] + fn review_bare_form_is_marked_as_ui_driven() { + assert_eq!( + 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(SlashCommandInvocation::Fast(FastArgs { + mode: FastSlashCommandArgs::Status, + })) + ); + } + + #[test] + fn feedback_accepts_category_arg() { + assert_eq!( + SlashCommand::Feedback.parse_invocation("bug", &[]), + Ok(SlashCommandInvocation::Feedback(FeedbackArgs { + category: FeedbackCategory::Bug, + })) + ); + } + + #[test] + fn statusline_accepts_variadic_item_list() { + assert_eq!( + SlashCommand::Statusline.parse_invocation("model-name current-dir", &[]), + Ok(SlashCommandInvocation::Statusline(StatuslineArgs { + items: vec![StatusLineItem::ModelName, StatusLineItem::CurrentDir], + })) + ); + } + + #[test] + fn sandbox_read_root_accepts_named_path_arg() { + let invocation = SlashCommand::SandboxReadRoot + .parse_invocation("--path='/tmp/test dir'", &[]) + .unwrap(); + + assert_eq!( + invocation, + SlashCommandInvocation::SandboxReadRoot(SandboxReadRootArgs { + path: "/tmp/test dir".to_string(), + }) + ); + assert_eq!( + invocation.serialize().text, + "/sandbox-add-read-dir '/tmp/test dir'" + ); + } + + #[test] + fn review_preserves_placeholder_elements() { + let placeholder = "[Image #1]".to_string(); + let text_elements = vec![codex_protocol::user_input::TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.clone()), + )]; + + assert_eq!( + SlashCommand::Review.parse_invocation(&placeholder, &text_elements), + Ok(SlashCommandInvocation::Review(ReviewArgs { + instructions: SlashTextArg::new(placeholder, text_elements), + })) + ); + } + + #[test] + fn clear_rejects_unexpected_inline_args() { + assert_eq!( + SlashCommand::Clear + .parse_invocation("now", &[]) + .unwrap_err() + .message(), + "'/clear' does not accept inline arguments. Usage: /clear" + ); + } + + #[test] + fn plan_serialization_preserves_placeholder_ranges() { + let placeholder = "[Image #1]".to_string(); + let invocation = SlashCommandInvocation::Plan(PlanArgs { + prompt: SlashTextArg::new( + format!("review {placeholder}"), + vec![codex_protocol::user_input::TextElement::new( + (7..18).into(), + Some(placeholder.clone()), + )], + ), + }); + + assert_eq!( + invocation.serialize(), + SlashSerializedText { + text: format!("/plan review {placeholder}"), + text_elements: vec![codex_protocol::user_input::TextElement::new( + (13..24).into(), + Some(placeholder), + )], + } + ); + } } diff --git a/codex-rs/tui_app_server/src/slash_command_protocol.rs b/codex-rs/tui_app_server/src/slash_command_protocol.rs new file mode 100644 index 00000000000..8fe62a6c4ce --- /dev/null +++ b/codex-rs/tui_app_server/src/slash_command_protocol.rs @@ -0,0 +1,981 @@ +use std::collections::HashMap; +use std::marker::PhantomData; +use std::str::FromStr; + +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; +use shlex::Shlex; +use shlex::try_join; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SlashCommandUsageErrorKind { + UnexpectedInlineArgs, + InvalidInlineArgs, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashCommandParseInput<'a> { + pub(crate) args: &'a str, + pub(crate) text_elements: &'a [TextElement], +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlashSerializedText { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + +impl SlashSerializedText { + pub(crate) fn empty() -> Self { + Self { + text: String::new(), + text_elements: Vec::new(), + } + } + + pub(crate) fn with_prefix(&self, prefix: &str) -> Self { + if self.text.is_empty() { + return Self { + text: prefix.to_string(), + text_elements: Vec::new(), + }; + } + + let offset = prefix.len() + 1; + Self { + text: format!("{prefix} {}", self.text), + text_elements: shift_text_elements_right(&self.text_elements, offset), + } + } + + #[allow(dead_code)] + fn prepend_inline(&self, prefix: &str) -> Self { + if prefix.is_empty() { + return self.clone(); + } + + Self { + text: format!("{prefix}{}", self.text), + text_elements: shift_text_elements_right(&self.text_elements, prefix.len()), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlashTokenArg { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + +impl SlashTokenArg { + pub(crate) fn new(text: String, text_elements: Vec<TextElement>) -> Self { + Self { + text, + text_elements, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SlashTextArg { + pub(crate) text: String, + pub(crate) text_elements: Vec<TextElement>, +} + +impl SlashTextArg { + pub(crate) fn new(text: String, text_elements: Vec<TextElement>) -> Self { + Self { + text, + text_elements, + } + } +} + +pub(crate) trait SlashTokenValueSpec<T> { + fn parse_token(&self, token: SlashTokenArg) -> Result<T, SlashCommandUsageErrorKind>; + fn serialize_token(&self, value: &T) -> SlashTokenArg; +} + +pub(crate) trait SlashTextValueSpec<T> { + fn parse_text(&self, text: SlashTextArg) -> Result<T, SlashCommandUsageErrorKind>; + fn serialize_text(&self, value: &T) -> SlashTextArg; +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashTokenSpec; + +#[allow(dead_code)] +pub(crate) fn token() -> SlashTokenSpec { + SlashTokenSpec +} + +impl SlashTokenValueSpec<SlashTokenArg> for SlashTokenSpec { + fn parse_token( + &self, + token: SlashTokenArg, + ) -> Result<SlashTokenArg, SlashCommandUsageErrorKind> { + Ok(token) + } + + fn serialize_token(&self, value: &SlashTokenArg) -> SlashTokenArg { + value.clone() + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashStringSpec; + +pub(crate) fn string() -> SlashStringSpec { + SlashStringSpec +} + +impl SlashTokenValueSpec<String> for SlashStringSpec { + fn parse_token(&self, token: SlashTokenArg) -> Result<String, SlashCommandUsageErrorKind> { + Ok(token.text) + } + + fn serialize_token(&self, value: &String) -> SlashTokenArg { + SlashTokenArg::new(value.clone(), Vec::new()) + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashTextSpec; + +pub(crate) fn text() -> SlashTextSpec { + SlashTextSpec +} + +impl SlashTextValueSpec<SlashTextArg> for SlashTextSpec { + fn parse_text(&self, text: SlashTextArg) -> Result<SlashTextArg, SlashCommandUsageErrorKind> { + Ok(text) + } + + fn serialize_text(&self, value: &SlashTextArg) -> SlashTextArg { + value.clone() + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashEnumChoiceSpec<T: 'static> { + choices: &'static [(&'static str, T)], + ascii_case_insensitive: bool, +} + +pub(crate) fn enum_choice<T>(choices: &'static [(&'static str, T)]) -> SlashEnumChoiceSpec<T> +where + T: Clone + PartialEq + 'static, +{ + SlashEnumChoiceSpec { + choices, + ascii_case_insensitive: false, + } +} + +impl<T> SlashEnumChoiceSpec<T> { + pub(crate) fn ascii_case_insensitive(mut self) -> Self { + self.ascii_case_insensitive = true; + self + } +} + +impl<T> SlashTokenValueSpec<T> for SlashEnumChoiceSpec<T> +where + T: Clone + PartialEq + 'static, +{ + fn parse_token(&self, token: SlashTokenArg) -> Result<T, SlashCommandUsageErrorKind> { + self.choices + .iter() + .find_map(|(literal, value)| { + let matches = if self.ascii_case_insensitive { + token.text.eq_ignore_ascii_case(literal) + } else { + token.text == *literal + }; + matches.then(|| value.clone()) + }) + .ok_or(SlashCommandUsageErrorKind::InvalidInlineArgs) + } + + fn serialize_token(&self, value: &T) -> SlashTokenArg { + let literal = match self + .choices + .iter() + .find_map(|(literal, choice)| (choice == value).then_some(*literal)) + { + Some(literal) => literal, + None => panic!("missing enum choice serializer mapping"), + }; + SlashTokenArg::new(literal.to_string(), Vec::new()) + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SlashFromStrSpec<T> { + _phantom: PhantomData<T>, +} + +pub(crate) fn from_str_value<T>() -> SlashFromStrSpec<T> +where + T: FromStr + ToString, +{ + SlashFromStrSpec { + _phantom: PhantomData, + } +} + +impl<T> SlashTokenValueSpec<T> for SlashFromStrSpec<T> +where + T: FromStr + ToString, +{ + fn parse_token(&self, token: SlashTokenArg) -> Result<T, SlashCommandUsageErrorKind> { + token + .text + .parse() + .map_err(|_| SlashCommandUsageErrorKind::InvalidInlineArgs) + } + + fn serialize_token(&self, value: &T) -> SlashTokenArg { + SlashTokenArg::new(value.to_string(), Vec::new()) + } +} + +#[derive(Debug)] +pub(crate) struct SlashArgsParser<'a> { + input: SlashCommandParseInput<'a>, + positionals: Vec<SlashTokenArg>, + next_positional: usize, + named: HashMap<String, SlashTokenArg>, + duplicates: HashMap<String, usize>, +} + +impl<'a> SlashArgsParser<'a> { + pub(crate) fn new( + input: SlashCommandParseInput<'a>, + ) -> Result<Self, SlashCommandUsageErrorKind> { + let mut positionals = Vec::new(); + let mut named = HashMap::new(); + let mut duplicates = HashMap::new(); + + for token in tokenize_with_elements(input.args, input.text_elements)? { + if let Some((key, value)) = split_named_arg(&token) { + if named.insert(key.clone(), value).is_some() { + *duplicates.entry(key).or_default() += 1; + } + } else if token.text.starts_with("--") { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } else { + positionals.push(token); + } + } + + Ok(Self { + input, + positionals, + next_positional: 0, + named, + duplicates, + }) + } + + pub(crate) fn positional<T, S>(&mut self, spec: &S) -> Result<T, SlashCommandUsageErrorKind> + where + S: SlashTokenValueSpec<T>, + { + let Some(token) = self.positionals.get(self.next_positional).cloned() else { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + }; + self.next_positional += 1; + spec.parse_token(token) + } + + #[allow(dead_code)] + pub(crate) fn optional_positional<T, S>( + &mut self, + spec: &S, + ) -> Result<Option<T>, SlashCommandUsageErrorKind> + where + S: SlashTokenValueSpec<T>, + { + if self.next_positional >= self.positionals.len() { + Ok(None) + } else { + self.positional(spec).map(Some) + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn positional_list<T, S>( + &mut self, + spec: &S, + ) -> Result<Vec<T>, SlashCommandUsageErrorKind> + where + S: SlashTokenValueSpec<T>, + { + let mut values = Vec::new(); + while self.next_positional < self.positionals.len() { + values.push(self.positional(spec)?); + } + Ok(values) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn named<T, S>( + &mut self, + key: &'static str, + spec: &S, + ) -> Result<Option<T>, SlashCommandUsageErrorKind> + where + S: SlashTokenValueSpec<T>, + { + if self.duplicates.contains_key(key) { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + let Some(value) = self.named.remove(key) else { + return Ok(None); + }; + spec.parse_token(value).map(Some) + } + + pub(crate) fn remainder<T, S>(&self, spec: &S) -> Result<Option<T>, SlashCommandUsageErrorKind> + where + S: SlashTextValueSpec<T>, + { + parse_remainder_text_arg(self.input.args, self.input.text_elements) + .map(|value| spec.parse_text(value)) + .transpose() + } + + pub(crate) fn required_remainder<T, S>(&self, spec: &S) -> Result<T, SlashCommandUsageErrorKind> + where + S: SlashTextValueSpec<T>, + { + self.remainder(spec)? + .ok_or(SlashCommandUsageErrorKind::InvalidInlineArgs) + } + + pub(crate) fn finish(self) -> Result<(), SlashCommandUsageErrorKind> { + if self.next_positional != self.positionals.len() { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + if !self.named.is_empty() || !self.duplicates.is_empty() { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + Ok(()) + } +} + +#[derive(Debug, Default)] +pub(crate) struct SlashArgsSerializer { + fragments: Vec<SlashSerializedText>, +} + +impl SlashArgsSerializer { + pub(crate) fn positional<T, S>(&mut self, value: &T, spec: &S) + where + S: SlashTokenValueSpec<T>, + { + self.fragments + .push(serialize_token(&spec.serialize_token(value))); + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn list<T, I, S>(&mut self, values: I, spec: &S) + where + I: IntoIterator<Item = T>, + S: SlashTokenValueSpec<T>, + { + for value in values { + self.positional(&value, spec); + } + } + + #[allow(dead_code)] + pub(crate) fn named<T, S>(&mut self, key: &'static str, value: &T, spec: &S) + where + S: SlashTokenValueSpec<T>, + { + let serialized_value = serialize_token(&spec.serialize_token(value)); + self.fragments + .push(serialized_value.prepend_inline(&format!("--{key}="))); + } + + pub(crate) fn remainder<T, S>(&mut self, value: &T, spec: &S) + where + S: SlashTextValueSpec<T>, + { + let serialized = spec.serialize_text(value); + if remainder_can_roundtrip_raw(&serialized) { + self.fragments.push(SlashSerializedText { + text: serialized.text.clone(), + text_elements: serialized.text_elements, + }); + } else { + self.fragments.push(serialize_token(&SlashTokenArg::new( + serialized.text.clone(), + serialized.text_elements, + ))); + } + } + + pub(crate) fn finish(self) -> SlashSerializedText { + join_serialized_fragments(self.fragments) + } +} + +pub(crate) trait SlashArgsSchema<T> { + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<T, SlashCommandUsageErrorKind>; + + fn serialize(&self, value: &T, serializer: &mut SlashArgsSerializer); + + fn finish<'a>(&self, parser: SlashArgsParser<'a>) -> Result<(), SlashCommandUsageErrorKind> { + parser.finish() + } + + fn map_result<U, P, S>( + self, + parse_map: P, + serialize_map: S, + ) -> SlashMapResultSchema<Self, P, S, T, U> + where + Self: Sized, + P: Fn(T) -> Result<U, SlashCommandUsageErrorKind>, + S: Fn(&U) -> T, + { + SlashMapResultSchema { + inner: self, + parse_map, + serialize_map, + _phantom: PhantomData, + } + } +} + +pub(crate) struct SlashMapResultSchema<C, P, S, T, U> { + inner: C, + parse_map: P, + serialize_map: S, + _phantom: PhantomData<fn(T) -> U>, +} + +impl<C, P, S, T, U> SlashArgsSchema<U> for SlashMapResultSchema<C, P, S, T, U> +where + C: SlashArgsSchema<T>, + P: Fn(T) -> Result<U, SlashCommandUsageErrorKind>, + S: Fn(&U) -> T, +{ + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<U, SlashCommandUsageErrorKind> { + let parsed = self.inner.parse(parser)?; + (self.parse_map)(parsed) + } + + fn serialize(&self, value: &U, serializer: &mut SlashArgsSerializer) { + let mapped = (self.serialize_map)(value); + self.inner.serialize(&mapped, serializer); + } + + fn finish<'a>(&self, parser: SlashArgsParser<'a>) -> Result<(), SlashCommandUsageErrorKind> { + self.inner.finish(parser) + } +} + +pub(crate) struct SlashPositionalSchema<S> { + spec: S, +} + +pub(crate) fn positional<S>(spec: S) -> SlashPositionalSchema<S> { + SlashPositionalSchema { spec } +} + +impl<T, S> SlashArgsSchema<T> for SlashPositionalSchema<S> +where + S: SlashTokenValueSpec<T>, +{ + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<T, SlashCommandUsageErrorKind> { + parser.positional(&self.spec) + } + + fn serialize(&self, value: &T, serializer: &mut SlashArgsSerializer) { + serializer.positional(value, &self.spec); + } +} + +pub(crate) struct SlashListSchema<S> { + spec: S, +} + +pub(crate) fn list<S>(spec: S) -> SlashListSchema<S> { + SlashListSchema { spec } +} + +impl<T, S> SlashArgsSchema<Vec<T>> for SlashListSchema<S> +where + T: Clone, + S: SlashTokenValueSpec<T>, +{ + fn parse<'a>( + &self, + parser: &mut SlashArgsParser<'a>, + ) -> Result<Vec<T>, SlashCommandUsageErrorKind> { + parser.positional_list(&self.spec) + } + + fn serialize(&self, value: &Vec<T>, serializer: &mut SlashArgsSerializer) { + serializer.list(value.iter().cloned(), &self.spec); + } +} + +#[allow(dead_code)] +pub(crate) struct SlashNamedSchema<S> { + key: &'static str, + spec: S, +} + +#[allow(dead_code)] +pub(crate) fn named<S>(key: &'static str, spec: S) -> SlashNamedSchema<S> { + SlashNamedSchema { key, spec } +} + +impl<T, S> SlashArgsSchema<Option<T>> for SlashNamedSchema<S> +where + S: SlashTokenValueSpec<T>, +{ + fn parse<'a>( + &self, + parser: &mut SlashArgsParser<'a>, + ) -> Result<Option<T>, SlashCommandUsageErrorKind> { + parser.named(self.key, &self.spec) + } + + fn serialize(&self, value: &Option<T>, serializer: &mut SlashArgsSerializer) { + if let Some(value) = value { + serializer.named(self.key, value, &self.spec); + } + } +} + +pub(crate) struct SlashNamedOrPositionalSchema<S> { + key: &'static str, + spec: S, +} + +pub(crate) fn named_or_positional<S>( + key: &'static str, + spec: S, +) -> SlashNamedOrPositionalSchema<S> { + SlashNamedOrPositionalSchema { key, spec } +} + +impl<T, S> SlashArgsSchema<T> for SlashNamedOrPositionalSchema<S> +where + S: SlashTokenValueSpec<T>, +{ + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<T, SlashCommandUsageErrorKind> { + match parser.named(self.key, &self.spec)? { + Some(value) => Ok(value), + None => parser.positional(&self.spec), + } + } + + fn serialize(&self, value: &T, serializer: &mut SlashArgsSerializer) { + serializer.positional(value, &self.spec); + } +} + +pub(crate) struct SlashRemainderSchema<S> { + spec: S, +} + +pub(crate) fn remainder<S>(spec: S) -> SlashRemainderSchema<S> { + SlashRemainderSchema { spec } +} + +impl<T, S> SlashArgsSchema<T> for SlashRemainderSchema<S> +where + S: SlashTextValueSpec<T>, +{ + fn parse<'a>(&self, parser: &mut SlashArgsParser<'a>) -> Result<T, SlashCommandUsageErrorKind> { + parser.required_remainder(&self.spec) + } + + fn serialize(&self, value: &T, serializer: &mut SlashArgsSerializer) { + serializer.remainder(value, &self.spec); + } + + fn finish<'a>(&self, _parser: SlashArgsParser<'a>) -> Result<(), SlashCommandUsageErrorKind> { + Ok(()) + } +} + +fn trim_text_arg(text: &str, text_elements: &[TextElement]) -> Option<SlashTextArg> { + let trimmed_start = text.len() - text.trim_start().len(); + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + let trimmed_end = trimmed_start + trimmed.len(); + let mut elements = Vec::new(); + for element in text_elements { + let start = element.byte_range.start.max(trimmed_start); + let end = element.byte_range.end.min(trimmed_end); + if start < end { + elements.push(element.map_range(|_| ByteRange { + start: start - trimmed_start, + end: end - trimmed_start, + })); + } + } + + Some(SlashTextArg::new(trimmed.to_string(), elements)) +} + +fn parse_remainder_text_arg(text: &str, text_elements: &[TextElement]) -> Option<SlashTextArg> { + let trimmed = trim_text_arg(text, text_elements)?; + match tokenize_with_elements(&trimmed.text, &trimmed.text_elements) { + Ok(tokens) => match tokens.as_slice() { + [token] => Some(SlashTextArg::new( + token.text.clone(), + token.text_elements.clone(), + )), + _ => Some(trimmed), + }, + _ => Some(trimmed), + } +} + +fn remainder_can_roundtrip_raw(value: &SlashTextArg) -> bool { + match tokenize_with_elements(&value.text, &value.text_elements) { + Ok(tokens) if tokens.len() == 1 => { + tokens[0] == SlashTokenArg::new(value.text.clone(), value.text_elements.clone()) + } + Ok(_) => true, + Err(_) => false, + } +} + +fn split_named_arg(token: &SlashTokenArg) -> Option<(String, SlashTokenArg)> { + let rest = token.text.strip_prefix("--")?; + let (key, value) = rest.split_once('=')?; + if key.is_empty() { + return None; + } + let value_offset = 2 + key.len() + 1; + let value_elements = token + .text_elements + .iter() + .filter_map(|element| shift_text_element_left(element, value_offset)) + .collect(); + Some(( + key.to_string(), + SlashTokenArg::new(value.to_string(), value_elements), + )) +} + +fn tokenize_with_elements( + text: &str, + text_elements: &[TextElement], +) -> Result<Vec<SlashTokenArg>, SlashCommandUsageErrorKind> { + let mut elements = text_elements.to_vec(); + elements.sort_by_key(|element| element.byte_range.start); + let (text_for_shlex, replacements) = replace_text_elements_with_sentinels(text, &elements); + let mut lexer = Shlex::new(&text_for_shlex); + let tokens: Vec<String> = lexer.by_ref().collect(); + if lexer.had_error { + return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); + } + Ok(tokens + .into_iter() + .map(|token| { + let restored = restore_sentinels_in_fragment(token, &replacements); + SlashTokenArg::new(restored.text, restored.text_elements) + }) + .collect()) +} + +fn serialize_token(token: &SlashTokenArg) -> SlashSerializedText { + if token.text.is_empty() { + return SlashSerializedText::empty(); + } + + let (token_for_shlex, replacements) = + replace_text_elements_with_sentinels(&token.text, &token.text_elements); + let quoted = try_join([token_for_shlex.as_str()]) + .unwrap_or_else(|_| shell_quote_token(&token_for_shlex)); + restore_sentinels_in_fragment(quoted, &replacements) +} + +fn shell_quote_token(token: &str) -> String { + if token.is_empty() { + return "''".to_string(); + } + + let mut quoted = String::from("'"); + for ch in token.chars() { + if ch == '\'' { + quoted.push_str("'\"'\"'"); + } else { + quoted.push(ch); + } + } + quoted.push('\''); + quoted +} + +fn join_serialized_fragments(fragments: Vec<SlashSerializedText>) -> SlashSerializedText { + let mut text = String::new(); + let mut text_elements = Vec::new(); + + for fragment in fragments + .into_iter() + .filter(|fragment| !fragment.text.is_empty()) + { + let offset = if text.is_empty() { 0 } else { 1 }; + if offset == 1 { + text.push(' '); + } + let fragment_offset = text.len(); + text.push_str(&fragment.text); + text_elements.extend(shift_text_elements_right( + &fragment.text_elements, + fragment_offset, + )); + } + + SlashSerializedText { + text, + text_elements, + } +} + +fn shift_text_element_left(element: &TextElement, offset: usize) -> Option<TextElement> { + if element.byte_range.end <= offset { + return None; + } + let start = element.byte_range.start.saturating_sub(offset); + let end = element.byte_range.end.saturating_sub(offset); + (start < end).then(|| element.map_range(|_| ByteRange { start, end })) +} + +fn shift_text_elements_right(elements: &[TextElement], offset: usize) -> Vec<TextElement> { + elements + .iter() + .map(|element| { + element.map_range(|byte_range| ByteRange { + start: byte_range.start + offset, + end: byte_range.end + offset, + }) + }) + .collect() +} + +#[derive(Debug, Clone)] +struct ElementReplacement { + sentinel: String, + text: String, + placeholder: Option<String>, +} + +fn replace_text_elements_with_sentinels( + text: &str, + text_elements: &[TextElement], +) -> (String, Vec<ElementReplacement>) { + let mut out = String::with_capacity(text.len()); + let mut replacements = Vec::new(); + let mut cursor = 0; + let text_len = text.len(); + + for (idx, element) in text_elements.iter().enumerate() { + let start = element.byte_range.start.clamp(cursor, text_len); + let end = element.byte_range.end.clamp(start, text_len); + out.push_str(&text[cursor..start]); + let mut sentinel = format!("__CODEX_ELEM_{idx}__"); + while text.contains(&sentinel) { + sentinel.push('_'); + } + out.push_str(&sentinel); + let replacement_text = text + .get(start..end) + .or_else(|| element.placeholder(text)) + .unwrap_or_default() + .to_string(); + replacements.push(ElementReplacement { + sentinel, + text: replacement_text, + placeholder: element.placeholder(text).map(str::to_string), + }); + cursor = end; + } + + out.push_str(&text[cursor..]); + (out, replacements) +} + +fn restore_sentinels_in_fragment( + fragment: String, + replacements: &[ElementReplacement], +) -> SlashSerializedText { + if replacements.is_empty() { + return SlashSerializedText { + text: fragment, + text_elements: Vec::new(), + }; + } + + let mut out = String::with_capacity(fragment.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + while cursor < fragment.len() { + let Some((offset, replacement)) = next_replacement(&fragment, cursor, replacements) else { + out.push_str(&fragment[cursor..]); + break; + }; + let start_in_fragment = cursor + offset; + out.push_str(&fragment[cursor..start_in_fragment]); + let start = out.len(); + out.push_str(&replacement.text); + let end = out.len(); + if start < end { + out_elements.push(TextElement::new( + ByteRange { start, end }, + replacement.placeholder.clone(), + )); + } + cursor = start_in_fragment + replacement.sentinel.len(); + } + + SlashSerializedText { + text: out, + text_elements: out_elements, + } +} + +fn next_replacement<'a>( + text: &str, + cursor: usize, + replacements: &'a [ElementReplacement], +) -> Option<(usize, &'a ElementReplacement)> { + replacements + .iter() + .filter_map(|replacement| { + text[cursor..] + .find(&replacement.sentinel) + .map(|offset| (offset, replacement)) + }) + .min_by_key(|(offset, _)| *offset) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Switch { + On, + Off, + } + + const SWITCH_CHOICES: &[(&str, Switch)] = &[("on", Switch::On), ("off", Switch::Off)]; + + #[test] + fn parser_supports_positional_list_and_named_args() { + let mut parser = SlashArgsParser::new(SlashCommandParseInput { + args: "on first second --path=\"some dir\"", + text_elements: &[], + }) + .unwrap(); + + assert_eq!( + parser.positional(&enum_choice(SWITCH_CHOICES)), + Ok(Switch::On) + ); + assert_eq!( + parser.positional_list(&string()), + Ok(vec!["first".to_string(), "second".to_string()]) + ); + assert_eq!( + parser.named("path", &string()), + Ok(Some("some dir".to_string())) + ); + assert_eq!(parser.finish(), Ok(())); + } + + #[test] + fn parser_supports_optional_positional_args() { + let mut parser = SlashArgsParser::new(SlashCommandParseInput { + args: "on", + text_elements: &[], + }) + .unwrap(); + + assert_eq!( + parser.positional(&enum_choice(SWITCH_CHOICES)), + Ok(Switch::On) + ); + assert_eq!(parser.optional_positional(&string()), Ok(None)); + assert_eq!(parser.finish(), Ok(())); + } + + #[test] + fn serializer_stably_formats_named_args_after_positionals() { + let mut serializer = SlashArgsSerializer::default(); + serializer.positional(&Switch::On, &enum_choice(SWITCH_CHOICES)); + serializer.list(["first".to_string(), "second".to_string()], &string()); + serializer.named("path", &"some dir".to_string(), &string()); + + assert_eq!( + serializer.finish(), + SlashSerializedText { + text: "on first second --path='some dir'".to_string(), + text_elements: Vec::new(), + } + ); + } + + #[test] + fn remainder_preserves_placeholder_ranges() { + let placeholder = "[Image #1]".to_string(); + let prompt = SlashTextArg::new( + format!("review {placeholder}"), + vec![TextElement::new((7..18).into(), Some(placeholder.clone()))], + ); + let mut serializer = SlashArgsSerializer::default(); + serializer.remainder(&prompt, &text()); + + assert_eq!( + serializer.finish(), + SlashSerializedText { + text: format!("review {placeholder}"), + text_elements: vec![TextElement::new((7..18).into(), Some(placeholder))], + } + ); + } + + #[test] + fn remainder_quotes_shell_sensitive_text_when_needed() { + let prompt = SlashTextArg::new("a\"\" a\"".to_string(), Vec::new()); + let mut serializer = SlashArgsSerializer::default(); + serializer.remainder(&prompt, &text()); + + assert_eq!( + serializer.finish(), + SlashSerializedText { + text: "'a\"\" a\"'".to_string(), + text_elements: Vec::new(), + } + ); + assert_eq!( + SlashArgsParser::new(SlashCommandParseInput { + args: "'a\"\" a\"'", + text_elements: &[], + }) + .unwrap() + .required_remainder(&text()), + Ok(prompt) + ); + } +}