Remove text element from slash command if text added before

This commit is contained in:
Charles Cunningham
2026-02-01 01:20:56 -08:00
parent 7e8064e7b6
commit 81a82b7e21
2 changed files with 109 additions and 36 deletions

View File

@@ -167,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;
@@ -1240,16 +1241,6 @@ impl ChatComposer {
}
self.textarea.input(input);
if let KeyEvent {
code: KeyCode::Char(' '),
modifiers,
kind: KeyEventKind::Press,
..
} = input
&& !has_ctrl_or_alt(modifiers)
{
self.mark_slash_command_element_on_space();
}
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
@@ -2245,9 +2236,6 @@ impl ChatComposer {
// pending fast char flushes as normal typed input.
self.textarea.insert_str(ch.to_string().as_str());
self.sync_popups();
if ch == ' ' {
self.mark_slash_command_element_on_space();
}
true
}
FlushResult::None => false,
@@ -2530,6 +2518,7 @@ impl ChatComposer {
}
fn sync_popups(&mut self) {
self.sync_slash_command_elements();
if !self.popups_enabled() {
self.active_popup = ActivePopup::None;
return;
@@ -2596,22 +2585,51 @@ impl ChatComposer {
}
}
/// Convert a completed `/command` token on the first line into a text element.
///
/// This keeps typed commands visually distinct and edits atomic once the
/// user finishes the command name and hits space.
fn mark_slash_command_element_on_space(&mut self) {
/// 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 Some((name, _rest, _rest_offset)) = parse_slash_name(first_line) else {
return;
};
let desired_range = self.slash_command_element_range(first_line);
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 !self.is_known_slash_payload(payload) {
continue;
}
let range = elem.byte_range.start..elem.byte_range.end;
if desired_range
.as_ref()
.is_some_and(|desired| desired.start == range.start && desired.end == range.end)
{
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;
return None;
}
let element_end = 1 + name.len();
let has_space_after = first_line
@@ -2619,8 +2637,26 @@ impl ChatComposer {
.and_then(|tail| tail.chars().next())
.is_some_and(char::is_whitespace);
if !has_space_after {
return;
return None;
}
if self.is_known_slash_name(name) {
Some(0..element_end)
} else {
None
}
}
fn is_known_slash_payload(&self, payload: &str) -> bool {
let Some(name) = payload.strip_prefix('/') else {
return false;
};
if name.is_empty() || name.contains('/') || name.chars().any(char::is_whitespace) {
return false;
}
self.is_known_slash_name(name)
}
fn is_known_slash_name(&self, name: &str) -> bool {
let is_builtin = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
@@ -2629,21 +2665,18 @@ impl ChatComposer {
self.windows_degraded_sandbox_active,
)
.is_some();
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_prompt = name
.strip_prefix(&prompt_prefix)
.map(|prompt_name| {
self.custom_prompts
.iter()
.any(|prompt| prompt.name == prompt_name)
})
.unwrap_or(false);
if !is_builtin && !is_prompt {
return;
if is_builtin {
return true;
}
if element_end <= first_line.len() {
self.textarea.add_element_range(0..element_end);
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,
@@ -4922,6 +4955,34 @@ mod tests {
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;

View File

@@ -872,6 +872,18 @@ impl TextArea {
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);