Compare commits

...

13 Commits

Author SHA1 Message Date
Charles Cunningham
159d9a2cb6 tui: rename slash args codec to schema
Co-authored-by: Codex <noreply@openai.com>
2026-03-22 18:51:31 -07:00
Charles Cunningham
2b96127399 tui: derive slash command codecs from schema
Co-authored-by: Codex <noreply@openai.com>
2026-03-22 18:02:14 -07:00
Charles Cunningham
e7328255e6 tui: use slash command parser combinators
Co-authored-by: Codex <noreply@openai.com>
2026-03-22 15:52:59 -07:00
Charles Cunningham
ed5c86fcfc tui: fuzz slash command roundtrips
Add registry-driven proptest coverage for slash command serialization in tui and tui_app_server. The new property suite surfaced a remainder quoting bug, so the shared slash protocol now shell-quotes non-roundtrippable remainder text before replay parsing.

Co-authored-by: Codex <noreply@openai.com>
2026-03-22 15:15:37 -07:00
Charles Cunningham
91901741bd tui: unify slash command protocol
Replace the transitional slash parser/serializer split with a single typed protocol layer shared by tui and tui_app_server.

Commands now own their typed args, usage text, and inline parse/serialize behavior from the slash command registry, while a shared protocol module provides positional, variadic, named, and remainder argument building blocks with stable shlex-based roundtripping.

Co-authored-by: Codex <noreply@openai.com>
2026-03-22 02:09:11 -07:00
Charles Cunningham
924c8dc421 prototyping 2026-03-21 14:49:57 -07:00
Charles Cunningham
c17a609cb2 tui: use generic slash parser building blocks
Replace the remaining parse-kind special cases with declarative slash parser primitives so each command spec owns its inline-arg parser as NoArgs, Remainder, or token choices in both tui implementations.

Co-authored-by: Codex <noreply@openai.com>
2026-03-21 01:06:02 -07:00
Charles Cunningham
708a95ed4a tui: unify slash command registration
Move usage strings, bare-form behavior, and inline-arg parser selection into the per-command slash spec so command registration lives in one authoritative table in both tui implementations.

Co-authored-by: Codex <noreply@openai.com>
2026-03-20 23:26:26 -07:00
Charles Cunningham
9e1f60ce42 tui: fix slash command rebase drift
Align the serialization-abstraction branch with current main after the rebase by updating popup visibility calls and teaching the shared slash parser about Plugins.

Co-authored-by: Codex <noreply@openai.com>
2026-03-20 20:37:32 -07:00
Charles Cunningham
f64a5d560b tui: make slash parsing command-owned
Co-authored-by: Codex <noreply@openai.com>
2026-03-20 20:26:32 -07:00
Charles Cunningham
15ab7fa6ef tui: clean up slash command argument handling
Co-authored-by: Codex <noreply@openai.com>
2026-03-20 20:26:14 -07:00
Charles Cunningham
0b4fbac88d tui_app_server: mirror slash command serialization
Co-authored-by: Codex <noreply@openai.com>
2026-03-20 20:25:42 -07:00
Charles Cunningham
6edc129714 tui: centralize slash command serialization
Introduce a small slash command invocation serializer and consolidate built-in command metadata behind a single spec table without changing slash command runtime behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-03-20 20:25:14 -07:00
16 changed files with 5095 additions and 410 deletions

47
codex-rs/Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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<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.",
@@ -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);
}
SlashCommand::Rename if !trimmed.is_empty() => {
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);
}
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());
}
}
}

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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)
);
}
}

View File

@@ -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 }

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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);
}
SlashCommand::Rename if !trimmed.is_empty() => {
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);
}
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());
}
}
}

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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)
);
}
}