mirror of
https://github.com/openai/codex.git
synced 2026-05-13 15:52:40 +00:00
Compare commits
13 Commits
split-mcp-
...
cc/tui-sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159d9a2cb6 | ||
|
|
2b96127399 | ||
|
|
e7328255e6 | ||
|
|
ed5c86fcfc | ||
|
|
91901741bd | ||
|
|
924c8dc421 | ||
|
|
c17a609cb2 | ||
|
|
708a95ed4a | ||
|
|
9e1f60ce42 | ||
|
|
f64a5d560b | ||
|
|
15ab7fa6ef | ||
|
|
0b4fbac88d | ||
|
|
6edc129714 |
47
codex-rs/Cargo.lock
generated
47
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
981
codex-rs/tui/src/slash_command_protocol.rs
Normal file
981
codex-rs/tui/src/slash_command_protocol.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
981
codex-rs/tui_app_server/src/slash_command_protocol.rs
Normal file
981
codex-rs/tui_app_server/src/slash_command_protocol.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user