mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user