From 2b961273995e0049dad941f7909f78b04ea891be Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 22 Mar 2026 18:02:14 -0700 Subject: [PATCH] tui: derive slash command codecs from schema Co-authored-by: Codex --- codex-rs/tui/src/slash_command.rs | 206 +++++++----------- codex-rs/tui/src/slash_command_protocol.rs | 186 +++++++++++++++- codex-rs/tui_app_server/src/slash_command.rs | 206 +++++++----------- .../src/slash_command_protocol.rs | 186 +++++++++++++++- 4 files changed, 528 insertions(+), 256 deletions(-) diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a0fb3e7825..1d41067c33 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -4,15 +4,17 @@ use strum_macros::IntoStaticStr; use crate::app_event::FeedbackCategory; use crate::bottom_pane::StatusLineItem; -use crate::slash_command_protocol::SlashArgsParser; -use crate::slash_command_protocol::SlashArgsSerializer; -use crate::slash_command_protocol::SlashCommandArgs; +use crate::slash_command_protocol::SlashArgsCodec; 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; @@ -120,10 +122,26 @@ const FEEDBACK_CATEGORY_CHOICES: &[(&str, FeedbackCategory)] = &[ ("other", FeedbackCategory::Other), ]; -pub(crate) trait SlashCommandInlineArgs: SlashCommandArgs + Sized { +pub(crate) trait SlashCommandInlineArgs: Sized { const USAGE_LINES: &'static [&'static str]; + fn codec() -> Box>; fn into_invocation(self) -> SlashCommandInvocation; + + fn parse_inline(input: SlashCommandParseInput<'_>) -> Result { + let codec = Self::codec(); + let mut parser = crate::slash_command_protocol::SlashArgsParser::new(input)?; + let value = codec.parse(&mut parser)?; + codec.finish(parser)?; + Ok(value) + } + + fn serialize_inline(&self) -> SlashSerializedText { + let codec = Self::codec(); + let mut serializer = crate::slash_command_protocol::SlashArgsSerializer::default(); + codec.serialize(self, &mut serializer); + serializer.finish() + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -131,24 +149,16 @@ pub(crate) struct FastArgs { pub(crate) mode: FastSlashCommandArgs, } -impl SlashCommandArgs for FastArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result { - let mut parser = SlashArgsParser::new(input)?; - let mode = parser.positional(&enum_choice(FAST_MODE_CHOICES).ascii_case_insensitive())?; - parser.finish()?; - Ok(Self { mode }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.positional(&self.mode, &enum_choice(FAST_MODE_CHOICES)); - serializer.finish() - } -} - impl SlashCommandInlineArgs for FastArgs { const USAGE_LINES: &'static [&'static str] = &["/fast", "/fast [on|off|status]"]; + fn codec() -> 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) } @@ -159,23 +169,15 @@ pub(crate) struct RenameArgs { pub(crate) title: SlashTextArg, } -impl SlashCommandArgs for RenameArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result { - let parser = SlashArgsParser::new(input)?; - let title = parser.required_remainder(&text())?; - Ok(Self { title }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.remainder(&self.title, &text()); - serializer.finish() - } -} - impl SlashCommandInlineArgs for RenameArgs { const USAGE_LINES: &'static [&'static str] = &["/rename", "/rename "]; + fn codec() -> Box<dyn SlashArgsCodec<Self>> { + Box::new( + remainder(text()).map_result(|title| Ok(Self { title }), |args| args.title.clone()), + ) + } + fn into_invocation(self) -> SlashCommandInvocation { SlashCommandInvocation::Rename(self) } @@ -186,23 +188,15 @@ pub(crate) struct PlanArgs { pub(crate) prompt: SlashTextArg, } -impl SlashCommandArgs for PlanArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let parser = SlashArgsParser::new(input)?; - let prompt = parser.required_remainder(&text())?; - Ok(Self { prompt }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.remainder(&self.prompt, &text()); - serializer.finish() - } -} - impl SlashCommandInlineArgs for PlanArgs { const USAGE_LINES: &'static [&'static str] = &["/plan", "/plan <prompt>"]; + fn codec() -> Box<dyn SlashArgsCodec<Self>> { + Box::new( + remainder(text()).map_result(|prompt| Ok(Self { prompt }), |args| args.prompt.clone()), + ) + } + fn into_invocation(self) -> SlashCommandInvocation { SlashCommandInvocation::Plan(self) } @@ -213,23 +207,16 @@ pub(crate) struct ReviewArgs { pub(crate) instructions: SlashTextArg, } -impl SlashCommandArgs for ReviewArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let parser = SlashArgsParser::new(input)?; - let instructions = parser.required_remainder(&text())?; - Ok(Self { instructions }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.remainder(&self.instructions, &text()); - serializer.finish() - } -} - impl SlashCommandInlineArgs for ReviewArgs { const USAGE_LINES: &'static [&'static str] = &["/review", "/review <instructions>"]; + fn codec() -> Box<dyn SlashArgsCodec<Self>> { + Box::new(remainder(text()).map_result( + |instructions| Ok(Self { instructions }), + |args| args.instructions.clone(), + )) + } + fn into_invocation(self) -> SlashCommandInvocation { SlashCommandInvocation::Review(self) } @@ -240,30 +227,19 @@ pub(crate) struct SandboxReadRootArgs { pub(crate) path: String, } -impl SlashCommandArgs for SandboxReadRootArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let mut parser = SlashArgsParser::new(input)?; - let path = match parser.named("path", &string())? { - Some(path) => path, - None => parser.positional(&string())?, - }; - parser.finish()?; - Ok(Self { path }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.positional(&self.path, &string()); - serializer.finish() - } -} - impl SlashCommandInlineArgs for SandboxReadRootArgs { const USAGE_LINES: &'static [&'static str] = &[ "/sandbox-add-read-dir <absolute-path>", "/sandbox-add-read-dir --path=<absolute-path>", ]; + fn codec() -> Box<dyn SlashArgsCodec<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) } @@ -274,27 +250,19 @@ pub(crate) struct FeedbackArgs { pub(crate) category: FeedbackCategory, } -impl SlashCommandArgs for FeedbackArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let mut parser = SlashArgsParser::new(input)?; - let category = parser.positional(&enum_choice(FEEDBACK_CATEGORY_CHOICES))?; - parser.finish()?; - Ok(Self { category }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.positional(&self.category, &enum_choice(FEEDBACK_CATEGORY_CHOICES)); - serializer.finish() - } -} - impl SlashCommandInlineArgs for FeedbackArgs { const USAGE_LINES: &'static [&'static str] = &[ "/feedback", "/feedback <bad-result|good-result|bug|safety-check|other>", ]; + fn codec() -> Box<dyn SlashArgsCodec<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) } @@ -305,30 +273,22 @@ pub(crate) struct StatuslineArgs { pub(crate) items: Vec<StatusLineItem>, } -impl SlashCommandArgs for StatuslineArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let mut parser = SlashArgsParser::new(input)?; - let items = parser.positional_list(&from_str_value::<StatusLineItem>())?; - parser.finish()?; - if items.is_empty() { - return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); - } - Ok(Self { items }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.list( - self.items.iter().cloned(), - &from_str_value::<StatusLineItem>(), - ); - serializer.finish() - } -} - impl SlashCommandInlineArgs for StatuslineArgs { const USAGE_LINES: &'static [&'static str] = &["/statusline", "/statusline <item>..."]; + fn codec() -> Box<dyn SlashArgsCodec<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) } @@ -368,13 +328,13 @@ impl SlashCommandInvocation { let prefix = format!("/{}", self.command().command()); match self { Self::Bare(_) => SlashSerializedText::empty().with_prefix(&prefix), - Self::Fast(args) => args.serialize().with_prefix(&prefix), - Self::Rename(args) => args.serialize().with_prefix(&prefix), - Self::Plan(args) => args.serialize().with_prefix(&prefix), - Self::Review(args) => args.serialize().with_prefix(&prefix), - Self::SandboxReadRoot(args) => args.serialize().with_prefix(&prefix), - Self::Feedback(args) => args.serialize().with_prefix(&prefix), - Self::Statusline(args) => args.serialize().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), } } @@ -412,7 +372,7 @@ fn parse_typed_inline<T>( where T: SlashCommandInlineArgs, { - T::parse(input).map(T::into_invocation) + T::parse_inline(input).map(T::into_invocation) } // ===== /model ===== diff --git a/codex-rs/tui/src/slash_command_protocol.rs b/codex-rs/tui/src/slash_command_protocol.rs index 4b585d9e6f..7090bcf817 100644 --- a/codex-rs/tui/src/slash_command_protocol.rs +++ b/codex-rs/tui/src/slash_command_protocol.rs @@ -241,11 +241,6 @@ where } } -pub(crate) trait SlashCommandArgs: Sized { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind>; - fn serialize(&self) -> SlashSerializedText; -} - #[derive(Debug)] pub(crate) struct SlashArgsParser<'a> { input: SlashCommandParseInput<'a>, @@ -429,6 +424,187 @@ impl SlashArgsSerializer { } } +pub(crate) trait SlashArgsCodec<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, + ) -> SlashMapResultCodec<Self, P, S, T, U> + where + Self: Sized, + P: Fn(T) -> Result<U, SlashCommandUsageErrorKind>, + S: Fn(&U) -> T, + { + SlashMapResultCodec { + inner: self, + parse_map, + serialize_map, + _phantom: PhantomData, + } + } +} + +pub(crate) struct SlashMapResultCodec<C, P, S, T, U> { + inner: C, + parse_map: P, + serialize_map: S, + _phantom: PhantomData<fn(T) -> U>, +} + +impl<C, P, S, T, U> SlashArgsCodec<U> for SlashMapResultCodec<C, P, S, T, U> +where + C: SlashArgsCodec<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 SlashPositionalCodec<S> { + spec: S, +} + +pub(crate) fn positional<S>(spec: S) -> SlashPositionalCodec<S> { + SlashPositionalCodec { spec } +} + +impl<T, S> SlashArgsCodec<T> for SlashPositionalCodec<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 SlashListCodec<S> { + spec: S, +} + +pub(crate) fn list<S>(spec: S) -> SlashListCodec<S> { + SlashListCodec { spec } +} + +impl<T, S> SlashArgsCodec<Vec<T>> for SlashListCodec<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 SlashNamedCodec<S> { + key: &'static str, + spec: S, +} + +#[allow(dead_code)] +pub(crate) fn named<S>(key: &'static str, spec: S) -> SlashNamedCodec<S> { + SlashNamedCodec { key, spec } +} + +impl<T, S> SlashArgsCodec<Option<T>> for SlashNamedCodec<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 SlashNamedOrPositionalCodec<S> { + key: &'static str, + spec: S, +} + +pub(crate) fn named_or_positional<S>(key: &'static str, spec: S) -> SlashNamedOrPositionalCodec<S> { + SlashNamedOrPositionalCodec { key, spec } +} + +impl<T, S> SlashArgsCodec<T> for SlashNamedOrPositionalCodec<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 SlashRemainderCodec<S> { + spec: S, +} + +pub(crate) fn remainder<S>(spec: S) -> SlashRemainderCodec<S> { + SlashRemainderCodec { spec } +} + +impl<T, S> SlashArgsCodec<T> for SlashRemainderCodec<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(); diff --git a/codex-rs/tui_app_server/src/slash_command.rs b/codex-rs/tui_app_server/src/slash_command.rs index b5bd8f0900..ebb0a8aadb 100644 --- a/codex-rs/tui_app_server/src/slash_command.rs +++ b/codex-rs/tui_app_server/src/slash_command.rs @@ -4,15 +4,17 @@ use strum_macros::IntoStaticStr; use crate::app_event::FeedbackCategory; use crate::bottom_pane::StatusLineItem; -use crate::slash_command_protocol::SlashArgsParser; -use crate::slash_command_protocol::SlashArgsSerializer; -use crate::slash_command_protocol::SlashCommandArgs; +use crate::slash_command_protocol::SlashArgsCodec; 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; @@ -119,10 +121,26 @@ const FEEDBACK_CATEGORY_CHOICES: &[(&str, FeedbackCategory)] = &[ ("other", FeedbackCategory::Other), ]; -pub(crate) trait SlashCommandInlineArgs: SlashCommandArgs + Sized { +pub(crate) trait SlashCommandInlineArgs: Sized { const USAGE_LINES: &'static [&'static str]; + fn codec() -> Box<dyn SlashArgsCodec<Self>>; fn into_invocation(self) -> SlashCommandInvocation; + + fn parse_inline(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { + let codec = Self::codec(); + let mut parser = crate::slash_command_protocol::SlashArgsParser::new(input)?; + let value = codec.parse(&mut parser)?; + codec.finish(parser)?; + Ok(value) + } + + fn serialize_inline(&self) -> SlashSerializedText { + let codec = Self::codec(); + let mut serializer = crate::slash_command_protocol::SlashArgsSerializer::default(); + codec.serialize(self, &mut serializer); + serializer.finish() + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -130,24 +148,16 @@ pub(crate) struct FastArgs { pub(crate) mode: FastSlashCommandArgs, } -impl SlashCommandArgs for FastArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let mut parser = SlashArgsParser::new(input)?; - let mode = parser.positional(&enum_choice(FAST_MODE_CHOICES).ascii_case_insensitive())?; - parser.finish()?; - Ok(Self { mode }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.positional(&self.mode, &enum_choice(FAST_MODE_CHOICES)); - serializer.finish() - } -} - impl SlashCommandInlineArgs for FastArgs { const USAGE_LINES: &'static [&'static str] = &["/fast", "/fast [on|off|status]"]; + fn codec() -> Box<dyn SlashArgsCodec<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) } @@ -158,23 +168,15 @@ pub(crate) struct RenameArgs { pub(crate) title: SlashTextArg, } -impl SlashCommandArgs for RenameArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let parser = SlashArgsParser::new(input)?; - let title = parser.required_remainder(&text())?; - Ok(Self { title }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.remainder(&self.title, &text()); - serializer.finish() - } -} - impl SlashCommandInlineArgs for RenameArgs { const USAGE_LINES: &'static [&'static str] = &["/rename", "/rename <title>"]; + fn codec() -> Box<dyn SlashArgsCodec<Self>> { + Box::new( + remainder(text()).map_result(|title| Ok(Self { title }), |args| args.title.clone()), + ) + } + fn into_invocation(self) -> SlashCommandInvocation { SlashCommandInvocation::Rename(self) } @@ -185,23 +187,15 @@ pub(crate) struct PlanArgs { pub(crate) prompt: SlashTextArg, } -impl SlashCommandArgs for PlanArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let parser = SlashArgsParser::new(input)?; - let prompt = parser.required_remainder(&text())?; - Ok(Self { prompt }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.remainder(&self.prompt, &text()); - serializer.finish() - } -} - impl SlashCommandInlineArgs for PlanArgs { const USAGE_LINES: &'static [&'static str] = &["/plan", "/plan <prompt>"]; + fn codec() -> Box<dyn SlashArgsCodec<Self>> { + Box::new( + remainder(text()).map_result(|prompt| Ok(Self { prompt }), |args| args.prompt.clone()), + ) + } + fn into_invocation(self) -> SlashCommandInvocation { SlashCommandInvocation::Plan(self) } @@ -212,23 +206,16 @@ pub(crate) struct ReviewArgs { pub(crate) instructions: SlashTextArg, } -impl SlashCommandArgs for ReviewArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let parser = SlashArgsParser::new(input)?; - let instructions = parser.required_remainder(&text())?; - Ok(Self { instructions }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.remainder(&self.instructions, &text()); - serializer.finish() - } -} - impl SlashCommandInlineArgs for ReviewArgs { const USAGE_LINES: &'static [&'static str] = &["/review", "/review <instructions>"]; + fn codec() -> Box<dyn SlashArgsCodec<Self>> { + Box::new(remainder(text()).map_result( + |instructions| Ok(Self { instructions }), + |args| args.instructions.clone(), + )) + } + fn into_invocation(self) -> SlashCommandInvocation { SlashCommandInvocation::Review(self) } @@ -239,30 +226,19 @@ pub(crate) struct SandboxReadRootArgs { pub(crate) path: String, } -impl SlashCommandArgs for SandboxReadRootArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let mut parser = SlashArgsParser::new(input)?; - let path = match parser.named("path", &string())? { - Some(path) => path, - None => parser.positional(&string())?, - }; - parser.finish()?; - Ok(Self { path }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.positional(&self.path, &string()); - serializer.finish() - } -} - impl SlashCommandInlineArgs for SandboxReadRootArgs { const USAGE_LINES: &'static [&'static str] = &[ "/sandbox-add-read-dir <absolute-path>", "/sandbox-add-read-dir --path=<absolute-path>", ]; + fn codec() -> Box<dyn SlashArgsCodec<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) } @@ -273,27 +249,19 @@ pub(crate) struct FeedbackArgs { pub(crate) category: FeedbackCategory, } -impl SlashCommandArgs for FeedbackArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let mut parser = SlashArgsParser::new(input)?; - let category = parser.positional(&enum_choice(FEEDBACK_CATEGORY_CHOICES))?; - parser.finish()?; - Ok(Self { category }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.positional(&self.category, &enum_choice(FEEDBACK_CATEGORY_CHOICES)); - serializer.finish() - } -} - impl SlashCommandInlineArgs for FeedbackArgs { const USAGE_LINES: &'static [&'static str] = &[ "/feedback", "/feedback <bad-result|good-result|bug|safety-check|other>", ]; + fn codec() -> Box<dyn SlashArgsCodec<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) } @@ -304,30 +272,22 @@ pub(crate) struct StatuslineArgs { pub(crate) items: Vec<StatusLineItem>, } -impl SlashCommandArgs for StatuslineArgs { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind> { - let mut parser = SlashArgsParser::new(input)?; - let items = parser.positional_list(&from_str_value::<StatusLineItem>())?; - parser.finish()?; - if items.is_empty() { - return Err(SlashCommandUsageErrorKind::InvalidInlineArgs); - } - Ok(Self { items }) - } - - fn serialize(&self) -> SlashSerializedText { - let mut serializer = SlashArgsSerializer::default(); - serializer.list( - self.items.iter().cloned(), - &from_str_value::<StatusLineItem>(), - ); - serializer.finish() - } -} - impl SlashCommandInlineArgs for StatuslineArgs { const USAGE_LINES: &'static [&'static str] = &["/statusline", "/statusline <item>..."]; + fn codec() -> Box<dyn SlashArgsCodec<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) } @@ -367,13 +327,13 @@ impl SlashCommandInvocation { let prefix = format!("/{}", self.command().command()); match self { Self::Bare(_) => SlashSerializedText::empty().with_prefix(&prefix), - Self::Fast(args) => args.serialize().with_prefix(&prefix), - Self::Rename(args) => args.serialize().with_prefix(&prefix), - Self::Plan(args) => args.serialize().with_prefix(&prefix), - Self::Review(args) => args.serialize().with_prefix(&prefix), - Self::SandboxReadRoot(args) => args.serialize().with_prefix(&prefix), - Self::Feedback(args) => args.serialize().with_prefix(&prefix), - Self::Statusline(args) => args.serialize().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), } } @@ -411,7 +371,7 @@ fn parse_typed_inline<T>( where T: SlashCommandInlineArgs, { - T::parse(input).map(T::into_invocation) + T::parse_inline(input).map(T::into_invocation) } // ===== /model ===== diff --git a/codex-rs/tui_app_server/src/slash_command_protocol.rs b/codex-rs/tui_app_server/src/slash_command_protocol.rs index 4b585d9e6f..7090bcf817 100644 --- a/codex-rs/tui_app_server/src/slash_command_protocol.rs +++ b/codex-rs/tui_app_server/src/slash_command_protocol.rs @@ -241,11 +241,6 @@ where } } -pub(crate) trait SlashCommandArgs: Sized { - fn parse(input: SlashCommandParseInput<'_>) -> Result<Self, SlashCommandUsageErrorKind>; - fn serialize(&self) -> SlashSerializedText; -} - #[derive(Debug)] pub(crate) struct SlashArgsParser<'a> { input: SlashCommandParseInput<'a>, @@ -429,6 +424,187 @@ impl SlashArgsSerializer { } } +pub(crate) trait SlashArgsCodec<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, + ) -> SlashMapResultCodec<Self, P, S, T, U> + where + Self: Sized, + P: Fn(T) -> Result<U, SlashCommandUsageErrorKind>, + S: Fn(&U) -> T, + { + SlashMapResultCodec { + inner: self, + parse_map, + serialize_map, + _phantom: PhantomData, + } + } +} + +pub(crate) struct SlashMapResultCodec<C, P, S, T, U> { + inner: C, + parse_map: P, + serialize_map: S, + _phantom: PhantomData<fn(T) -> U>, +} + +impl<C, P, S, T, U> SlashArgsCodec<U> for SlashMapResultCodec<C, P, S, T, U> +where + C: SlashArgsCodec<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 SlashPositionalCodec<S> { + spec: S, +} + +pub(crate) fn positional<S>(spec: S) -> SlashPositionalCodec<S> { + SlashPositionalCodec { spec } +} + +impl<T, S> SlashArgsCodec<T> for SlashPositionalCodec<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 SlashListCodec<S> { + spec: S, +} + +pub(crate) fn list<S>(spec: S) -> SlashListCodec<S> { + SlashListCodec { spec } +} + +impl<T, S> SlashArgsCodec<Vec<T>> for SlashListCodec<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 SlashNamedCodec<S> { + key: &'static str, + spec: S, +} + +#[allow(dead_code)] +pub(crate) fn named<S>(key: &'static str, spec: S) -> SlashNamedCodec<S> { + SlashNamedCodec { key, spec } +} + +impl<T, S> SlashArgsCodec<Option<T>> for SlashNamedCodec<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 SlashNamedOrPositionalCodec<S> { + key: &'static str, + spec: S, +} + +pub(crate) fn named_or_positional<S>(key: &'static str, spec: S) -> SlashNamedOrPositionalCodec<S> { + SlashNamedOrPositionalCodec { key, spec } +} + +impl<T, S> SlashArgsCodec<T> for SlashNamedOrPositionalCodec<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 SlashRemainderCodec<S> { + spec: S, +} + +pub(crate) fn remainder<S>(spec: S) -> SlashRemainderCodec<S> { + SlashRemainderCodec { spec } +} + +impl<T, S> SlashArgsCodec<T> for SlashRemainderCodec<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();