prototyping

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