Nicer highlighting of slash commands, /plan accepts prompt args and pasted images (#10269)

## Summary
- Make typed slash commands become text elements when the user hits
space, including paste‑burst spaces.
- Enable `/plan` to accept inline args and submit them in plan mode,
mirroring `/review` behavior and blocking submission while a task is
running.
- Preserve text elements/attachments for slash commands that take args.

<img width="1510" height="500" alt="image"
src="https://github.com/user-attachments/assets/446024df-b69a-4249-85db-1a85110e07f1"
/>

## Changes
- Add safe helper to insert element ranges in the textarea.
- Extend command‑with‑args pipeline to carry text elements and reuse
submission prep.
- Update `/plan` dispatch to switch to plan mode then submit prompt +
elements.
- Document new composer behavior and add tests.

## Notes
- `/plan` is blocked during active tasks (same as `/review`).
- Slash‑command elementization recognizes built‑ins and `/prompts:`
custom commands only.

## Codex author
`codex fork 019c16d3-4520-7bb0-9b9d-48720d40a8ab`
This commit is contained in:
Charley Cunningham
2026-02-02 09:53:29 -08:00
committed by GitHub
parent d1e71cd202
commit 3392c5af24
7 changed files with 517 additions and 36 deletions
+353 -28
View File
@@ -4,6 +4,7 @@
//!
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
//! - Promoting typed slash commands into atomic elements when the command name is completed.
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
@@ -36,6 +37,8 @@
//!
//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion
//! and attachment pruning, and clears pending paste state on success.
//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so
//! pasted content and text elements are preserved when extracting args.
//!
//! # Non-bracketed Paste Bursts
//!
@@ -164,6 +167,7 @@ use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::ops::Range;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
@@ -184,7 +188,7 @@ pub enum InputResult {
text_elements: Vec<TextElement>,
},
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
None,
}
@@ -747,6 +751,7 @@ impl ChatComposer {
/// Move the cursor to the end of the current text buffer.
pub(crate) fn move_cursor_to_end(&mut self) {
self.textarea.set_cursor(self.textarea.text().len());
self.sync_popups();
}
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
@@ -1235,6 +1240,7 @@ impl ChatComposer {
self.handle_paste(pasted);
}
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
@@ -1798,7 +1804,12 @@ impl ChatComposer {
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
/// On success, clears pending paste payloads because placeholders have been expanded.
fn prepare_submission_text(&mut self) -> Option<(String, Vec<TextElement>)> {
///
/// When `record_history` is true, the final submission is stored for ↑/↓ recall.
fn prepare_submission_text(
&mut self,
record_history: bool,
) -> Option<(String, Vec<TextElement>)> {
let mut text = self.textarea.text().to_string();
let original_input = text.clone();
let original_text_elements = self.textarea.text_elements();
@@ -1896,7 +1907,7 @@ impl ChatComposer {
if text.is_empty() && self.attached_images.is_empty() {
return None;
}
if !text.is_empty() || !self.attached_images.is_empty() {
if record_history && (!text.is_empty() || !self.attached_images.is_empty()) {
let local_image_paths = self
.attached_images
.iter()
@@ -1978,7 +1989,7 @@ impl ChatComposer {
return (result, true);
}
if let Some((text, text_elements)) = self.prepare_submission_text() {
if let Some((text, text_elements)) = self.prepare_submission_text(true) {
if should_queue {
(
InputResult::Queued {
@@ -2026,6 +2037,9 @@ impl ChatComposer {
self.windows_degraded_sandbox_active,
)
{
if self.reject_slash_command_if_unavailable(cmd) {
return Some(InputResult::None);
}
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
} else {
@@ -2039,28 +2053,104 @@ impl ChatComposer {
if !self.slash_commands_enabled() {
return None;
}
let original_input = self.textarea.text().to_string();
let input_starts_with_space = original_input.starts_with(' ');
if !input_starts_with_space {
let text = self.textarea.text().to_string();
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)
&& matches!(cmd, SlashCommand::Review | SlashCommand::Rename)
{
self.textarea.set_text_clearing_elements("");
return Some(InputResult::CommandWithArgs(cmd, rest.to_string()));
}
let text = self.textarea.text().to_string();
if text.starts_with(' ') {
return None;
}
None
let (name, rest, rest_offset) = parse_slash_name(&text)?;
if rest.is_empty() || name.contains('/') {
return None;
}
let cmd = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)?;
if !cmd.supports_inline_args() {
return None;
}
if self.reject_slash_command_if_unavailable(cmd) {
return Some(InputResult::None);
}
let mut args_elements =
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
let trimmed_rest = rest.trim();
args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements);
Some(InputResult::CommandWithArgs(
cmd,
trimmed_rest.to_string(),
args_elements,
))
}
/// Expand pending placeholders and extract normalized inline-command args.
///
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
/// not consume user input. Once a command is accepted, this helper performs the usual
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
/// full-text offsets to command-arg offsets.
pub(crate) fn prepare_inline_args_submission(
&mut self,
record_history: bool,
) -> Option<(String, Vec<TextElement>)> {
let (prepared_text, prepared_elements) = self.prepare_submission_text(record_history)?;
let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(&prepared_text)?;
let mut args_elements = Self::slash_command_args_elements(
prepared_rest,
prepared_rest_offset,
&prepared_elements,
);
let trimmed_rest = prepared_rest.trim();
args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements);
Some((trimmed_rest.to_string(), args_elements))
}
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
if !self.is_task_running || cmd.available_during_task() {
return false;
}
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(message),
)));
true
}
/// Translate full-text element ranges into command-argument ranges.
///
/// `rest_offset` is the byte offset where `rest` begins in the full text.
fn slash_command_args_elements(
rest: &str,
rest_offset: usize,
text_elements: &[TextElement],
) -> Vec<TextElement> {
if rest.is_empty() || text_elements.is_empty() {
return Vec::new();
}
text_elements
.iter()
.filter_map(|elem| {
if elem.byte_range.end <= rest_offset {
return None;
}
let start = elem.byte_range.start.saturating_sub(rest_offset);
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
if start >= rest.len() {
return None;
}
end = end.min(rest.len());
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
})
.collect()
}
/// Handle key event when no popup is visible.
@@ -2441,6 +2531,7 @@ impl ChatComposer {
}
fn sync_popups(&mut self) {
self.sync_slash_command_elements();
if !self.popups_enabled() {
self.active_popup = ActivePopup::None;
return;
@@ -2507,6 +2598,88 @@ impl ChatComposer {
}
}
/// Keep slash command elements aligned with the current first line.
fn sync_slash_command_elements(&mut self) {
if !self.slash_commands_enabled() {
return;
}
let text = self.textarea.text();
let first_line_end = text.find('\n').unwrap_or(text.len());
let first_line = &text[..first_line_end];
let desired_range = self.slash_command_element_range(first_line);
// Slash commands are only valid at byte 0 of the first line.
// Any slash-shaped element not matching the current desired prefix is stale.
let mut has_desired = false;
let mut stale_ranges = Vec::new();
for elem in self.textarea.text_elements() {
let Some(payload) = elem.placeholder(text) else {
continue;
};
if payload.strip_prefix('/').is_none() {
continue;
}
let range = elem.byte_range.start..elem.byte_range.end;
if desired_range.as_ref() == Some(&range) {
has_desired = true;
} else {
stale_ranges.push(range);
}
}
for range in stale_ranges {
self.textarea.remove_element_range(range);
}
if let Some(range) = desired_range
&& !has_desired
{
self.textarea.add_element_range(range);
}
}
fn slash_command_element_range(&self, first_line: &str) -> Option<Range<usize>> {
let (name, _rest, _rest_offset) = parse_slash_name(first_line)?;
if name.contains('/') {
return None;
}
let element_end = 1 + name.len();
let has_space_after = first_line
.get(element_end..)
.and_then(|tail| tail.chars().next())
.is_some_and(char::is_whitespace);
if !has_space_after {
return None;
}
if self.is_known_slash_name(name) {
Some(0..element_end)
} else {
None
}
}
fn is_known_slash_name(&self, name: &str) -> bool {
let is_builtin = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
if is_builtin {
return true;
}
if let Some(rest) = name.strip_prefix(PROMPTS_CMD_PREFIX)
&& let Some(prompt_name) = rest.strip_prefix(':')
{
return self
.custom_prompts
.iter()
.any(|prompt| prompt.name == prompt_name);
}
false
}
/// If the cursor is currently within a slash command on the first line,
/// extract the command name and the rest of the line after it.
/// Returns None if the cursor is outside a slash command.
@@ -4582,7 +4755,7 @@ mod tests {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "init");
}
InputResult::CommandWithArgs(_, _) => {
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::Submitted { text, .. } => {
@@ -4596,6 +4769,49 @@ mod tests {
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn slash_command_disabled_while_task_running_keeps_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running(true);
composer
.textarea
.set_text_clearing_elements("/review these changes");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!("/review these changes", composer.textarea.text());
let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains("disabled while a task is in progress"));
found_error = true;
break;
}
}
assert!(found_error, "expected error history cell to be sent");
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = extract_positional_args_for_prompt_line(
@@ -4683,7 +4899,7 @@ mod tests {
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
InputResult::CommandWithArgs(_, _) => {
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::Submitted { text, .. } => {
@@ -4697,6 +4913,77 @@ mod tests {
assert!(composer.textarea.is_empty());
}
#[test]
fn slash_command_elementizes_on_space() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_collaboration_modes_enabled(true);
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
let text = composer.textarea.text().to_string();
let elements = composer.textarea.text_elements();
assert_eq!(text, "/plan ");
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].placeholder(&text), Some("/plan"));
}
#[test]
fn slash_command_elementizes_only_known_commands() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_collaboration_modes_enabled(true);
type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']);
let text = composer.textarea.text().to_string();
let elements = composer.textarea.text_elements();
assert_eq!(text, "/Users ");
assert!(elements.is_empty());
}
#[test]
fn slash_command_element_removed_when_not_at_start() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
let text = composer.textarea.text().to_string();
let elements = composer.textarea.text_elements();
assert_eq!(text, "/review ");
assert_eq!(elements.len(), 1);
composer.textarea.set_cursor(0);
type_chars_humanlike(&mut composer, &['x']);
let text = composer.textarea.text().to_string();
let elements = composer.textarea.text_elements();
assert_eq!(text, "x/review ");
assert!(elements.is_empty());
}
#[test]
fn slash_mention_dispatches_command_and_inserts_at() {
use crossterm::event::KeyCode;
@@ -4722,7 +5009,7 @@ mod tests {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "mention");
}
InputResult::CommandWithArgs(_, _) => {
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::Submitted { text, .. } => {
@@ -4738,6 +5025,44 @@ mod tests {
assert_eq!(composer.textarea.text(), "@");
}
#[test]
fn slash_plan_args_preserve_text_elements() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_collaboration_modes_enabled(true);
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
let placeholder = local_image_label_text(1);
composer.attach_image(PathBuf::from("/tmp/plan.png"));
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
assert_eq!(cmd.command(), "plan");
assert_eq!(args, placeholder);
assert_eq!(text_elements.len(), 1);
assert_eq!(
text_elements[0].placeholder(&args),
Some(placeholder.as_str())
);
}
_ => panic!("expected CommandWithArgs for /plan with args"),
}
}
/// Behavior: multiple paste operations can coexist; placeholders should be expanded to their
/// original content on submission.
#[test]
+14
View File
@@ -218,6 +218,12 @@ impl BottomPane {
self.composer.take_mention_paths()
}
/// Clear pending attachments and mention paths e.g. when a slash command doesn't submit text.
pub(crate) fn drain_pending_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
let _ = self.take_mention_paths();
}
pub fn set_steer_enabled(&mut self, enabled: bool) {
self.composer.set_steer_enabled(enabled);
}
@@ -404,6 +410,7 @@ impl BottomPane {
) {
self.composer
.set_text_content(text, text_elements, local_image_paths);
self.composer.move_cursor_to_end();
self.request_redraw();
}
@@ -787,6 +794,13 @@ impl BottomPane {
.take_recent_submission_images_with_placeholders()
}
pub(crate) fn prepare_inline_args_submission(
&mut self,
record_history: bool,
) -> Option<(String, Vec<TextElement>)> {
self.composer.prepare_inline_args_submission(record_history)
}
fn as_renderable(&'_ self) -> RenderableItem<'_> {
if let Some(view) = self.active_view() {
RenderableItem::Borrowed(view)
+40
View File
@@ -844,6 +844,46 @@ impl TextArea {
self.set_cursor(end);
}
/// Mark an existing text range as an atomic element without changing the text.
///
/// This is used to convert already-typed tokens (like `/plan`) into elements
/// so they render and edit atomically. Overlapping or duplicate ranges are ignored.
pub fn add_element_range(&mut self, range: Range<usize>) {
let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
if start >= end {
return;
}
if self
.elements
.iter()
.any(|e| e.range.start == start && e.range.end == end)
{
return;
}
if self
.elements
.iter()
.any(|e| start < e.range.end && end > e.range.start)
{
return;
}
self.elements.push(TextElement { range: start..end });
self.elements.sort_by_key(|e| e.range.start);
}
pub fn remove_element_range(&mut self, range: Range<usize>) -> bool {
let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
if start >= end {
return false;
}
let len_before = self.elements.len();
self.elements
.retain(|elem| elem.range.start != start || elem.range.end != end);
len_before != self.elements.len()
}
fn add_element(&mut self, range: Range<usize>) {
let elem = TextElement { range };
self.elements.push(elem);
+52 -7
View File
@@ -2732,8 +2732,8 @@ impl ChatWidget {
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
}
InputResult::CommandWithArgs(cmd, args) => {
self.dispatch_command_with_args(cmd, args);
InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.dispatch_command_with_args(cmd, args, text_elements);
}
InputResult::None => {}
},
@@ -2783,6 +2783,7 @@ impl ChatWidget {
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.drain_pending_submission_state();
self.request_redraw();
return;
}
@@ -3019,7 +3020,16 @@ impl ChatWidget {
}
}
fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) {
fn dispatch_command_with_args(
&mut self,
cmd: SlashCommand,
args: String,
_text_elements: Vec<TextElement>,
) {
if !cmd.supports_inline_args() {
self.dispatch_command(cmd);
return;
}
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
@@ -3033,7 +3043,12 @@ impl ChatWidget {
let trimmed = args.trim();
match cmd {
SlashCommand::Rename if !trimmed.is_empty() => {
let Some(name) = codex_core::util::normalize_thread_name(trimmed) else {
let Some((prepared_args, _prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)
else {
return;
};
let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
};
@@ -3042,20 +3057,50 @@ impl ChatWidget {
self.request_redraw();
self.app_event_tx
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Collab | SlashCommand::Plan => {
let _ = trimmed;
SlashCommand::Plan if !trimmed.is_empty() => {
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(true)
else {
return;
};
let user_message = UserMessage {
text: prepared_args,
local_images: self
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements: prepared_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
SlashCommand::Review if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)
else {
return;
};
self.submit_op(Op::Review {
review_request: ReviewRequest {
target: ReviewTarget::Custom {
instructions: trimmed.to_string(),
instructions: prepared_args,
},
user_facing_hint: None,
},
});
self.bottom_pane.drain_pending_submission_state();
}
_ => self.dispatch_command(cmd),
}
+44
View File
@@ -2316,6 +2316,50 @@ async fn plan_slash_command_switches_to_plan_mode() {
assert_eq!(chat.current_collaboration_mode(), &initial);
}
#[tokio::test]
async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::CollaborationModes, true);
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: None,
};
chat.handle_codex_event(Event {
id: "configured".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.bottom_pane
.set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(items.len(), 1);
assert_eq!(
items[0],
UserInput::Text {
text: "build the plan".to_string(),
text_elements: Vec::new(),
}
);
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
}
#[tokio::test]
async fn collaboration_modes_defaults_to_code_on_startup() {
let codex_home = tempdir().expect("tempdir");
+9 -1
View File
@@ -87,6 +87,14 @@ impl SlashCommand {
self.into()
}
/// Whether this command supports inline args (for example `/review ...`).
pub fn supports_inline_args(self) -> bool {
matches!(
self,
SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan
)
}
/// Whether this command can be run while a task is in progress.
pub fn available_during_task(self) -> bool {
match self {
@@ -103,6 +111,7 @@ impl SlashCommand {
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::Plan
| SlashCommand::Logout => false,
SlashCommand::Diff
| SlashCommand::Rename
@@ -117,7 +126,6 @@ impl SlashCommand {
| SlashCommand::Exit => true,
SlashCommand::Rollout => true,
SlashCommand::TestApproval => true,
SlashCommand::Plan => true,
SlashCommand::Collab => true,
SlashCommand::Agent => true,
}
+5
View File
@@ -48,6 +48,8 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl
history navigation, etc).
- After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the
latest text + cursor.
- When a slash command name is completed and the user types a space, the `/command` token is
promoted into a text element so it renders distinctly and edits atomically.
### History navigation (↑/↓)
@@ -105,6 +107,9 @@ There are multiple submission paths, but they share the same core rules:
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
are no attachments.
The same preparation path is reused for slash commands with arguments (for example `/plan` and
`/review`) so pasted content and text elements are preserved when extracting args.
### Numeric auto-submit path
When the slash popup is open and the first line matches a numeric-only custom prompt with