mirror of
https://github.com/openai/codex.git
synced 2026-05-23 12:34:25 +00:00
move slash input
This commit is contained in:
@@ -160,8 +160,6 @@ use super::chat_composer_history::ChatComposerHistory;
|
||||
use super::chat_composer_history::HistoryEntry;
|
||||
use super::chat_composer_history::HistoryEntryResponse;
|
||||
use super::command_popup::CommandItem;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::command_popup::CommandPopupFlags;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use super::footer::CollaborationModeIndicator;
|
||||
use super::footer::FooterKeyHints;
|
||||
@@ -197,10 +195,7 @@ use super::skill_popup::SkillPopup;
|
||||
use super::slash_commands::BuiltinCommandFlags;
|
||||
use super::slash_commands::ServiceTierCommand;
|
||||
use super::slash_commands::SlashCommandItem;
|
||||
use super::slash_commands::find_slash_command;
|
||||
use super::slash_commands::has_slash_command_prefix;
|
||||
use crate::bottom_pane::paste_burst::FlushResult;
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::EditorKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
@@ -222,6 +217,7 @@ mod draft_state;
|
||||
mod footer_state;
|
||||
mod history_search;
|
||||
mod popup_state;
|
||||
mod slash_input;
|
||||
|
||||
use self::attachment_state::AttachmentState;
|
||||
use self::draft_state::ComposerMentionBinding;
|
||||
@@ -230,6 +226,9 @@ use self::footer_state::FooterState;
|
||||
use self::history_search::HistorySearchSession;
|
||||
use self::popup_state::ActivePopup;
|
||||
use self::popup_state::PopupState;
|
||||
use self::slash_input::SlashInput;
|
||||
use self::slash_input::SlashValidation;
|
||||
use self::slash_input::SubmissionValidation;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -411,12 +410,6 @@ pub(crate) struct ComposerDraftSnapshot {
|
||||
pub(crate) pending_pastes: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SlashValidation {
|
||||
Immediate,
|
||||
Deferred,
|
||||
}
|
||||
|
||||
const FOOTER_SPACING_HEIGHT: u16 = 0;
|
||||
|
||||
/// Builds the one-line nudge that replaces the ambient footer without adding layout height.
|
||||
@@ -433,6 +426,15 @@ fn plan_mode_nudge_line() -> Line<'static> {
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
fn slash_input(&self) -> SlashInput<'_> {
|
||||
SlashInput::new(
|
||||
self.slash_commands_enabled(),
|
||||
self.draft.is_bash_mode,
|
||||
self.builtin_command_flags(),
|
||||
&self.service_tier_commands,
|
||||
)
|
||||
}
|
||||
|
||||
fn builtin_command_flags(&self) -> BuiltinCommandFlags {
|
||||
BuiltinCommandFlags {
|
||||
collaboration_modes_enabled: self.collaboration_modes_enabled,
|
||||
@@ -1653,145 +1655,6 @@ impl ChatComposer {
|
||||
self.history_search.is_some() || self.popups.active()
|
||||
}
|
||||
|
||||
/// Handle key event when the slash-command popup is visible.
|
||||
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
if self.handle_shortcut_overlay_key(&key_event) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if key_event.code == KeyCode::Esc {
|
||||
let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running);
|
||||
if next_mode != self.footer.mode {
|
||||
self.footer.mode = next_mode;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
} else {
|
||||
self.footer.mode = reset_mode_after_activity(self.footer.mode);
|
||||
}
|
||||
let ActivePopup::Command(popup) = &mut self.popups.active else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
popup.move_up();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
popup.move_down();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
// Dismiss the slash popup; keep the current input untouched.
|
||||
self.popups.active = ActivePopup::None;
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Tab, ..
|
||||
} => {
|
||||
// Ensure popup filtering/selection reflects the latest composer text
|
||||
// before applying completion.
|
||||
let first_line = self.draft.textarea.text().lines().next().unwrap_or("");
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
if let Some(selected_cmd) = popup.selected_item() {
|
||||
let selected_command_text = format!("/{}", selected_cmd.command());
|
||||
if let CommandItem::Builtin(cmd) = selected_cmd
|
||||
&& cmd == SlashCommand::Skills
|
||||
{
|
||||
self.stage_selected_slash_command_history(&CommandItem::Builtin(cmd));
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
|
||||
let starts_with_cmd =
|
||||
first_line.trim_start().starts_with(&selected_command_text);
|
||||
if !starts_with_cmd {
|
||||
self.draft
|
||||
.textarea
|
||||
.set_text_clearing_elements(&format!("{selected_command_text} "));
|
||||
if !self.draft.textarea.text().is_empty() {
|
||||
self.draft
|
||||
.textarea
|
||||
.set_cursor(self.draft.textarea.text().len());
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
if self.is_task_running {
|
||||
return self.handle_submission(/*should_queue*/ true);
|
||||
}
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('/'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
// Treat "/" as accepting the highlighted command as text completion
|
||||
// while the slash-command popup is active.
|
||||
let first_line = self.draft.textarea.text().lines().next().unwrap_or("");
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
if let Some(selected_cmd) = popup.selected_item() {
|
||||
let selected_command_text = format!("/{}", selected_cmd.command());
|
||||
let starts_with_cmd =
|
||||
first_line.trim_start().starts_with(&selected_command_text);
|
||||
if !starts_with_cmd {
|
||||
self.draft
|
||||
.textarea
|
||||
.set_text_clearing_elements(&format!("{selected_command_text} "));
|
||||
self.draft.is_bash_mode = false;
|
||||
}
|
||||
if !self.draft.textarea.text().is_empty() {
|
||||
self.draft
|
||||
.textarea
|
||||
.set_cursor(self.draft.textarea.text().len());
|
||||
}
|
||||
}
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
self.stage_selected_slash_command_history(&sel);
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
return (
|
||||
match sel {
|
||||
CommandItem::Builtin(cmd) => InputResult::Command(cmd),
|
||||
CommandItem::ServiceTier(command) => {
|
||||
InputResult::ServiceTierCommand(command)
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
// Fallback to default newline handling if no command selected.
|
||||
self.handle_key_event_without_popup(key_event)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
|
||||
let mut p = pos.min(text.len());
|
||||
@@ -2721,37 +2584,27 @@ impl ChatComposer {
|
||||
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
|
||||
|
||||
if slash_validation == SlashValidation::Immediate
|
||||
&& self.slash_commands_enabled()
|
||||
&& let Some((name, _rest, _rest_offset)) = parse_slash_name(&text)
|
||||
&& let SubmissionValidation::UnknownCommand(name) = self
|
||||
.slash_input()
|
||||
.validate_submission(&text, input_starts_with_space)
|
||||
{
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_known = find_slash_command(
|
||||
name,
|
||||
self.builtin_command_flags(),
|
||||
&self.service_tier_commands,
|
||||
)
|
||||
.is_some();
|
||||
if !is_known {
|
||||
let message = format!(
|
||||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, /*hint*/ None),
|
||||
)));
|
||||
self.set_text_content_with_mention_bindings(
|
||||
original_input.clone(),
|
||||
original_text_elements,
|
||||
original_local_image_paths,
|
||||
original_mention_bindings,
|
||||
);
|
||||
self.draft
|
||||
.pending_pastes
|
||||
.clone_from(&original_pending_pastes);
|
||||
self.draft.textarea.set_cursor(original_input.len());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let message = format!(
|
||||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, /*hint*/ None),
|
||||
)));
|
||||
self.set_text_content_with_mention_bindings(
|
||||
original_input.clone(),
|
||||
original_text_elements,
|
||||
original_local_image_paths,
|
||||
original_mention_bindings,
|
||||
);
|
||||
self.draft
|
||||
.pending_pastes
|
||||
.clone_from(&original_pending_pastes);
|
||||
self.draft.textarea.set_cursor(original_input.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
let actual_chars = text.chars().count();
|
||||
@@ -2823,8 +2676,7 @@ impl ChatComposer {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
let raw_text = self.draft.textarea.text();
|
||||
let defer_slash_validation =
|
||||
self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text);
|
||||
let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text);
|
||||
if let Some((text, text_elements)) = self.prepare_submission_text_with_options(
|
||||
/*record_history*/ true,
|
||||
if defer_slash_validation {
|
||||
@@ -2833,7 +2685,7 @@ impl ChatComposer {
|
||||
SlashValidation::Immediate
|
||||
},
|
||||
) {
|
||||
let action = self.queued_input_action(&text, defer_slash_validation);
|
||||
let action = slash_input::queued_input_action(&text, defer_slash_validation);
|
||||
return (
|
||||
InputResult::Queued {
|
||||
text,
|
||||
@@ -2940,25 +2792,9 @@ impl ChatComposer {
|
||||
/// Check if the first line is a bare slash command (no args) and dispatch it.
|
||||
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
|
||||
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
|
||||
if !self.slash_commands_enabled() || self.draft.is_bash_mode {
|
||||
return None;
|
||||
}
|
||||
let text = self.draft.textarea.text();
|
||||
let first_line = text.lines().next().unwrap_or("");
|
||||
let (name, rest, _rest_offset) = parse_slash_name(first_line)?;
|
||||
if !rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let command = find_slash_command(
|
||||
name,
|
||||
self.builtin_command_flags(),
|
||||
&self.service_tier_commands,
|
||||
)?;
|
||||
if command.supports_inline_args()
|
||||
&& parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let command = self
|
||||
.slash_input()
|
||||
.bare_command(self.draft.textarea.text())?;
|
||||
if self.reject_slash_command_if_unavailable(&command) {
|
||||
self.stage_slash_command_history(&command);
|
||||
self.record_pending_slash_command_history();
|
||||
@@ -2976,28 +2812,9 @@ impl ChatComposer {
|
||||
/// Check if the input is a slash command with args (e.g., /review args) and dispatch it.
|
||||
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
|
||||
fn try_dispatch_slash_command_with_args(&mut self) -> Option<InputResult> {
|
||||
if !self.slash_commands_enabled() || self.draft.is_bash_mode {
|
||||
return None;
|
||||
}
|
||||
let text = self.draft.textarea.text().to_string();
|
||||
if text.starts_with(' ') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (name, rest, rest_offset) = parse_slash_name(&text)?;
|
||||
if rest.is_empty() || name.contains('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command = find_slash_command(
|
||||
name,
|
||||
self.builtin_command_flags(),
|
||||
&self.service_tier_commands,
|
||||
)?;
|
||||
|
||||
if !command.supports_inline_args() {
|
||||
return None;
|
||||
}
|
||||
let inline_command = self.slash_input().inline_command(&text)?;
|
||||
let command = inline_command.command;
|
||||
if self.reject_slash_command_if_unavailable(&command) {
|
||||
self.stage_slash_command_history(&command);
|
||||
self.record_pending_slash_command_history();
|
||||
@@ -3006,13 +2823,13 @@ impl ChatComposer {
|
||||
|
||||
self.stage_slash_command_history(&command);
|
||||
|
||||
let mut args_elements = Self::slash_command_args_elements(
|
||||
rest,
|
||||
rest_offset,
|
||||
let mut args_elements = slash_input::args_elements(
|
||||
inline_command.rest,
|
||||
inline_command.rest_offset,
|
||||
&self.draft.textarea.text_elements(),
|
||||
);
|
||||
let trimmed_rest = rest.trim();
|
||||
args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements);
|
||||
let trimmed_rest = inline_command.rest.trim();
|
||||
args_elements = Self::trim_text_elements(inline_command.rest, trimmed_rest, args_elements);
|
||||
let SlashCommandItem::Builtin(cmd) = command else {
|
||||
return None;
|
||||
};
|
||||
@@ -3038,12 +2855,9 @@ impl ChatComposer {
|
||||
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 (prepared_rest, prepared_rest_offset) = slash_input::prepared_args(&prepared_text)?;
|
||||
let mut args_elements =
|
||||
slash_input::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))
|
||||
@@ -3063,24 +2877,6 @@ impl ChatComposer {
|
||||
true
|
||||
}
|
||||
|
||||
fn should_parse_as_slash_on_dequeue_from_raw_text(&self, text: &str) -> bool {
|
||||
self.slash_commands_enabled() && !text.starts_with(' ') && text.trim().starts_with('/')
|
||||
}
|
||||
|
||||
fn queued_input_action(
|
||||
&self,
|
||||
prepared_text: &str,
|
||||
defer_slash_validation: bool,
|
||||
) -> QueuedInputAction {
|
||||
if defer_slash_validation && prepared_text.starts_with('/') {
|
||||
QueuedInputAction::ParseSlash
|
||||
} else if prepared_text.starts_with('!') {
|
||||
QueuedInputAction::RunShell
|
||||
} else {
|
||||
QueuedInputAction::Plain
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage the current slash-command text for later local recall.
|
||||
///
|
||||
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
|
||||
@@ -3120,34 +2916,6 @@ impl ChatComposer {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
fn handle_remote_image_selection_key(
|
||||
&mut self,
|
||||
key_event: &KeyEvent,
|
||||
@@ -3715,123 +3483,6 @@ 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.draft.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.draft.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.draft.textarea.remove_element_range(range);
|
||||
}
|
||||
|
||||
if let Some(range) = desired_range
|
||||
&& !has_desired
|
||||
{
|
||||
self.draft.textarea.add_element_range(range);
|
||||
}
|
||||
}
|
||||
|
||||
fn slash_command_element_range(&self, first_line: &str) -> Option<Range<usize>> {
|
||||
if self.draft.is_bash_mode {
|
||||
return None;
|
||||
}
|
||||
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 {
|
||||
find_slash_command(
|
||||
name,
|
||||
self.builtin_command_flags(),
|
||||
&self.service_tier_commands,
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> {
|
||||
if !first_line.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_start = 1usize;
|
||||
let name_end = first_line[name_start..]
|
||||
.find(char::is_whitespace)
|
||||
.map(|idx| name_start + idx)
|
||||
.unwrap_or_else(|| first_line.len());
|
||||
|
||||
if cursor > name_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = &first_line[name_start..name_end];
|
||||
let rest_start = first_line[name_end..]
|
||||
.find(|c: char| !c.is_whitespace())
|
||||
.map(|idx| name_end + idx)
|
||||
.unwrap_or(name_end);
|
||||
let rest = &first_line[rest_start..];
|
||||
|
||||
Some((name, rest))
|
||||
}
|
||||
|
||||
/// Heuristic for whether the typed slash command looks like a valid
|
||||
/// prefix for any known built-in command.
|
||||
/// Empty names only count when there is no extra content after the '/'.
|
||||
fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool {
|
||||
if !self.slash_commands_enabled() {
|
||||
return false;
|
||||
}
|
||||
if name.is_empty() {
|
||||
return rest_after_name.is_empty();
|
||||
}
|
||||
|
||||
has_slash_command_prefix(
|
||||
name,
|
||||
self.builtin_command_flags(),
|
||||
&self.service_tier_commands,
|
||||
)
|
||||
}
|
||||
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
@@ -3850,8 +3501,9 @@ impl ChatComposer {
|
||||
let caret_on_first_line = cursor <= first_line_end;
|
||||
|
||||
let is_editing_slash_command_name = caret_on_first_line
|
||||
&& Self::slash_command_under_cursor(first_line, cursor)
|
||||
.is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest));
|
||||
&& self
|
||||
.slash_input()
|
||||
.is_editing_command_name(first_line, cursor);
|
||||
|
||||
// If the cursor is currently positioned within an `@token`, prefer the
|
||||
// file-search popup over the slash popup so users can insert a file path
|
||||
@@ -3872,30 +3524,7 @@ impl ChatComposer {
|
||||
}
|
||||
_ => {
|
||||
if is_editing_slash_command_name {
|
||||
let collaboration_modes_enabled = self.collaboration_modes_enabled;
|
||||
let connectors_enabled = self.connectors_enabled;
|
||||
let plugins_command_enabled = self.plugins_command_enabled;
|
||||
let service_tier_commands_enabled = self.service_tier_commands_enabled;
|
||||
let goal_command_enabled = self.goal_command_enabled;
|
||||
let personality_command_enabled = self.personality_command_enabled;
|
||||
let realtime_conversation_enabled = self.realtime_conversation_enabled;
|
||||
let audio_device_selection_enabled = self.audio_device_selection_enabled;
|
||||
let mut command_popup = CommandPopup::new(
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled,
|
||||
connectors_enabled,
|
||||
plugins_command_enabled,
|
||||
service_tier_commands_enabled,
|
||||
goal_command_enabled,
|
||||
personality_command_enabled,
|
||||
realtime_conversation_enabled,
|
||||
audio_device_selection_enabled,
|
||||
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
|
||||
side_conversation_active: self.side_conversation_active,
|
||||
},
|
||||
self.service_tier_commands.clone(),
|
||||
);
|
||||
command_popup.on_composer_text_change(first_line.to_string());
|
||||
let command_popup = self.slash_input().command_popup(first_line);
|
||||
self.popups.active = ActivePopup::Command(command_popup);
|
||||
}
|
||||
}
|
||||
|
||||
452
codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs
Normal file
452
codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs
Normal file
@@ -0,0 +1,452 @@
|
||||
//! Slash-command input parsing, cursor detection, and completion helpers.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
use crate::bottom_pane::command_popup::CommandItem;
|
||||
use crate::bottom_pane::command_popup::CommandPopup;
|
||||
use crate::bottom_pane::command_popup::CommandPopupFlags;
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::bottom_pane::slash_commands::BuiltinCommandFlags;
|
||||
use crate::bottom_pane::slash_commands::ServiceTierCommand;
|
||||
use crate::bottom_pane::slash_commands::SlashCommandItem;
|
||||
use crate::bottom_pane::slash_commands::find_slash_command;
|
||||
use crate::bottom_pane::slash_commands::has_slash_command_prefix;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
|
||||
use super::super::footer::esc_hint_mode;
|
||||
use super::super::footer::reset_mode_after_activity;
|
||||
use super::ActivePopup;
|
||||
use super::ChatComposer;
|
||||
use super::InputResult;
|
||||
use super::QueuedInputAction;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SlashValidation {
|
||||
Immediate,
|
||||
Deferred,
|
||||
}
|
||||
|
||||
pub(super) enum SubmissionValidation {
|
||||
Valid,
|
||||
UnknownCommand(String),
|
||||
}
|
||||
|
||||
pub(super) struct InlineCommand<'a> {
|
||||
pub(super) command: SlashCommandItem,
|
||||
pub(super) rest: &'a str,
|
||||
pub(super) rest_offset: usize,
|
||||
}
|
||||
|
||||
pub(super) struct SlashInput<'a> {
|
||||
enabled: bool,
|
||||
is_bash_mode: bool,
|
||||
command_flags: BuiltinCommandFlags,
|
||||
service_tier_commands: &'a [ServiceTierCommand],
|
||||
}
|
||||
|
||||
impl<'a> SlashInput<'a> {
|
||||
pub(super) fn new(
|
||||
enabled: bool,
|
||||
is_bash_mode: bool,
|
||||
command_flags: BuiltinCommandFlags,
|
||||
service_tier_commands: &'a [ServiceTierCommand],
|
||||
) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
is_bash_mode,
|
||||
command_flags,
|
||||
service_tier_commands,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_submission(
|
||||
&self,
|
||||
text: &str,
|
||||
input_starts_with_space: bool,
|
||||
) -> SubmissionValidation {
|
||||
if !self.enabled {
|
||||
return SubmissionValidation::Valid;
|
||||
}
|
||||
let Some((name, _rest, _rest_offset)) = parse_slash_name(text) else {
|
||||
return SubmissionValidation::Valid;
|
||||
};
|
||||
if input_starts_with_space || name.contains('/') {
|
||||
return SubmissionValidation::Valid;
|
||||
}
|
||||
if self.command(name).is_some() {
|
||||
SubmissionValidation::Valid
|
||||
} else {
|
||||
SubmissionValidation::UnknownCommand(name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bare_command(&self, text: &str) -> Option<SlashCommandItem> {
|
||||
if !self.enabled || self.is_bash_mode {
|
||||
return None;
|
||||
}
|
||||
let first_line = text.lines().next().unwrap_or("");
|
||||
let (name, rest, _rest_offset) = parse_slash_name(first_line)?;
|
||||
if !rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let command = self.command(name)?;
|
||||
if command.supports_inline_args()
|
||||
&& parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(command)
|
||||
}
|
||||
|
||||
pub(super) fn inline_command<'text>(&self, text: &'text str) -> Option<InlineCommand<'text>> {
|
||||
if !self.enabled || self.is_bash_mode || text.starts_with(' ') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (name, rest, rest_offset) = parse_slash_name(text)?;
|
||||
if rest.is_empty() || name.contains('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command = self.command(name)?;
|
||||
command.supports_inline_args().then_some(InlineCommand {
|
||||
command,
|
||||
rest,
|
||||
rest_offset,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn should_parse_on_dequeue(&self, text: &str) -> bool {
|
||||
self.enabled && !text.starts_with(' ') && text.trim().starts_with('/')
|
||||
}
|
||||
|
||||
pub(super) fn command_element_range(&self, first_line: &str) -> Option<Range<usize>> {
|
||||
if self.is_bash_mode {
|
||||
return None;
|
||||
}
|
||||
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;
|
||||
}
|
||||
self.command(name).is_some().then_some(0..element_end)
|
||||
}
|
||||
|
||||
pub(super) fn is_editing_command_name(&self, first_line: &str, cursor: usize) -> bool {
|
||||
let Some((name, rest)) = command_under_cursor(first_line, cursor) else {
|
||||
return false;
|
||||
};
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
if name.is_empty() {
|
||||
return rest.is_empty();
|
||||
}
|
||||
|
||||
has_slash_command_prefix(name, self.command_flags, self.service_tier_commands)
|
||||
}
|
||||
|
||||
pub(super) fn command_popup(&self, first_line: &str) -> CommandPopup {
|
||||
let mut command_popup = CommandPopup::new(
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: self.command_flags.collaboration_modes_enabled,
|
||||
connectors_enabled: self.command_flags.connectors_enabled,
|
||||
plugins_command_enabled: self.command_flags.plugins_command_enabled,
|
||||
service_tier_commands_enabled: self.command_flags.service_tier_commands_enabled,
|
||||
goal_command_enabled: self.command_flags.goal_command_enabled,
|
||||
personality_command_enabled: self.command_flags.personality_command_enabled,
|
||||
realtime_conversation_enabled: self.command_flags.realtime_conversation_enabled,
|
||||
audio_device_selection_enabled: self.command_flags.audio_device_selection_enabled,
|
||||
windows_degraded_sandbox_active: self.command_flags.allow_elevate_sandbox,
|
||||
side_conversation_active: self.command_flags.side_conversation_active,
|
||||
},
|
||||
self.service_tier_commands.to_vec(),
|
||||
);
|
||||
command_popup.on_composer_text_change(first_line.to_string());
|
||||
command_popup
|
||||
}
|
||||
|
||||
fn command(&self, name: &str) -> Option<SlashCommandItem> {
|
||||
find_slash_command(name, self.command_flags, self.service_tier_commands)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn queued_input_action(
|
||||
prepared_text: &str,
|
||||
defer_slash_validation: bool,
|
||||
) -> QueuedInputAction {
|
||||
if defer_slash_validation && prepared_text.starts_with('/') {
|
||||
QueuedInputAction::ParseSlash
|
||||
} else if prepared_text.starts_with('!') {
|
||||
QueuedInputAction::RunShell
|
||||
} else {
|
||||
QueuedInputAction::Plain
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
/// Handle key event when the slash-command popup is visible.
|
||||
pub(super) fn handle_key_event_with_slash_popup(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
) -> (InputResult, bool) {
|
||||
if self.handle_shortcut_overlay_key(&key_event) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if key_event.code == KeyCode::Esc {
|
||||
let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running);
|
||||
if next_mode != self.footer.mode {
|
||||
self.footer.mode = next_mode;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
} else {
|
||||
self.footer.mode = reset_mode_after_activity(self.footer.mode);
|
||||
}
|
||||
let ActivePopup::Command(popup) = &mut self.popups.active else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
popup.move_up();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
popup.move_down();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
// Dismiss the slash popup; keep the current input untouched.
|
||||
self.popups.active = ActivePopup::None;
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Tab, ..
|
||||
} => {
|
||||
// Ensure popup filtering/selection reflects the latest composer text
|
||||
// before applying completion.
|
||||
let first_line = self.draft.textarea.text().lines().next().unwrap_or("");
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
if let Some(selected_cmd) = popup.selected_item() {
|
||||
if selected_command_dispatches_immediately_on_tab(&selected_cmd)
|
||||
&& let CommandItem::Builtin(cmd) = &selected_cmd
|
||||
{
|
||||
self.stage_selected_slash_command_history(&selected_cmd);
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
return (InputResult::Command(*cmd), true);
|
||||
}
|
||||
|
||||
if let Some(completed_text) =
|
||||
selected_command_completion(first_line, &selected_cmd)
|
||||
{
|
||||
self.draft
|
||||
.textarea
|
||||
.set_text_clearing_elements(&completed_text);
|
||||
if !self.draft.textarea.text().is_empty() {
|
||||
self.draft
|
||||
.textarea
|
||||
.set_cursor(self.draft.textarea.text().len());
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
if self.is_task_running {
|
||||
return self.handle_submission(/*should_queue*/ true);
|
||||
}
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('/'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
// Treat "/" as accepting the highlighted command as text completion
|
||||
// while the slash-command popup is active.
|
||||
let first_line = self.draft.textarea.text().lines().next().unwrap_or("");
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
if let Some(selected_cmd) = popup.selected_item() {
|
||||
if let Some(completed_text) =
|
||||
selected_command_completion(first_line, &selected_cmd)
|
||||
{
|
||||
self.draft
|
||||
.textarea
|
||||
.set_text_clearing_elements(&completed_text);
|
||||
self.draft.is_bash_mode = false;
|
||||
}
|
||||
if !self.draft.textarea.text().is_empty() {
|
||||
self.draft
|
||||
.textarea
|
||||
.set_cursor(self.draft.textarea.text().len());
|
||||
}
|
||||
}
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
self.stage_selected_slash_command_history(&sel);
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
return (
|
||||
match sel {
|
||||
CommandItem::Builtin(cmd) => InputResult::Command(cmd),
|
||||
CommandItem::ServiceTier(command) => {
|
||||
InputResult::ServiceTierCommand(command)
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
// Fallback to default newline handling if no command selected.
|
||||
self.handle_key_event_without_popup(key_event)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep slash command elements aligned with the current first line.
|
||||
pub(super) fn sync_slash_command_elements(&mut self) {
|
||||
if !self.slash_commands_enabled() {
|
||||
return;
|
||||
}
|
||||
let text = self.draft.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_input().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.draft.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.draft.textarea.remove_element_range(range);
|
||||
}
|
||||
|
||||
if let Some(range) = desired_range
|
||||
&& !has_desired
|
||||
{
|
||||
self.draft.textarea.add_element_range(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn selected_command_dispatches_immediately_on_tab(command: &CommandItem) -> bool {
|
||||
matches!(command, CommandItem::Builtin(SlashCommand::Skills))
|
||||
}
|
||||
|
||||
pub(super) fn selected_command_completion(
|
||||
first_line: &str,
|
||||
command: &CommandItem,
|
||||
) -> Option<String> {
|
||||
let selected_command_text = format!("/{}", command.command());
|
||||
(!first_line.trim_start().starts_with(&selected_command_text))
|
||||
.then(|| format!("{selected_command_text} "))
|
||||
}
|
||||
|
||||
pub(super) fn prepared_args(prepared_text: &str) -> Option<(&str, usize)> {
|
||||
let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(prepared_text)?;
|
||||
Some((prepared_rest, prepared_rest_offset))
|
||||
}
|
||||
|
||||
/// Translate full-text element ranges into command-argument ranges.
|
||||
///
|
||||
/// `rest_offset` is the byte offset where `rest` begins in the full text.
|
||||
pub(super) fn 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()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> {
|
||||
if !first_line.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_start = 1usize;
|
||||
let name_end = first_line[name_start..]
|
||||
.find(char::is_whitespace)
|
||||
.map(|idx| name_start + idx)
|
||||
.unwrap_or_else(|| first_line.len());
|
||||
|
||||
if cursor > name_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = &first_line[name_start..name_end];
|
||||
let rest_start = first_line[name_end..]
|
||||
.find(|c: char| !c.is_whitespace())
|
||||
.map(|idx| name_end + idx)
|
||||
.unwrap_or(name_end);
|
||||
let rest = &first_line[rest_start..];
|
||||
|
||||
Some((name, rest))
|
||||
}
|
||||
Reference in New Issue
Block a user