mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
tui: share slash command shlex codec
Centralize slash command draft serialization on top of shlex so popup flows and queued replay use the same quoting and tokenization rules. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
co-authored by
Codex
parent
7ea03de12b
commit
ad586ba24c
@@ -1753,11 +1753,11 @@ impl App {
|
||||
is_current: self.active_thread_id == Some(*thread_id),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
format!(
|
||||
"/{} {id}",
|
||||
crate::slash_command::SlashCommand::Agent.command()
|
||||
crate::slash_command_invocation::SlashCommandInvocation::with_args(
|
||||
crate::slash_command::SlashCommand::Agent,
|
||||
[id.to_string()],
|
||||
)
|
||||
.into(),
|
||||
.into_user_message(),
|
||||
));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::render::RectExt as _;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use crate::style::user_message_style;
|
||||
|
||||
use super::CancellationEvent;
|
||||
@@ -198,14 +199,14 @@ impl BottomPaneView for ExperimentalFeaturesView {
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if !self.features.is_empty() {
|
||||
let args = self
|
||||
.features
|
||||
.iter()
|
||||
.map(|item| format!("{}={}", item.key, if item.enabled { "on" } else { "off" }))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let invocation = SlashCommandInvocation::with_args(
|
||||
SlashCommand::Experimental,
|
||||
self.features.iter().map(|item| {
|
||||
format!("{}={}", item.key, if item.enabled { "on" } else { "off" })
|
||||
}),
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
format!("/{} {args}", SlashCommand::Experimental.command()).into(),
|
||||
invocation.into_user_message(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ use crate::app_event::FeedbackCategory;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::history_cell;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
|
||||
use super::CancellationEvent;
|
||||
@@ -494,7 +496,7 @@ fn make_feedback_item(
|
||||
};
|
||||
let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| {
|
||||
app_event_tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
format!("/feedback {token}").into(),
|
||||
SlashCommandInvocation::with_args(SlashCommand::Feedback, [token]).into_user_message(),
|
||||
));
|
||||
});
|
||||
super::SelectionItem {
|
||||
|
||||
@@ -35,6 +35,7 @@ use crate::bottom_pane::multi_select_picker::MultiSelectItem;
|
||||
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
|
||||
/// Available items that can be displayed in the status line.
|
||||
///
|
||||
@@ -232,13 +233,13 @@ impl StatusLineSetupView {
|
||||
.enable_ordering()
|
||||
.on_preview(move |items| preview_data.line_for_items(items))
|
||||
.on_confirm(|ids, app_event| {
|
||||
let args = if ids.is_empty() {
|
||||
"none".to_string()
|
||||
let invocation = if ids.is_empty() {
|
||||
SlashCommandInvocation::with_args(SlashCommand::Statusline, ["none"])
|
||||
} else {
|
||||
ids.join(" ")
|
||||
SlashCommandInvocation::with_args(SlashCommand::Statusline, ids)
|
||||
};
|
||||
app_event.send(AppEvent::HandleSlashCommandDraft(
|
||||
format!("/{} {args}", SlashCommand::Statusline.command()).into(),
|
||||
invocation.into_user_message(),
|
||||
));
|
||||
})
|
||||
.on_cancel(|app_event| {
|
||||
|
||||
+389
-182
@@ -48,6 +48,7 @@ use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::StatusLinePreviewData;
|
||||
use crate::bottom_pane::StatusLineSetupView;
|
||||
use crate::bottom_pane::find_builtin_command;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use crate::status::RateLimitWindowDisplay;
|
||||
use crate::status::format_directory_display;
|
||||
use crate::status::format_tokens_compact;
|
||||
@@ -4337,7 +4338,7 @@ impl ChatWidget {
|
||||
&& !cmd.available_during_task()
|
||||
&& !cmd.requires_interaction()
|
||||
{
|
||||
self.queue_user_message(format!("/{}", cmd.command()).into());
|
||||
self.queue_user_message(SlashCommandInvocation::bare(cmd).into_user_message());
|
||||
// This busy-path queueing only happens for live command dispatch. Queued replay
|
||||
// executes slash drafts only while idle, and handle_serialized_slash_command() queues
|
||||
// instead of dispatching when a task is already running, so this Stop result is not
|
||||
@@ -4773,11 +4774,19 @@ impl ChatWidget {
|
||||
) -> QueueReplayControl {
|
||||
match cmd {
|
||||
SlashCommand::Approvals | SlashCommand::Permissions => {
|
||||
let args = args_message
|
||||
.text
|
||||
.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /approvals <read-only|auto|full-access> [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.is_empty() {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
@@ -4941,7 +4950,27 @@ impl ChatWidget {
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => {
|
||||
match ThreadId::from_string(&args_message.text) {
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /agent <thread-id>",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.len() != 1 {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /agent <thread-id>".to_string(),
|
||||
);
|
||||
}
|
||||
match ThreadId::from_string(&args[0]) {
|
||||
Ok(thread_id) => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::SelectAgentThread(thread_id));
|
||||
@@ -4955,7 +4984,27 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
SlashCommand::Collab => {
|
||||
let mode = args_message.text.trim().to_ascii_lowercase();
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /collab <default|plan>",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.len() != 1 {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /collab <default|plan>".to_string(),
|
||||
);
|
||||
}
|
||||
let mode = args[0].to_ascii_lowercase();
|
||||
let Some(mask) = collaboration_modes::presets_for_tui(self.models_manager.as_ref())
|
||||
.into_iter()
|
||||
.find(|mask| mask.name.eq_ignore_ascii_case(&mode))
|
||||
@@ -4972,11 +5021,19 @@ impl ChatWidget {
|
||||
QueueReplayControl::Continue
|
||||
}
|
||||
SlashCommand::Experimental => {
|
||||
let tokens = args_message
|
||||
.text
|
||||
.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let tokens = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /experimental <feature-key>=on|off ...",
|
||||
) {
|
||||
Ok(tokens) => tokens,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut updates = Vec::new();
|
||||
for token in tokens {
|
||||
let Some((key, value)) = token.split_once('=') else {
|
||||
@@ -5010,32 +5067,76 @@ impl ChatWidget {
|
||||
.send(AppEvent::UpdateFeatureFlags { updates });
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
SlashCommand::Fast => match args_message.text.to_ascii_lowercase().as_str() {
|
||||
"on" => {
|
||||
self.set_service_tier_selection(Some(ServiceTier::Fast));
|
||||
QueueReplayControl::Continue
|
||||
SlashCommand::Fast => {
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /fast [on|off|status]",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.len() != 1 {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /fast [on|off|status]".to_string(),
|
||||
);
|
||||
}
|
||||
"off" => {
|
||||
self.set_service_tier_selection(None);
|
||||
QueueReplayControl::Continue
|
||||
match args[0].to_ascii_lowercase().as_str() {
|
||||
"on" => {
|
||||
self.set_service_tier_selection(Some(ServiceTier::Fast));
|
||||
QueueReplayControl::Continue
|
||||
}
|
||||
"off" => {
|
||||
self.set_service_tier_selection(None);
|
||||
QueueReplayControl::Continue
|
||||
}
|
||||
"status" => {
|
||||
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
|
||||
{
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
};
|
||||
self.add_info_message(format!("Fast mode is {status}."), None);
|
||||
QueueReplayControl::Continue
|
||||
}
|
||||
_ => self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /fast [on|off|status]".to_string(),
|
||||
),
|
||||
}
|
||||
"status" => {
|
||||
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
};
|
||||
self.add_info_message(format!("Fast mode is {status}."), None);
|
||||
QueueReplayControl::Continue
|
||||
}
|
||||
_ => self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /fast [on|off|status]".to_string(),
|
||||
),
|
||||
},
|
||||
}
|
||||
SlashCommand::Feedback => {
|
||||
let category = match args_message.text.trim().to_ascii_lowercase().as_str() {
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /feedback <bug|bad-result|good-result|safety-check|other>",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.len() != 1 {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /feedback <bug|bad-result|good-result|safety-check|other>"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let category = match args[0].to_ascii_lowercase().as_str() {
|
||||
"bad-result" => crate::app_event::FeedbackCategory::BadResult,
|
||||
"good-result" => crate::app_event::FeedbackCategory::GoodResult,
|
||||
"bug" => crate::app_event::FeedbackCategory::Bug,
|
||||
@@ -5054,17 +5155,45 @@ impl ChatWidget {
|
||||
.send(AppEvent::OpenFeedbackConsent { category });
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
SlashCommand::Model => match Self::parse_model_selection_args(&args_message.text) {
|
||||
Ok((model, effort, scope)) => {
|
||||
self.apply_model_selection(model, effort, scope);
|
||||
QueueReplayControl::Continue
|
||||
}
|
||||
SlashCommand::Model => match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]",
|
||||
) {
|
||||
Ok(args) => match Self::parse_model_selection_args(&args) {
|
||||
Ok((model, effort, scope)) => {
|
||||
self.apply_model_selection(model, effort, scope);
|
||||
QueueReplayControl::Continue
|
||||
}
|
||||
Err(message) => {
|
||||
self.restore_invalid_inline_slash_command(cmd, args_message, message)
|
||||
}
|
||||
},
|
||||
Err(message) => {
|
||||
self.restore_invalid_inline_slash_command(cmd, args_message, message)
|
||||
}
|
||||
},
|
||||
SlashCommand::Personality => {
|
||||
let personality = match args_message.text.trim().to_ascii_lowercase().as_str() {
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /personality <none|friendly|pragmatic>",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.len() != 1 {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /personality <none|friendly|pragmatic>".to_string(),
|
||||
);
|
||||
}
|
||||
let personality = match args[0].to_ascii_lowercase().as_str() {
|
||||
"none" => Personality::None,
|
||||
"friendly" => Personality::Friendly,
|
||||
"pragmatic" => Personality::Pragmatic,
|
||||
@@ -5108,7 +5237,20 @@ impl ChatWidget {
|
||||
SlashCommand::Rename => {
|
||||
self.session_telemetry
|
||||
.counter("codex.thread.rename", 1, &[]);
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&args_message.text) else {
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Thread name cannot be empty.",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&args.join(" ")) else {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
@@ -5134,26 +5276,56 @@ impl ChatWidget {
|
||||
}
|
||||
QueueReplayControl::Stop
|
||||
}
|
||||
SlashCommand::Review => match Self::parse_review_request(&args_message.text) {
|
||||
Ok(review_request) => {
|
||||
self.submit_op(Op::Review { review_request });
|
||||
QueueReplayControl::Stop
|
||||
}
|
||||
SlashCommand::Review => match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /review [uncommitted|branch <name>|commit <sha> [title]|<instructions>]",
|
||||
) {
|
||||
Ok(args) => match Self::parse_review_request(&args) {
|
||||
Ok(review_request) => {
|
||||
self.submit_op(Op::Review { review_request });
|
||||
QueueReplayControl::Stop
|
||||
}
|
||||
Err(message) => {
|
||||
self.restore_invalid_inline_slash_command(cmd, args_message, message)
|
||||
}
|
||||
},
|
||||
Err(message) => {
|
||||
self.restore_invalid_inline_slash_command(cmd, args_message, message)
|
||||
}
|
||||
},
|
||||
SlashCommand::Resume => match ThreadId::from_string(args_message.text.trim()) {
|
||||
Ok(thread_id) => {
|
||||
self.app_event_tx.send(AppEvent::ResumeSession(thread_id));
|
||||
QueueReplayControl::Stop
|
||||
SlashCommand::Resume => {
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /resume <thread-id>",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.len() != 1 {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /resume <thread-id>".to_string(),
|
||||
);
|
||||
}
|
||||
Err(_) => self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /resume <thread-id>".to_string(),
|
||||
),
|
||||
},
|
||||
match ThreadId::from_string(&args[0]) {
|
||||
Ok(thread_id) => {
|
||||
self.app_event_tx.send(AppEvent::ResumeSession(thread_id));
|
||||
QueueReplayControl::Stop
|
||||
}
|
||||
Err(_) => self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /resume <thread-id>".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
SlashCommand::SandboxReadRoot => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
|
||||
@@ -5162,8 +5334,20 @@ impl ChatWidget {
|
||||
QueueReplayControl::Stop
|
||||
}
|
||||
SlashCommand::Settings => {
|
||||
let mut tokens = args_message.text.trim().splitn(2, char::is_whitespace);
|
||||
let Some(kind_name) = tokens.next() else {
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /settings <microphone|speaker> [default|<device-name>]",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(kind_name) = args.first() else {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
@@ -5182,62 +5366,107 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
};
|
||||
let name = match tokens.next().map(str::trim) {
|
||||
None | Some("") | Some("default") => None,
|
||||
Some(other) => Some(other.to_string()),
|
||||
let name = match args.get(1..).map(|rest| rest.join(" ")) {
|
||||
None => None,
|
||||
Some(device_name) if device_name.is_empty() || device_name == "default" => None,
|
||||
Some(device_name) => Some(device_name),
|
||||
};
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name });
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
SlashCommand::Skills => match args_message.text.trim().to_ascii_lowercase().as_str() {
|
||||
"list" => {
|
||||
self.open_skills_list();
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
"manage" => {
|
||||
self.app_event_tx.send(AppEvent::OpenManageSkillsPopup);
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
_ => self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /skills <list|manage>".to_string(),
|
||||
),
|
||||
},
|
||||
SlashCommand::Statusline => {
|
||||
let trimmed = args_message.text.trim();
|
||||
let item_ids = if trimmed.eq_ignore_ascii_case("none") {
|
||||
Vec::new()
|
||||
} else {
|
||||
trimmed
|
||||
.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let items = match item_ids
|
||||
.iter()
|
||||
.map(|item_id| item_id.parse::<StatusLineItem>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
{
|
||||
Ok(items) => items,
|
||||
Err(_) => {
|
||||
SlashCommand::Skills => {
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /skills <list|manage>",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /statusline <item-id>... | /statusline none".to_string(),
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.len() != 1 {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /skills <list|manage>".to_string(),
|
||||
);
|
||||
}
|
||||
match args[0].to_ascii_lowercase().as_str() {
|
||||
"list" => {
|
||||
self.open_skills_list();
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
"manage" => {
|
||||
self.app_event_tx.send(AppEvent::OpenManageSkillsPopup);
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
_ => self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /skills <list|manage>".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
SlashCommand::Statusline => {
|
||||
let item_ids = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /statusline <item-id>... | /statusline none",
|
||||
) {
|
||||
Ok(item_ids) => item_ids,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
let items = if item_ids.len() == 1 && item_ids[0].eq_ignore_ascii_case("none") {
|
||||
Vec::new()
|
||||
} else {
|
||||
match item_ids
|
||||
.iter()
|
||||
.map(|item_id| item_id.parse::<StatusLineItem>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
{
|
||||
Ok(items) => items,
|
||||
Err(_) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
"Usage: /statusline <item-id>... | /statusline none".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
self.app_event_tx.send(AppEvent::StatusLineSetup { items });
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
SlashCommand::Theme => {
|
||||
if crate::render::highlight::resolve_theme_by_name(
|
||||
args_message.text.trim(),
|
||||
Some(&self.config.codex_home),
|
||||
)
|
||||
.is_none()
|
||||
let args = match SlashCommandInvocation::parse_args(
|
||||
&args_message.text,
|
||||
"Usage: /theme <theme-name>",
|
||||
) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
args_message,
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
if args.len() != 1
|
||||
|| crate::render::highlight::resolve_theme_by_name(
|
||||
&args[0],
|
||||
Some(&self.config.codex_home),
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
return self.restore_invalid_inline_slash_command(
|
||||
cmd,
|
||||
@@ -5246,7 +5475,7 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::SyntaxThemeSelected {
|
||||
name: args_message.text.trim().to_string(),
|
||||
name: args[0].clone(),
|
||||
});
|
||||
QueueReplayControl::ResumeWhenIdle
|
||||
}
|
||||
@@ -5348,18 +5577,10 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn plain_slash_command_draft(cmd: SlashCommand, args: &str) -> UserMessage {
|
||||
if args.is_empty() {
|
||||
format!("/{}", cmd.command()).into()
|
||||
} else {
|
||||
format!("/{} {args}", cmd.command()).into()
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_preset_draft(preset_id: &str, flags: &[&str]) -> UserMessage {
|
||||
let mut args = vec![preset_id.to_string()];
|
||||
args.extend(flags.iter().map(|flag| (*flag).to_string()));
|
||||
Self::plain_slash_command_draft(SlashCommand::Approvals, &args.join(" "))
|
||||
SlashCommandInvocation::with_args(SlashCommand::Approvals, args).into_user_message()
|
||||
}
|
||||
|
||||
fn settings_device_draft(kind: RealtimeAudioDeviceKind, name: Option<&str>) -> UserMessage {
|
||||
@@ -5367,11 +5588,9 @@ impl ChatWidget {
|
||||
RealtimeAudioDeviceKind::Microphone => "microphone",
|
||||
RealtimeAudioDeviceKind::Speaker => "speaker",
|
||||
};
|
||||
let args = match name {
|
||||
Some(name) => format!("{kind_name} {name}"),
|
||||
None => format!("{kind_name} default"),
|
||||
};
|
||||
Self::plain_slash_command_draft(SlashCommand::Settings, &args)
|
||||
let device_name = name.unwrap_or("default");
|
||||
SlashCommandInvocation::with_args(SlashCommand::Settings, [kind_name, device_name])
|
||||
.into_user_message()
|
||||
}
|
||||
|
||||
fn queue_current_inline_bare_slash_command(&mut self, cmd: SlashCommand) {
|
||||
@@ -5390,16 +5609,14 @@ impl ChatWidget {
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
scope: ModelSelectionScope,
|
||||
) -> UserMessage {
|
||||
let mut text = format!("/model {model}");
|
||||
let mut args = vec![model.to_string()];
|
||||
if let Some(token) = Self::model_reasoning_effort_token(effort) {
|
||||
text.push(' ');
|
||||
text.push_str(token);
|
||||
args.push(token.to_string());
|
||||
}
|
||||
if let Some(token) = Self::model_selection_scope_token(scope) {
|
||||
text.push(' ');
|
||||
text.push_str(token);
|
||||
args.push(token.to_string());
|
||||
}
|
||||
text.into()
|
||||
SlashCommandInvocation::with_args(SlashCommand::Model, args).into_user_message()
|
||||
}
|
||||
|
||||
fn apply_model_selection(
|
||||
@@ -5446,13 +5663,27 @@ impl ChatWidget {
|
||||
|
||||
fn review_request_draft(review_request: &ReviewRequest) -> UserMessage {
|
||||
match &review_request.target {
|
||||
ReviewTarget::UncommittedChanges => "/review uncommitted".into(),
|
||||
ReviewTarget::BaseBranch { branch } => format!("/review branch {branch}").into(),
|
||||
ReviewTarget::Commit { sha, title } => match title.as_deref().map(str::trim) {
|
||||
Some("") | None => format!("/review commit {sha}").into(),
|
||||
Some(title) => format!("/review commit {sha} {title}").into(),
|
||||
},
|
||||
ReviewTarget::Custom { instructions } => format!("/review -- {instructions}").into(),
|
||||
ReviewTarget::UncommittedChanges => {
|
||||
SlashCommandInvocation::with_args(SlashCommand::Review, ["uncommitted"])
|
||||
.into_user_message()
|
||||
}
|
||||
ReviewTarget::BaseBranch { branch } => {
|
||||
SlashCommandInvocation::with_args(SlashCommand::Review, ["branch", branch.as_str()])
|
||||
.into_user_message()
|
||||
}
|
||||
ReviewTarget::Commit { sha, title } => {
|
||||
let mut args = vec!["commit".to_string(), sha.clone()];
|
||||
if let Some(title) = title.as_deref().map(str::trim)
|
||||
&& !title.is_empty()
|
||||
{
|
||||
args.push(title.to_string());
|
||||
}
|
||||
SlashCommandInvocation::with_args(SlashCommand::Review, args).into_user_message()
|
||||
}
|
||||
ReviewTarget::Custom { instructions } => {
|
||||
SlashCommandInvocation::with_args(SlashCommand::Review, [instructions.as_str()])
|
||||
.into_user_message()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5626,60 +5857,37 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_review_request(args: &str) -> Result<ReviewRequest, String> {
|
||||
const REVIEW_USAGE: &str = "Usage: /review [uncommitted|branch <name>|commit <sha> [title]|-- <instructions>|<instructions>]";
|
||||
let trimmed = args.trim();
|
||||
if let Some(instructions) = trimmed.strip_prefix("--") {
|
||||
let instructions = instructions.trim_start();
|
||||
if instructions.is_empty() {
|
||||
return Err(REVIEW_USAGE.to_string());
|
||||
}
|
||||
return Ok(ReviewRequest {
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: instructions.to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
});
|
||||
}
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
let target = if matches!(
|
||||
lowered.as_str(),
|
||||
"uncommitted" | "current" | "current changes" | "current-changes"
|
||||
) {
|
||||
fn parse_review_request(args: &[String]) -> Result<ReviewRequest, String> {
|
||||
const REVIEW_USAGE: &str =
|
||||
"Usage: /review [uncommitted|branch <name>|commit <sha> [title]|<instructions>]";
|
||||
let target = if args.len() == 1
|
||||
&& matches!(
|
||||
args[0].to_ascii_lowercase().as_str(),
|
||||
"uncommitted" | "current" | "current changes" | "current-changes"
|
||||
) {
|
||||
ReviewTarget::UncommittedChanges
|
||||
} else if let Some((keyword, value)) = trimmed.split_once(char::is_whitespace) {
|
||||
let value = value.trim();
|
||||
} else if let Some(keyword) = args.first() {
|
||||
match keyword.to_ascii_lowercase().as_str() {
|
||||
"branch" if !value.is_empty() => ReviewTarget::BaseBranch {
|
||||
branch: value.to_string(),
|
||||
"branch" if args.len() > 1 => ReviewTarget::BaseBranch {
|
||||
branch: args[1..].join(" "),
|
||||
},
|
||||
"branch" => {
|
||||
return Err(REVIEW_USAGE.to_string());
|
||||
}
|
||||
"commit" if !value.is_empty() => {
|
||||
let (sha, title) = value.split_once(char::is_whitespace).map_or(
|
||||
(value, None),
|
||||
|(sha, title)| {
|
||||
let title = title.trim();
|
||||
(sha, (!title.is_empty()).then_some(title.to_string()))
|
||||
},
|
||||
);
|
||||
ReviewTarget::Commit {
|
||||
sha: sha.to_string(),
|
||||
title,
|
||||
}
|
||||
"commit" if args.len() > 1 => {
|
||||
let sha = args[1].clone();
|
||||
let title = (!args[2..].is_empty()).then(|| args[2..].join(" "));
|
||||
ReviewTarget::Commit { sha, title }
|
||||
}
|
||||
"commit" => {
|
||||
return Err(REVIEW_USAGE.to_string());
|
||||
}
|
||||
_ => ReviewTarget::Custom {
|
||||
instructions: trimmed.to_string(),
|
||||
instructions: args.join(" "),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
ReviewTarget::Custom {
|
||||
instructions: trimmed.to_string(),
|
||||
}
|
||||
return Err(REVIEW_USAGE.to_string());
|
||||
};
|
||||
|
||||
Ok(ReviewRequest {
|
||||
@@ -5689,11 +5897,10 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn parse_model_selection_args(
|
||||
args: &str,
|
||||
args: &[String],
|
||||
) -> Result<(String, Option<ReasoningEffortConfig>, ModelSelectionScope), String> {
|
||||
const MODEL_USAGE: &str = "Usage: /model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]";
|
||||
let mut tokens = args.split_whitespace();
|
||||
let Some(model) = tokens.next() else {
|
||||
let Some(model) = args.first() else {
|
||||
return Err(MODEL_USAGE.to_string());
|
||||
};
|
||||
|
||||
@@ -5701,7 +5908,7 @@ impl ChatWidget {
|
||||
let mut saw_effort = false;
|
||||
let mut scope = ModelSelectionScope::Global;
|
||||
let mut saw_scope = false;
|
||||
for token in tokens {
|
||||
for token in &args[1..] {
|
||||
if let Some(parsed_effort) = Self::parse_model_reasoning_effort_token(token) {
|
||||
if saw_effort {
|
||||
return Err(MODEL_USAGE.to_string());
|
||||
@@ -5721,7 +5928,7 @@ impl ChatWidget {
|
||||
return Err(MODEL_USAGE.to_string());
|
||||
}
|
||||
|
||||
Ok((model.to_string(), effort, scope))
|
||||
Ok((model.clone(), effort, scope))
|
||||
}
|
||||
|
||||
fn parse_model_reasoning_effort_token(token: &str) -> Option<Option<ReasoningEffortConfig>> {
|
||||
@@ -7307,12 +7514,11 @@ impl ChatWidget {
|
||||
let description = Some(Self::personality_description(personality).to_string());
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
Self::plain_slash_command_draft(
|
||||
SlashCommandInvocation::with_args(
|
||||
SlashCommand::Personality,
|
||||
Self::personality_label(personality)
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
),
|
||||
[Self::personality_label(personality).to_ascii_lowercase()],
|
||||
)
|
||||
.into_user_message(),
|
||||
));
|
||||
})];
|
||||
SelectionItem {
|
||||
@@ -7703,10 +7909,11 @@ impl ChatWidget {
|
||||
let command_name = name.to_ascii_lowercase();
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
Self::plain_slash_command_draft(
|
||||
SlashCommandInvocation::with_args(
|
||||
SlashCommand::Collab,
|
||||
command_name.as_str(),
|
||||
),
|
||||
[command_name.clone()],
|
||||
)
|
||||
.into_user_message(),
|
||||
));
|
||||
})];
|
||||
SelectionItem {
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::skills_helpers::skill_description;
|
||||
use crate::skills_helpers::skill_display_name;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use codex_chatgpt::connectors::AppInfo;
|
||||
use codex_core::connectors::connector_mention_slug;
|
||||
use codex_core::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
@@ -36,7 +37,8 @@ impl ChatWidget {
|
||||
description: Some("Tip: press $ to open this list directly.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
format!("/{} list", SlashCommand::Skills.command()).into(),
|
||||
SlashCommandInvocation::with_args(SlashCommand::Skills, ["list"])
|
||||
.into_user_message(),
|
||||
));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
@@ -47,7 +49,8 @@ impl ChatWidget {
|
||||
description: Some("Enable or disable skills.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
format!("/{} manage", SlashCommand::Skills.command()).into(),
|
||||
SlashCommandInvocation::with_args(SlashCommand::Skills, ["manage"])
|
||||
.into_user_message(),
|
||||
));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::bottom_pane::FeedbackAudience;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::bottom_pane::MentionBinding;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
use assert_matches::assert_matches;
|
||||
@@ -6421,7 +6422,15 @@ async fn custom_prompt_submit_serializes_review_draft() {
|
||||
let evt = rx.try_recv().expect("expected one app event");
|
||||
match evt {
|
||||
AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) => {
|
||||
assert_eq!(text, "/review -- please audit dependencies");
|
||||
assert_eq!(
|
||||
text,
|
||||
SlashCommandInvocation::with_args(
|
||||
SlashCommand::Review,
|
||||
["please audit dependencies"],
|
||||
)
|
||||
.into_user_message()
|
||||
.text
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected app event: {other:?}"),
|
||||
}
|
||||
@@ -8092,7 +8101,11 @@ async fn single_reasoning_option_skips_selection() {
|
||||
|
||||
#[test]
|
||||
fn model_parser_rejects_repeated_effort_when_first_token_is_default() {
|
||||
let parsed = ChatWidget::parse_model_selection_args("gpt-5 default high");
|
||||
let parsed = ChatWidget::parse_model_selection_args(&[
|
||||
"gpt-5".to_string(),
|
||||
"default".to_string(),
|
||||
"high".to_string(),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
@@ -8105,7 +8118,11 @@ fn model_parser_rejects_repeated_effort_when_first_token_is_default() {
|
||||
|
||||
#[test]
|
||||
fn model_parser_rejects_repeated_scope_when_first_token_is_global() {
|
||||
let parsed = ChatWidget::parse_model_selection_args("gpt-5 global plan-only");
|
||||
let parsed = ChatWidget::parse_model_selection_args(&[
|
||||
"gpt-5".to_string(),
|
||||
"global".to_string(),
|
||||
"plan-only".to_string(),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
@@ -11843,7 +11860,14 @@ async fn queued_custom_review_selection_preserves_branch_like_instructions_after
|
||||
|
||||
assert_eq!(
|
||||
chat.queued_user_message_texts(),
|
||||
vec!["/review -- branch main but focus on risky migrations".to_string()]
|
||||
vec![
|
||||
SlashCommandInvocation::with_args(
|
||||
SlashCommand::Review,
|
||||
["branch main but focus on risky migrations"],
|
||||
)
|
||||
.into_user_message()
|
||||
.text,
|
||||
]
|
||||
);
|
||||
|
||||
chat.on_task_complete(None, false);
|
||||
@@ -11884,7 +11908,14 @@ async fn queued_commit_review_selection_preserves_title_after_turn_complete() {
|
||||
|
||||
assert_eq!(
|
||||
chat.queued_user_message_texts(),
|
||||
vec!["/review commit abc123 Preserve commit subject".to_string()]
|
||||
vec![
|
||||
SlashCommandInvocation::with_args(
|
||||
SlashCommand::Review,
|
||||
["commit", "abc123", "Preserve commit subject"],
|
||||
)
|
||||
.into_user_message()
|
||||
.text,
|
||||
]
|
||||
);
|
||||
|
||||
chat.on_task_complete(None, false);
|
||||
|
||||
@@ -113,6 +113,7 @@ mod session_log;
|
||||
mod shimmer;
|
||||
mod skills_helpers;
|
||||
mod slash_command;
|
||||
mod slash_command_invocation;
|
||||
mod status;
|
||||
mod status_indicator_widget;
|
||||
mod streaming;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SlashCommandInvocation {
|
||||
pub(crate) command: SlashCommand,
|
||||
pub(crate) args: Vec<String>,
|
||||
}
|
||||
|
||||
impl SlashCommandInvocation {
|
||||
pub(crate) fn bare(command: SlashCommand) -> Self {
|
||||
Self {
|
||||
command,
|
||||
args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_args<I, S>(command: SlashCommand, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
Self {
|
||||
command,
|
||||
args: args.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_args(args: &str, usage: &str) -> Result<Vec<String>, String> {
|
||||
shlex::split(args).ok_or_else(|| usage.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn into_user_message(self) -> UserMessage {
|
||||
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}").into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn serializes_quoted_args() {
|
||||
let draft = SlashCommandInvocation::with_args(
|
||||
SlashCommand::Review,
|
||||
["branch main needs coverage".to_string()],
|
||||
)
|
||||
.into_user_message();
|
||||
|
||||
assert_eq!(
|
||||
draft,
|
||||
UserMessage::from("/review 'branch main needs coverage'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_shlex_args() {
|
||||
let parsed = SlashCommandInvocation::parse_args("'branch main' --flag key=value", "usage")
|
||||
.expect("quoted args should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
"branch main".to_string(),
|
||||
"--flag".to_string(),
|
||||
"key=value".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ use crate::diff_render::push_wrapped_diff_line_with_syntax_and_style_context;
|
||||
use crate::render::highlight;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use crate::status::format_directory_display;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -346,7 +347,11 @@ pub(crate) fn build_theme_picker_params(
|
||||
search_value: Some(entry.name.clone()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
format!("/{} {}", SlashCommand::Theme.command(), name_for_action).into(),
|
||||
SlashCommandInvocation::with_args(
|
||||
SlashCommand::Theme,
|
||||
[name_for_action.clone()],
|
||||
)
|
||||
.into_user_message(),
|
||||
));
|
||||
})],
|
||||
..Default::default()
|
||||
|
||||
Reference in New Issue
Block a user