mirror of
https://github.com/openai/codex.git
synced 2026-10-19 08:16:48 +00:00
Render slash command prefixes as composer prompt markers (#54742)
## What changed - Display the leading `/` in the prompt marker instead of the editable text, sharing prefix state with shell mode's `!`. - Preserve command prefixes and text element offsets across submission, queuing, history, and external editing. Keep prefixes literal in plain-text composers. - Track composer-owned slash command elements separately, omit them from exported draft elements, and refresh them when command availability changes. - Let backward deletion at the start of the text remove the prompt prefix while preserving selected-text deletion and Vim Replace recovery. ## Testing Add regression coverage for slash prompt cursor placement, prefix deletion, history search highlighting, dynamic command availability, and Vim Replace restoration, including Unicode text. Update rendering snapshots for the slash prompt marker. GitOrigin-RevId: 0da42da8e98bf7885f9b0ba2c8d5ad4f44d6847e
This commit is contained in:
committed by
copyberry
parent
82d958597f
commit
5009a99dfc
@@ -1586,9 +1586,10 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
|
||||
// A fresh composer click takes ownership away from the transcript selection.
|
||||
app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?;
|
||||
assert!(!app.transcript_view.has_active_interaction());
|
||||
app.chat_widget.apply_external_edit("/".into());
|
||||
app.chat_widget.apply_external_edit("/model".into());
|
||||
assert!(!app.chat_widget.no_modal_or_popup_active());
|
||||
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?);
|
||||
let event = mouse(Down(Left), x, y);
|
||||
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?);
|
||||
app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Drag(Left), x + 1, y))?;
|
||||
assert!(app.chat_widget.no_modal_or_popup_active());
|
||||
app.chat_widget.open_feature_enable_prompt(Feature::Collab);
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Transcript row 34: content behind the menu
|
||||
/mention mention a file
|
||||
/mcp list MCP tools; use /mcp verbose or /mcp login <name>
|
||||
|
||||
› /m
|
||||
/ m
|
||||
|
||||
gpt-test default · /tmp/project
|
||||
model high · fast
|
||||
|
||||
@@ -120,6 +120,12 @@ async fn unavailable_thread_routes_local_and_recovery_commands() -> Result<()> {
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
|
||||
)
|
||||
.await?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyCode::Backspace.into()),
|
||||
)
|
||||
.await?;
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer(draft.into());
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(KeyCode::Enter.into()))
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
//!
|
||||
//! Preset submenu hints borrow slots and reflow with the composer.
|
||||
//!
|
||||
//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab
|
||||
//! submit ordinary text without enabling shell mode.
|
||||
//! Enabled `!` and `/` command prefixes live in prompt state rather than the editable textarea;
|
||||
//! canonical draft APIs reconstruct them for submission, queuing, history, and external editing.
|
||||
//! Vim Replace keeps the overwritten leading grapheme recoverable until Backspace removes the
|
||||
//! absorbed prefix.
|
||||
//! The plain-text preset keeps both prefixes literal, so Enter and Tab submit ordinary text.
|
||||
//!
|
||||
//! # Mention Menus
|
||||
//!
|
||||
@@ -306,7 +309,6 @@ use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
@@ -401,6 +403,7 @@ mod warning_notice;
|
||||
use self::attachment_state::AttachmentState;
|
||||
use self::draft_state::ComposerMentionBinding;
|
||||
use self::draft_state::DraftState;
|
||||
use self::draft_state::PromptPrefix;
|
||||
pub(crate) use self::footer_state::CommandPopupPlacement;
|
||||
use self::footer_state::ComposerLayout;
|
||||
pub(crate) use self::footer_state::ComposerRenderOptions;
|
||||
@@ -444,6 +447,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
@@ -696,7 +700,7 @@ impl ChatComposer {
|
||||
fn slash_input(&self) -> SlashInput<'_> {
|
||||
SlashInput::new(
|
||||
self.slash_commands_enabled(),
|
||||
self.draft.is_bash_mode,
|
||||
self.draft.prompt_prefix == PromptPrefix::Shell,
|
||||
self.builtin_command_flags(),
|
||||
&self.service_tier_commands,
|
||||
)
|
||||
@@ -959,11 +963,13 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub fn set_plugins_command_enabled(&mut self, enabled: bool) {
|
||||
self.plugins_command_enabled = enabled;
|
||||
self.update_slash_command_catalog(|composer| composer.plugins_command_enabled = enabled);
|
||||
}
|
||||
|
||||
pub fn set_token_activity_command_enabled(&mut self, enabled: bool) {
|
||||
self.token_activity_command_enabled = enabled;
|
||||
self.update_slash_command_catalog(|composer| {
|
||||
composer.token_activity_command_enabled = enabled
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_mentions_v2_enabled(&mut self, enabled: bool) {
|
||||
@@ -1005,28 +1011,37 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
|
||||
self.collaboration_modes_enabled = enabled;
|
||||
self.update_slash_command_catalog(|composer| {
|
||||
composer.collaboration_modes_enabled = enabled
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_connectors_enabled(&mut self, enabled: bool) {
|
||||
self.connectors_enabled = enabled;
|
||||
self.update_slash_command_catalog(|composer| composer.connectors_enabled = enabled);
|
||||
}
|
||||
|
||||
pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) {
|
||||
self.service_tier_commands_enabled = enabled;
|
||||
self.update_slash_command_catalog(|composer| {
|
||||
composer.service_tier_commands_enabled = enabled
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_service_tier_commands(&mut self, commands: Vec<ServiceTierCommand>) {
|
||||
self.service_tier_commands = commands;
|
||||
self.sync_popups();
|
||||
self.update_slash_command_catalog(|composer| composer.service_tier_commands = commands);
|
||||
}
|
||||
|
||||
pub fn set_goal_command_enabled(&mut self, enabled: bool) {
|
||||
self.goal_command_enabled = enabled;
|
||||
self.update_slash_command_catalog(|composer| composer.goal_command_enabled = enabled);
|
||||
}
|
||||
|
||||
pub fn set_voice_command_enabled(&mut self, enabled: bool) {
|
||||
self.voice_command_enabled = enabled;
|
||||
self.update_slash_command_catalog(|composer| composer.voice_command_enabled = enabled);
|
||||
}
|
||||
|
||||
fn update_slash_command_catalog(&mut self, update: impl FnOnce(&mut Self)) {
|
||||
self.unmark_slash_command_element();
|
||||
update(self);
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
/// Refresh the event destination when a retained editor survives reconnection.
|
||||
@@ -1111,7 +1126,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub fn set_side_conversation_active(&mut self, active: bool) {
|
||||
self.side_conversation_active = active;
|
||||
self.update_slash_command_catalog(|composer| composer.side_conversation_active = active);
|
||||
}
|
||||
|
||||
/// Compatibility shim for tests that still toggle the removed steer mode flag.
|
||||
@@ -1131,7 +1146,9 @@ impl ChatComposer {
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) {
|
||||
self.windows_degraded_sandbox_active = enabled;
|
||||
self.update_slash_command_catalog(|composer| {
|
||||
composer.windows_degraded_sandbox_active = enabled
|
||||
});
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
@@ -1140,7 +1157,9 @@ impl ChatComposer {
|
||||
|
||||
/// Returns true if the composer currently contains no user-entered input.
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty()
|
||||
self.draft.textarea.is_empty()
|
||||
&& self.draft.prompt_prefix == PromptPrefix::None
|
||||
&& self.attachments.is_empty()
|
||||
}
|
||||
|
||||
/// Record local persistent-history metadata so the composer can navigate
|
||||
@@ -1549,7 +1568,7 @@ impl ChatComposer {
|
||||
self.vim_history = VimHistory::default();
|
||||
self.draft.textarea_state.get_mut().follow_cursor();
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.prompt_prefix = PromptPrefix::None;
|
||||
self.draft.pending_pastes.clear();
|
||||
self.draft.mention_bindings.clear();
|
||||
|
||||
@@ -1566,7 +1585,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub(crate) fn current_cursor(&self) -> usize {
|
||||
self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 }
|
||||
self.draft.textarea.cursor() + self.draft.prompt_prefix.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1575,7 +1594,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
fn history_navigation_cursor(&self) -> usize {
|
||||
if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 {
|
||||
if self.draft.prompt_prefix != PromptPrefix::None && self.draft.textarea.cursor() == 0 {
|
||||
0
|
||||
} else if self.draft.textarea.is_vim_normal_mode()
|
||||
&& !self.draft.textarea.text().is_empty()
|
||||
@@ -1588,22 +1607,27 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub(crate) fn set_current_cursor(&mut self, cursor: usize) {
|
||||
let visible_cursor = if self.draft.is_bash_mode {
|
||||
cursor.saturating_sub(1)
|
||||
} else {
|
||||
cursor
|
||||
};
|
||||
let visible_cursor = cursor.saturating_sub(self.draft.prompt_prefix.len());
|
||||
self.draft
|
||||
.textarea
|
||||
.set_cursor(visible_cursor.min(self.draft.textarea.text().len()));
|
||||
}
|
||||
|
||||
fn current_text_elements(&self) -> Vec<TextElement> {
|
||||
let shift = if self.draft.is_bash_mode { 1 } else { 0 };
|
||||
let shift = self.draft.prompt_prefix.len() as isize;
|
||||
let command_range = self
|
||||
.draft
|
||||
.slash_command_element_id
|
||||
.and_then(|id| self.draft.textarea.element_range(id));
|
||||
self.draft
|
||||
.textarea
|
||||
.text_elements()
|
||||
.into_iter()
|
||||
.filter(|element| {
|
||||
command_range.as_ref().is_none_or(|range| {
|
||||
element.byte_range.start != range.start || element.byte_range.end != range.end
|
||||
})
|
||||
})
|
||||
.filter_map(|element| Self::shift_text_element(element, shift))
|
||||
.collect()
|
||||
}
|
||||
@@ -1682,30 +1706,37 @@ impl ChatComposer {
|
||||
|
||||
/// Convert canonical composer text into the textarea's internal representation.
|
||||
///
|
||||
/// Shell mode stores the leading `!` as prompt state instead of editable text,
|
||||
/// Command modes store the leading `!` or `/` as prompt state instead of editable text,
|
||||
/// so full-buffer imports must absorb that prefix before rebuilding the textarea.
|
||||
fn imported_text_for_textarea(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
) -> (String, Vec<TextElement>) {
|
||||
if self.config.shell_commands_enabled
|
||||
&& let Some(stripped) = text.strip_prefix('!')
|
||||
{
|
||||
self.draft.is_bash_mode = true;
|
||||
let prefix = self.enabled_prompt_prefix_for_text(&text);
|
||||
self.draft.prompt_prefix = prefix;
|
||||
if prefix != PromptPrefix::None {
|
||||
(
|
||||
stripped.to_string(),
|
||||
text[prefix.len()..].to_string(),
|
||||
text_elements
|
||||
.into_iter()
|
||||
.filter_map(|element| Self::shift_text_element(element, /*shift*/ -1))
|
||||
.filter_map(|element| {
|
||||
Self::shift_text_element(element, -(prefix.len() as isize))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
self.draft.is_bash_mode = false;
|
||||
(text, text_elements)
|
||||
}
|
||||
}
|
||||
|
||||
fn set_canonical_text_clearing_elements(&mut self, text: String) {
|
||||
let (text, text_elements) = self.imported_text_for_textarea(text, Vec::new());
|
||||
self.draft
|
||||
.textarea
|
||||
.set_text_with_elements(&text, &text_elements);
|
||||
}
|
||||
|
||||
/// Flush buffered typing before clearing so cancellation preserves the complete draft in history.
|
||||
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
|
||||
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
|
||||
@@ -1736,11 +1767,11 @@ impl ChatComposer {
|
||||
|
||||
/// Get the current composer text.
|
||||
pub(crate) fn current_text(&self) -> String {
|
||||
if self.draft.is_bash_mode {
|
||||
format!("!{}", self.draft.textarea.text())
|
||||
} else {
|
||||
self.draft.textarea.text().to_string()
|
||||
}
|
||||
format!(
|
||||
"{}{}",
|
||||
self.draft.prompt_prefix.text(),
|
||||
self.draft.textarea.text()
|
||||
)
|
||||
}
|
||||
|
||||
/// Recall content at its history boundary, preserving pending pastes and omitting images
|
||||
@@ -2992,7 +3023,7 @@ impl ChatComposer {
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.draft.recent_submission_mention_bindings.clear();
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.prompt_prefix = PromptPrefix::None;
|
||||
|
||||
if pending_paste_handling == PendingPasteHandling::Expand
|
||||
&& !self.draft.pending_pastes.is_empty()
|
||||
@@ -3156,11 +3187,11 @@ impl ChatComposer {
|
||||
let original_input = self.current_text();
|
||||
let original_text_elements = self.current_text_elements();
|
||||
let original_pending_pastes = self.draft.pending_pastes.clone();
|
||||
let raw_text = self.draft.textarea.text();
|
||||
let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text);
|
||||
let defer_slash_validation =
|
||||
self.slash_input().should_parse_on_dequeue(&original_input);
|
||||
let preserve_pending_pastes = defer_slash_validation
|
||||
&& !self.draft.pending_pastes.is_empty()
|
||||
&& parse_slash_name(raw_text)
|
||||
&& parse_slash_name(&original_input)
|
||||
.is_some_and(|(name, _, _)| name == SlashCommand::Goal.command());
|
||||
let pending_pastes = if preserve_pending_pastes {
|
||||
self.draft.pending_pastes.clone()
|
||||
@@ -3296,9 +3327,7 @@ 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> {
|
||||
let command = self
|
||||
.slash_input()
|
||||
.bare_command(self.draft.textarea.text())?;
|
||||
let command = self.slash_input().bare_command(&self.current_text())?;
|
||||
if self.reject_slash_command_if_unavailable(&command) {
|
||||
self.stage_slash_command_history(&command);
|
||||
self.record_pending_slash_command_history();
|
||||
@@ -3308,7 +3337,7 @@ impl ChatComposer {
|
||||
if !matches!(command, SlashCommandItem::Builtin(cmd) if cmd.requires_dispatch_validation())
|
||||
{
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.prompt_prefix = PromptPrefix::None;
|
||||
}
|
||||
Some(match command {
|
||||
SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd),
|
||||
@@ -3319,7 +3348,7 @@ 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> {
|
||||
let text = self.draft.textarea.text().to_string();
|
||||
let text = self.current_text();
|
||||
let inline_command = self.slash_input().inline_command(&text)?;
|
||||
let command = inline_command.command;
|
||||
if self.reject_slash_command_if_unavailable(&command) {
|
||||
@@ -3333,7 +3362,7 @@ impl ChatComposer {
|
||||
let mut args_elements = slash_input::args_elements(
|
||||
inline_command.rest,
|
||||
inline_command.rest_offset,
|
||||
&self.draft.textarea.text_elements(),
|
||||
&self.current_text_elements(),
|
||||
);
|
||||
let trimmed_rest = inline_command.rest.trim();
|
||||
args_elements = Self::trim_text_elements(inline_command.rest, trimmed_rest, args_elements);
|
||||
@@ -3397,7 +3426,7 @@ impl ChatComposer {
|
||||
if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) {
|
||||
return;
|
||||
}
|
||||
self.stage_slash_command_history_text(self.draft.textarea.text().trim().to_string());
|
||||
self.stage_slash_command_history_text(self.current_text().trim().to_string());
|
||||
}
|
||||
|
||||
/// Stage a popup-selected command using its canonical command text.
|
||||
@@ -3419,7 +3448,7 @@ impl ChatComposer {
|
||||
fn stage_slash_command_history_text(&mut self, text: String) {
|
||||
self.pending_slash_command_history = Some(HistoryEntry {
|
||||
text,
|
||||
text_elements: self.draft.textarea.text_elements(),
|
||||
text_elements: self.current_text_elements(),
|
||||
local_image_paths: self.attachments.local_image_paths(),
|
||||
remote_image_urls: self.attachments.remote_image_urls(),
|
||||
mention_bindings: self.snapshot_mention_bindings(),
|
||||
@@ -3457,12 +3486,12 @@ impl ChatComposer {
|
||||
if self.handle_empty_prompt_shortcut(&key_event) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if self.draft.is_bash_mode && key_event.code == KeyCode::Esc {
|
||||
if self.draft.prompt_prefix == PromptPrefix::Shell && key_event.code == KeyCode::Esc {
|
||||
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
|
||||
self.apply_paste(pasted);
|
||||
}
|
||||
if self.draft.textarea.is_empty() {
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.prompt_prefix = PromptPrefix::None;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
@@ -3487,7 +3516,7 @@ impl ChatComposer {
|
||||
)
|
||||
{
|
||||
self.footer.mode = reset_mode_after_activity(self.footer.mode);
|
||||
self.draft.is_bash_mode = true;
|
||||
self.draft.prompt_prefix = PromptPrefix::Shell;
|
||||
self.draft.textarea.enter_vim_insert_mode();
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
@@ -3711,13 +3740,18 @@ impl ChatComposer {
|
||||
Some(self.draft.textarea.element_payloads())
|
||||
};
|
||||
|
||||
if self.draft.is_bash_mode
|
||||
if self.draft.prompt_prefix != PromptPrefix::None
|
||||
&& self.draft.textarea.vim_query().is_none()
|
||||
&& matches!(input.code, KeyCode::Backspace)
|
||||
&& self.draft.textarea.mouse_selection_range().is_none()
|
||||
&& self.draft.textarea.is_delete_backward_key(input)
|
||||
&& self.draft.textarea.cursor() == 0
|
||||
{
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
self.draft.is_bash_mode = false;
|
||||
if self.draft.textarea.is_vim_replace_mode() {
|
||||
self.draft.textarea.input(input);
|
||||
}
|
||||
self.draft.prompt_prefix = PromptPrefix::None;
|
||||
self.sync_popups();
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
@@ -3726,7 +3760,7 @@ impl ChatComposer {
|
||||
|
||||
self.begin_vim_edit(input);
|
||||
self.draft.textarea.input(input);
|
||||
self.sync_bash_mode_from_text();
|
||||
self.sync_prompt_prefix_from_text();
|
||||
|
||||
if let Some(elements_before) = elements_before {
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
@@ -3752,13 +3786,21 @@ impl ChatComposer {
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
fn sync_bash_mode_from_text(&mut self) {
|
||||
if self.config.shell_commands_enabled
|
||||
&& !self.draft.is_bash_mode
|
||||
&& self.draft.textarea.text().starts_with('!')
|
||||
{
|
||||
self.draft.textarea.replace_range(0..1, "");
|
||||
self.draft.is_bash_mode = true;
|
||||
fn sync_prompt_prefix_from_text(&mut self) {
|
||||
if self.draft.prompt_prefix == PromptPrefix::None {
|
||||
let prefix = self.enabled_prompt_prefix_for_text(self.draft.textarea.text());
|
||||
if prefix != PromptPrefix::None {
|
||||
self.draft.textarea.absorb_prompt_prefix(prefix.len());
|
||||
self.draft.prompt_prefix = prefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enabled_prompt_prefix_for_text(&self, text: &str) -> PromptPrefix {
|
||||
match text.graphemes(/*is_extended*/ true).next() {
|
||||
Some("!") if self.config.shell_commands_enabled => PromptPrefix::Shell,
|
||||
Some("/") if self.slash_commands_enabled() => PromptPrefix::Slash,
|
||||
_ => PromptPrefix::None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3876,7 +3918,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
let allow_command_popup = self.slash_commands_enabled()
|
||||
&& !self.draft.is_bash_mode
|
||||
&& self.draft.prompt_prefix != PromptPrefix::Shell
|
||||
&& file_token.is_none()
|
||||
&& mentions_v2_token.is_none()
|
||||
&& mention_target.is_none();
|
||||
@@ -3932,7 +3974,7 @@ impl ChatComposer {
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
fn sync_command_popup(&mut self, allow: bool) {
|
||||
let text = self.draft.textarea.text();
|
||||
let text = self.current_text();
|
||||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||||
let first_line = &text[..first_line_end];
|
||||
// Keep an explicitly dismissed popup closed until the command token changes.
|
||||
@@ -3951,7 +3993,7 @@ impl ChatComposer {
|
||||
return;
|
||||
}
|
||||
// Determine whether the caret is inside the initial '/name' token on the first line.
|
||||
let cursor = self.draft.textarea.cursor();
|
||||
let cursor = self.current_cursor();
|
||||
let caret_on_first_line = cursor <= first_line_end;
|
||||
|
||||
let is_editing_slash_command_name = caret_on_first_line
|
||||
@@ -4841,8 +4883,10 @@ impl ChatComposer {
|
||||
}
|
||||
if !textarea_rect.is_empty() {
|
||||
let prompt = if self.draft.input_enabled {
|
||||
if self.draft.is_bash_mode {
|
||||
Span::from("!").light_red().bold()
|
||||
if self.draft.prompt_prefix == PromptPrefix::Shell {
|
||||
"!".light_red().bold()
|
||||
} else if self.draft.prompt_prefix == PromptPrefix::Slash {
|
||||
"/".fg(crate::style::accent_color_on(style.bg)).bold()
|
||||
} else if self.luna_reserve_active {
|
||||
// Reserve keeps one arrow at every reasoning effort; only its foreground changes.
|
||||
"›"
|
||||
@@ -4873,7 +4917,8 @@ impl ChatComposer {
|
||||
if options.max_height.is_none() {
|
||||
state.follow_cursor();
|
||||
}
|
||||
let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode;
|
||||
let textarea_is_empty =
|
||||
self.draft.textarea.text().is_empty() && self.draft.prompt_prefix == PromptPrefix::None;
|
||||
if self.draft.input_enabled {
|
||||
if let Some(mask_char) = mask_char {
|
||||
self.draft
|
||||
@@ -4889,22 +4934,13 @@ impl ChatComposer {
|
||||
.chain(self.draft.textarea.vim_search_highlights())
|
||||
.map(|range| (range, search_highlight_style)),
|
||||
);
|
||||
if highlights.is_empty() {
|
||||
StatefulWidgetRef::render_ref(
|
||||
&(&self.draft.textarea),
|
||||
textarea_rect,
|
||||
buf,
|
||||
&mut state,
|
||||
);
|
||||
} else {
|
||||
self.draft.textarea.render_ref_styled_with_highlights(
|
||||
textarea_rect,
|
||||
buf,
|
||||
&mut state,
|
||||
Style::default(),
|
||||
&highlights,
|
||||
);
|
||||
}
|
||||
self.draft.textarea.render_ref_styled_with_highlights(
|
||||
textarea_rect,
|
||||
buf,
|
||||
&mut state,
|
||||
Style::default(),
|
||||
&highlights,
|
||||
);
|
||||
}
|
||||
}
|
||||
if !self.draft.input_enabled || textarea_is_empty {
|
||||
@@ -5413,6 +5449,46 @@ mod tests {
|
||||
assert_eq!(composer.cursor_pos(area), Some((6, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_cursor_uses_absorbed_prefix() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ true,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
let area = Rect::new(0, 0, 40, 5);
|
||||
|
||||
composer.set_text_content("/help".to_string(), Vec::new(), Vec::new());
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash);
|
||||
assert_eq!(composer.draft.textarea.text(), "help");
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
let before_command = composer.cursor_pos(area);
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 1);
|
||||
let after_first_character = composer.cursor_pos(area);
|
||||
|
||||
assert_eq!(
|
||||
(before_command, after_first_character),
|
||||
(Some((2, 1)), Some((3, 1)))
|
||||
);
|
||||
composer.set_text_content("/\u{301}help".to_string(), Vec::new(), Vec::new());
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::None);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
composer.render(area, &mut buffer);
|
||||
assert_eq!(buffer[(2, 1)].symbol(), "/\u{301}");
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
assert_eq!(
|
||||
(
|
||||
composer.cursor_pos(Rect::new(0, 0, 40, 1)),
|
||||
composer.cursor_pos(Rect::new(0, 0, 0, 5)),
|
||||
),
|
||||
(None, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_uses_shell_accent_style() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -5637,7 +5713,7 @@ mod tests {
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['!']);
|
||||
assert!(composer.draft.is_bash_mode);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell);
|
||||
assert_eq!(composer.current_text(), "!");
|
||||
|
||||
let (result, needs_redraw) =
|
||||
@@ -5645,10 +5721,38 @@ mod tests {
|
||||
|
||||
assert!(matches!(result, InputResult::None));
|
||||
assert!(needs_redraw);
|
||||
assert!(!composer.draft.is_bash_mode);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::None);
|
||||
assert_eq!(composer.current_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_removes_absorbed_prompt_prefixes() {
|
||||
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(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ true,
|
||||
);
|
||||
|
||||
for prefix in ["!", "/"] {
|
||||
composer.set_text_content(prefix.to_string(), Vec::new(), Vec::new());
|
||||
let (result, needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(result, InputResult::None);
|
||||
assert!(needs_redraw);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::None);
|
||||
assert_eq!(composer.current_text(), "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_keeps_shell_mode_when_paste_burst_flushes_pending_text() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -5675,7 +5779,7 @@ mod tests {
|
||||
|
||||
assert!(matches!(result, InputResult::None));
|
||||
assert!(needs_redraw);
|
||||
assert!(composer.draft.is_bash_mode);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell);
|
||||
assert_eq!(composer.current_text(), "!g");
|
||||
}
|
||||
|
||||
@@ -6033,8 +6137,11 @@ mod tests {
|
||||
|
||||
assert!(matches!(result, InputResult::None));
|
||||
assert!(needs_redraw);
|
||||
assert_eq!(composer.draft.textarea.text(), "/");
|
||||
assert_eq!(composer.draft.textarea.cursor(), "/".len());
|
||||
assert_eq!(composer.current_text(), "/");
|
||||
assert_eq!(composer.draft.textarea.text(), "");
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash);
|
||||
assert_eq!(composer.current_cursor(), "/".len());
|
||||
assert_eq!(composer.draft.textarea.cursor(), 0);
|
||||
assert!(matches!(composer.popups.active, ActivePopup::Command(_)));
|
||||
assert_eq!(
|
||||
composer.vim_mode_indicator_span(),
|
||||
@@ -6062,7 +6169,9 @@ mod tests {
|
||||
for ch in ['i', '/', 'd', 'i', 'f', 'f'] {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
assert_eq!(composer.draft.textarea.text(), "/diff");
|
||||
assert_eq!(composer.current_text(), "/diff");
|
||||
assert_eq!(composer.draft.textarea.text(), "diff");
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash);
|
||||
assert!(matches!(composer.popups.active, ActivePopup::Command(_)));
|
||||
|
||||
let (result, needs_redraw) =
|
||||
@@ -6145,7 +6254,7 @@ mod tests {
|
||||
|
||||
assert!(matches!(result, InputResult::None));
|
||||
assert!(needs_redraw);
|
||||
assert!(composer.draft.is_bash_mode);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell);
|
||||
assert_eq!(composer.current_text(), "!");
|
||||
assert_eq!(composer.draft.textarea.text(), "");
|
||||
assert_eq!(
|
||||
@@ -6175,7 +6284,7 @@ mod tests {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
assert!(composer.draft.is_bash_mode);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell);
|
||||
assert_eq!(composer.current_text(), "!echo");
|
||||
assert_eq!(composer.draft.textarea.text(), "echo");
|
||||
assert!(matches!(composer.popups.active, ActivePopup::None));
|
||||
@@ -6429,7 +6538,9 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(matches!(result, InputResult::None));
|
||||
assert_eq!(composer.draft.textarea.text(), "/not-a-command");
|
||||
assert_eq!(composer.current_text(), "/not-a-command");
|
||||
assert_eq!(composer.draft.textarea.text(), "not-a-command");
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash);
|
||||
assert_eq!(
|
||||
composer.vim_mode_indicator_span(),
|
||||
Some("Vim: Insert".green())
|
||||
@@ -9328,7 +9439,7 @@ mod tests {
|
||||
terminal
|
||||
.draw(|f| {
|
||||
composer.render(f.area(), f.buffer_mut());
|
||||
assert_eq!(composer.cursor_pos(f.area()), Some((5, 1)));
|
||||
assert_eq!(composer.cursor_pos(f.area()), Some((4, 1)));
|
||||
})
|
||||
.expect("draw composer");
|
||||
|
||||
@@ -9403,6 +9514,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_slash_command_becomes_editable_and_is_not_exported() {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
composer.set_voice_command_enabled(/*enabled*/ true);
|
||||
type_chars_humanlike(&mut composer, &['/', 'v', 'o', 'i', 'c', 'e', ' ']);
|
||||
assert_eq!(composer.draft.textarea.text_elements().len(), 1);
|
||||
assert!(composer.text_elements().is_empty());
|
||||
|
||||
composer.set_voice_command_enabled(/*enabled*/ false);
|
||||
|
||||
assert_eq!(composer.current_text(), "/voice ");
|
||||
assert!(composer.draft.textarea.text_elements().is_empty());
|
||||
}
|
||||
|
||||
fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool {
|
||||
std::thread::sleep(PasteBurst::recommended_active_flush_delay());
|
||||
composer.flush_paste_burst_if_due()
|
||||
@@ -9877,7 +10002,10 @@ mod tests {
|
||||
let (_result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(composer.draft.textarea.text(), "/compact ");
|
||||
assert_eq!(composer.current_text(), "/compact ");
|
||||
assert_eq!(composer.draft.textarea.text(), "compact ");
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash);
|
||||
assert_eq!(composer.current_cursor(), composer.current_text().len());
|
||||
assert_eq!(
|
||||
composer.draft.textarea.cursor(),
|
||||
composer.draft.textarea.text().len()
|
||||
@@ -9907,7 +10035,9 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(result, InputResult::None);
|
||||
assert_eq!(composer.draft.textarea.text(), "/model ");
|
||||
assert_eq!(composer.current_text(), "/model ");
|
||||
assert_eq!(composer.draft.textarea.text(), "model ");
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash);
|
||||
assert_eq!(
|
||||
composer.draft.textarea.cursor(),
|
||||
composer.draft.textarea.text().len()
|
||||
@@ -9936,7 +10066,9 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(result, InputResult::None);
|
||||
assert_eq!(composer.draft.textarea.text(), "/model ");
|
||||
assert_eq!(composer.current_text(), "/model ");
|
||||
assert_eq!(composer.draft.textarea.text(), "model ");
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash);
|
||||
assert_eq!(
|
||||
composer.draft.textarea.cursor(),
|
||||
composer.draft.textarea.text().len()
|
||||
@@ -9960,7 +10092,9 @@ mod tests {
|
||||
type_chars_humanlike(&mut composer, &['/', 'd', 'i']);
|
||||
let (_res, _redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
assert_eq!(composer.draft.textarea.text(), "/diff ");
|
||||
assert_eq!(composer.current_text(), "/diff ");
|
||||
assert_eq!(composer.draft.textarea.text(), "diff ");
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash);
|
||||
|
||||
// Press Enter: should dispatch the command, not submit literal text.
|
||||
let (result, _needs_redraw) =
|
||||
@@ -10004,9 +10138,10 @@ mod tests {
|
||||
|
||||
let text = composer.draft.textarea.text().to_string();
|
||||
let elements = composer.draft.textarea.text_elements();
|
||||
assert_eq!(text, "/plan ");
|
||||
assert_eq!(composer.current_text(), "/plan ");
|
||||
assert_eq!(text, "plan ");
|
||||
assert_eq!(elements.len(), 1);
|
||||
assert_eq!(elements[0].placeholder(&text), Some("/plan"));
|
||||
assert_eq!(elements[0].placeholder(&text), Some("plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -10026,12 +10161,30 @@ mod tests {
|
||||
|
||||
let text = composer.draft.textarea.text().to_string();
|
||||
let elements = composer.draft.textarea.text_elements();
|
||||
assert_eq!(text, "/Users ");
|
||||
assert_eq!(composer.current_text(), "/Users ");
|
||||
assert_eq!(text, "Users ");
|
||||
assert!(elements.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_element_removed_when_not_at_start() {
|
||||
fn unowned_element_spanning_slash_command_is_preserved() {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
let text = "/review ";
|
||||
let element = TextElement::new(ByteRange { start: 1, end: 7 }, Some("review".to_string()));
|
||||
|
||||
composer.set_text_content(text.to_string(), vec![element.clone()], Vec::new());
|
||||
composer.move_cursor_to_end();
|
||||
|
||||
assert_eq!(composer.current_text(), text);
|
||||
assert_eq!(composer.text_elements(), vec![element.clone()]);
|
||||
|
||||
composer.sync_popups();
|
||||
|
||||
assert_eq!(composer.text_elements(), vec![element]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_element_removed_when_command_changes() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -10046,15 +10199,17 @@ mod tests {
|
||||
|
||||
let text = composer.draft.textarea.text().to_string();
|
||||
let elements = composer.draft.textarea.text_elements();
|
||||
assert_eq!(text, "/review ");
|
||||
assert_eq!(composer.current_text(), "/review ");
|
||||
assert_eq!(text, "review ");
|
||||
assert_eq!(elements.len(), 1);
|
||||
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
type_chars_humanlike(&mut composer, &['x']);
|
||||
type_chars_humanlike(&mut composer, &[' ']);
|
||||
|
||||
let text = composer.draft.textarea.text().to_string();
|
||||
let elements = composer.draft.textarea.text_elements();
|
||||
assert_eq!(text, "x/review ");
|
||||
assert_eq!(composer.current_text(), "/ review ");
|
||||
assert_eq!(text, " review ");
|
||||
assert!(elements.is_empty());
|
||||
}
|
||||
|
||||
@@ -11086,7 +11241,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_navigation_from_start_of_bang_command_recalls_older_entry() {
|
||||
fn history_navigation_from_start_of_slash_prompt_recalls_older_entry() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -11106,14 +11261,17 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||||
|
||||
type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']);
|
||||
type_chars_humanlike(
|
||||
&mut composer,
|
||||
&['/', 't', 'm', 'p', '/', 'f', 'i', 'l', 'e'],
|
||||
);
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||||
|
||||
let (_result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(composer.current_text(), "!git");
|
||||
assert_eq!(composer.current_text(), "/tmp/file");
|
||||
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
let (_result, _needs_redraw) =
|
||||
@@ -11341,13 +11499,11 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(result, InputResult::None));
|
||||
assert_eq!(composer.draft.pending_pastes.len(), 1);
|
||||
assert_eq!(
|
||||
composer.draft.textarea.text(),
|
||||
format!("/unknown {placeholder}")
|
||||
);
|
||||
assert_eq!(composer.current_text(), format!("/unknown {placeholder}"));
|
||||
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
composer.draft.textarea.insert_str(" ");
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
composer.draft.textarea.insert_str(" /");
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
@@ -12136,7 +12292,8 @@ mod tests {
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
|
||||
composer.set_text_content("/diff".to_string(), Vec::new(), Vec::new());
|
||||
type_chars_humanlike(&mut composer, &['/', 'd', 'i', 'f', 'f', ' ']);
|
||||
assert_eq!(composer.draft.textarea.text_elements().len(), 1);
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
@@ -12147,6 +12304,7 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(result, InputResult::None);
|
||||
assert_eq!(composer.current_text(), "/diff");
|
||||
assert!(composer.draft.textarea.text_elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12264,7 +12422,7 @@ mod tests {
|
||||
|
||||
composer.apply_external_edit("!git status".to_string());
|
||||
|
||||
assert!(composer.draft.is_bash_mode);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell);
|
||||
assert_eq!(composer.draft.textarea.text(), "git status");
|
||||
assert_eq!(composer.current_text(), "!git status");
|
||||
}
|
||||
@@ -12284,7 +12442,7 @@ mod tests {
|
||||
|
||||
composer.apply_external_edit("git status".to_string());
|
||||
|
||||
assert!(!composer.draft.is_bash_mode);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::None);
|
||||
assert_eq!(composer.draft.textarea.text(), "git status");
|
||||
assert_eq!(composer.current_text(), "git status");
|
||||
}
|
||||
@@ -12304,7 +12462,7 @@ mod tests {
|
||||
|
||||
composer.apply_external_edit("!git status".to_string());
|
||||
|
||||
assert!(composer.draft.is_bash_mode);
|
||||
assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell);
|
||||
assert_eq!(composer.draft.textarea.text(), "git status");
|
||||
assert_eq!(composer.current_text(), "!git status");
|
||||
}
|
||||
|
||||
@@ -8,10 +8,33 @@ use crate::bottom_pane::paste_burst::PasteBurst;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum PromptPrefix {
|
||||
#[default]
|
||||
None,
|
||||
Shell,
|
||||
Slash,
|
||||
}
|
||||
|
||||
impl PromptPrefix {
|
||||
pub(super) fn text(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "",
|
||||
Self::Shell => "!",
|
||||
Self::Slash => "/",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn len(self) -> usize {
|
||||
self.text().len()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct DraftState {
|
||||
pub(super) textarea: TextArea,
|
||||
pub(super) textarea_state: RefCell<TextAreaState>,
|
||||
pub(super) is_bash_mode: bool,
|
||||
pub(super) prompt_prefix: PromptPrefix,
|
||||
pub(super) slash_command_element_id: Option<u64>,
|
||||
pub(super) pending_pastes: Vec<(String, String)>,
|
||||
pub(super) input_enabled: bool,
|
||||
pub(super) input_disabled_placeholder: Option<String>,
|
||||
@@ -26,7 +49,8 @@ impl DraftState {
|
||||
Self {
|
||||
textarea: TextArea::new(),
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
is_bash_mode: false,
|
||||
prompt_prefix: PromptPrefix::None,
|
||||
slash_command_element_id: None,
|
||||
pending_pastes: Vec::new(),
|
||||
input_enabled: true,
|
||||
input_disabled_placeholder: None,
|
||||
|
||||
@@ -18,6 +18,13 @@ fn plain_text_composer_keeps_command_prefixes_literal() {
|
||||
] {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char(prefix), modifiers));
|
||||
composer.handle_paste("keep this literal".into());
|
||||
if prefix == '/' {
|
||||
let area = Rect::new(0, 0, /*width*/ 40, /*height*/ 4);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
composer.render(area, &mut buffer);
|
||||
assert_eq!(buffer[(0, 1)].symbol(), "›");
|
||||
assert_eq!(buffer[(2, 1)].symbol(), "/");
|
||||
}
|
||||
assert_eq!(
|
||||
composer.handle_key_event(KeyCode::Tab.into()).0,
|
||||
InputResult::Submitted {
|
||||
|
||||
@@ -60,8 +60,7 @@ fn text(buffer: &Buffer) -> String {
|
||||
#[test]
|
||||
fn retained_command_suggestions_yield_paint_and_cursor_to_find() {
|
||||
let mut composer = composer();
|
||||
composer.draft.textarea.insert_str("/");
|
||||
composer.sync_popups();
|
||||
composer.insert_str("/");
|
||||
let original = render(&composer, /*footer*/ None);
|
||||
let footer = TranscriptFooter {
|
||||
text: vec![
|
||||
@@ -91,7 +90,8 @@ fn retained_command_suggestions_yield_paint_and_cursor_to_find() {
|
||||
.then_some((footer_area.x + 8, footer_area.y))
|
||||
);
|
||||
assert!(matches!(composer.popups.active, ActivePopup::Command(_)));
|
||||
assert_eq!(composer.draft.textarea.text(), "/");
|
||||
assert_eq!(composer.current_text(), "/");
|
||||
assert!(composer.draft.textarea.is_empty());
|
||||
frames.push(format!(
|
||||
"{width}x{height}, cursor {cursor:?}\n{}",
|
||||
text(&buffer)
|
||||
|
||||
@@ -431,7 +431,15 @@ impl ChatComposer {
|
||||
if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
Self::case_insensitive_match_ranges(self.draft.textarea.text(), &search.query)
|
||||
let prefix_len = self.draft.prompt_prefix.len();
|
||||
Self::case_insensitive_match_ranges(&self.current_text(), &search.query)
|
||||
.into_iter()
|
||||
.filter_map(|range| {
|
||||
let start = range.start.saturating_sub(prefix_len);
|
||||
let end = range.end.saturating_sub(prefix_len);
|
||||
(start < end).then_some(start..end)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec<Range<usize>> {
|
||||
|
||||
@@ -117,6 +117,22 @@ fn history_search_empty_paste_preserves_selected_match() {
|
||||
insta::assert_snapshot!("history_search_empty_paste", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_search_highlights_absorbed_prompt_prefix_match() {
|
||||
let mut composer = composer_with_history();
|
||||
composer
|
||||
.history
|
||||
.record_local_submission(HistoryEntry::new("/review changes".to_string()));
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
composer.handle_paste("/review".to_string());
|
||||
|
||||
assert_eq!(composer.current_text(), "/review changes");
|
||||
assert_eq!(composer.history_search_highlight_ranges(), vec![0..6]);
|
||||
|
||||
let terminal = render_composer(&composer, /*width*/ 70);
|
||||
insta::assert_snapshot!("history_search_absorbed_slash", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_search_paste_shows_separators_and_matches_original_query() {
|
||||
let mut composer = composer_with_history();
|
||||
@@ -208,7 +224,7 @@ fn history_search_paste_uses_full_sanitized_text() {
|
||||
composer.handle_paste(pasted);
|
||||
|
||||
assert_eq!(composer.history_search.as_ref().unwrap().query, query);
|
||||
assert_eq!(composer.draft.textarea.text(), query);
|
||||
assert_eq!(composer.current_text(), query);
|
||||
assert!(composer.attachments.local_image_paths().is_empty());
|
||||
assert!(composer.draft.pending_pastes.is_empty());
|
||||
assert!(composer.current_text_elements().is_empty());
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Command prefixes become escaped, editable prompt text before answers are appended.
|
||||
|
||||
use super::super::textarea::VimPersistentState;
|
||||
use super::draft_state::PromptPrefix;
|
||||
use super::*;
|
||||
|
||||
impl ComposerDraft {
|
||||
@@ -32,13 +33,19 @@ impl ChatComposer {
|
||||
.swap_vim_persistent_state(&mut vim_state);
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
// Recovered answers turn the draft into a prompt, so escape any command prefix
|
||||
// before appending them. Shell mode stores its `!` outside the textarea.
|
||||
let text = self.draft.textarea.text();
|
||||
let slash_command_draft = self.slash_input().should_parse_on_dequeue(text)
|
||||
// before appending them. Command modes store their prefix outside the textarea.
|
||||
let text = self.current_text();
|
||||
let slash_command_draft = self.slash_input().should_parse_on_dequeue(&text)
|
||||
&& parse_slash_name(text.trim_start()).is_none_or(|(name, _, _)| !name.contains('/'));
|
||||
if self.draft.is_bash_mode {
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.textarea.insert_str_at(/*pos*/ 0, "\\!");
|
||||
if self.draft.prompt_prefix == PromptPrefix::Shell
|
||||
|| (self.draft.prompt_prefix == PromptPrefix::Slash && slash_command_draft)
|
||||
{
|
||||
self.unmark_slash_command_element();
|
||||
let prefix = self.draft.prompt_prefix.text();
|
||||
self.draft.prompt_prefix = PromptPrefix::None;
|
||||
self.draft
|
||||
.textarea
|
||||
.insert_str_at(/*pos*/ 0, &format!("\\{prefix}"));
|
||||
} else if self.is_bang_shell_command() || slash_command_draft {
|
||||
let prefix_len =
|
||||
self.draft.textarea.text().len() - self.draft.textarea.text().trim_start().len();
|
||||
|
||||
@@ -86,6 +86,31 @@ fn recovery_keeps_unselected_text_after_a_held_replace_key() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_a_body_selection_before_an_absorbed_prefix() {
|
||||
for prefix in ["!", "/"] {
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
composer.insert_str(&format!("{prefix}review text"));
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4,
|
||||
);
|
||||
composer.render(area, &mut Buffer::empty(area));
|
||||
let (x, y) = composer.cursor_pos(area).unwrap();
|
||||
for (kind, column) in [
|
||||
(Down(Left), x),
|
||||
(MouseEventKind::Drag(Left), x - "review text".len() as u16),
|
||||
(Up(Left), x - "review text".len() as u16),
|
||||
] {
|
||||
mouse(&mut composer, area, kind, column, y);
|
||||
}
|
||||
|
||||
composer.handle_key_event(KeyCode::Backspace.into());
|
||||
|
||||
assert_eq!(composer.current_text(), prefix);
|
||||
assert_eq!(composer.draft.textarea.mouse_selection_range(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_text_navigation_does_not_recall_history_or_select_images() {
|
||||
for (key, remote) in [
|
||||
|
||||
@@ -28,7 +28,7 @@ impl ChatComposer {
|
||||
if let Some(elements_before) = elements_before {
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
}
|
||||
self.sync_bash_mode_from_text();
|
||||
self.sync_prompt_prefix_from_text();
|
||||
self.sync_popups();
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
@@ -48,7 +48,7 @@ impl ChatComposer {
|
||||
FlushResult::Typed(ch) if self.draft.textarea.is_vim_replace_mode() => {
|
||||
textarea.set_cursor(snapshot.cursor);
|
||||
if let Some(selection) = self.draft.textarea.mouse_selection_range() {
|
||||
let offset = usize::from(self.draft.is_bash_mode);
|
||||
let offset = self.draft.prompt_prefix.len();
|
||||
textarea.replace_range(
|
||||
selection.start + offset..selection.end + offset,
|
||||
&ch.to_string(),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Recovery commands must occupy one line and be visible before the submit key expands pastes.
|
||||
//! Offline draft edits also consume the Astra sparkle opportunity before rendering.
|
||||
|
||||
use super::draft_state::PromptPrefix;
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -17,12 +18,10 @@ impl ChatComposer {
|
||||
/// Preserve Enter inside a paste burst without attempting submission.
|
||||
pub(crate) fn handle_paste_enter(&mut self, now: Instant) -> bool {
|
||||
let in_slash_context = self.slash_commands_enabled()
|
||||
&& !self.draft.is_bash_mode
|
||||
&& self.draft.prompt_prefix != PromptPrefix::Shell
|
||||
&& (matches!(self.popups.active, ActivePopup::Command(_))
|
||||
|| self
|
||||
.draft
|
||||
.textarea
|
||||
.text()
|
||||
.current_text()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
@@ -83,10 +82,10 @@ impl ChatComposer {
|
||||
&& self.submit_keys.is_pressed(key)
|
||||
{
|
||||
let input = self.slash_input();
|
||||
let text = self.draft.textarea.text();
|
||||
let text = self.current_text();
|
||||
let command = input
|
||||
.bare_command(text)
|
||||
.or_else(|| input.inline_command(text).map(|command| command.command))
|
||||
.bare_command(&text)
|
||||
.or_else(|| input.inline_command(&text).map(|command| command.command))
|
||||
.filter(|_| text.trim().lines().count() == 1);
|
||||
if matches!(command, Some(SlashCommandItem::Builtin(command))
|
||||
if command == SlashCommand::Warnings
|
||||
|
||||
@@ -24,6 +24,7 @@ use super::ActivePopup;
|
||||
use super::ChatComposer;
|
||||
use super::InputResult;
|
||||
use super::QueuedInputAction;
|
||||
use super::draft_state::PromptPrefix;
|
||||
use super::parent_owned_command_is_allowed;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -225,11 +226,13 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub fn set_daybreak_command_description(&mut self, description: Option<&'static str>) {
|
||||
self.popups.daybreak_command_description = description;
|
||||
self.update_slash_command_catalog(|composer| {
|
||||
composer.popups.daybreak_command_description = description
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_worktrees_enabled(&mut self, enabled: bool) {
|
||||
self.worktrees_enabled = enabled;
|
||||
self.update_slash_command_catalog(|composer| composer.worktrees_enabled = enabled);
|
||||
}
|
||||
|
||||
/// Handle key event when the slash-command popup is visible.
|
||||
@@ -242,13 +245,17 @@ impl ChatComposer {
|
||||
}
|
||||
if key_event.code == KeyCode::Esc {
|
||||
// Always dismiss the popup without changing the draft.
|
||||
let first_line = self.draft.textarea.text().lines().next().unwrap_or("");
|
||||
let text = self.current_text();
|
||||
let first_line = text.lines().next().unwrap_or("");
|
||||
self.popups.dismissed_command_token =
|
||||
command_popup_filter_text(first_line, /*cursor*/ 0);
|
||||
self.popups.active = ActivePopup::None;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
self.footer.mode = reset_mode_after_activity(self.footer.mode);
|
||||
let current_text = self.current_text();
|
||||
let current_cursor = self.current_cursor();
|
||||
let first_line = current_text.lines().next().unwrap_or("");
|
||||
let ActivePopup::Command(popup) = &mut self.popups.active else {
|
||||
unreachable!();
|
||||
};
|
||||
@@ -282,11 +289,8 @@ impl ChatComposer {
|
||||
} => {
|
||||
// Ensure popup filtering/selection reflects the latest composer text
|
||||
// before applying completion.
|
||||
let text = self.draft.textarea.text();
|
||||
let first_line = text.lines().next().unwrap_or("").to_owned();
|
||||
let cursor = self.draft.textarea.cursor();
|
||||
let filter_text = command_popup_filter_text(&first_line, cursor)
|
||||
.unwrap_or_else(|| first_line.clone());
|
||||
let filter_text = command_popup_filter_text(first_line, current_cursor)
|
||||
.unwrap_or_else(|| first_line.to_owned());
|
||||
popup.on_composer_text_change(filter_text);
|
||||
let has_visible_items = !popup.filtered_items().is_empty();
|
||||
let selected_item = popup.selected_item();
|
||||
@@ -297,7 +301,7 @@ impl ChatComposer {
|
||||
{
|
||||
self.stage_selected_slash_command_history(&selected_cmd);
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.prompt_prefix = PromptPrefix::None;
|
||||
return (InputResult::Command(*cmd), true);
|
||||
}
|
||||
|
||||
@@ -310,11 +314,9 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
if let Some(completed_text) =
|
||||
selected_command_completion(&first_line, &selected_cmd)
|
||||
selected_command_completion(first_line, &selected_cmd)
|
||||
{
|
||||
self.draft
|
||||
.textarea
|
||||
.set_text_clearing_elements(&completed_text);
|
||||
self.set_canonical_text_clearing_elements(completed_text);
|
||||
if !self.draft.textarea.text().is_empty() {
|
||||
self.draft
|
||||
.textarea
|
||||
@@ -338,11 +340,8 @@ impl ChatComposer {
|
||||
} => {
|
||||
// Treat "/" as accepting the highlighted command as text completion
|
||||
// while the slash-command popup is active.
|
||||
let text = self.draft.textarea.text();
|
||||
let first_line = text.lines().next().unwrap_or("").to_owned();
|
||||
let cursor = self.draft.textarea.cursor();
|
||||
let filter_text = command_popup_filter_text(&first_line, cursor)
|
||||
.unwrap_or_else(|| first_line.clone());
|
||||
let filter_text = command_popup_filter_text(first_line, current_cursor)
|
||||
.unwrap_or_else(|| first_line.to_owned());
|
||||
popup.on_composer_text_change(filter_text);
|
||||
if let Some(selected_cmd) = popup.selected_item() {
|
||||
if self
|
||||
@@ -354,12 +353,9 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
if let Some(completed_text) =
|
||||
selected_command_completion(&first_line, &selected_cmd)
|
||||
selected_command_completion(first_line, &selected_cmd)
|
||||
{
|
||||
self.draft
|
||||
.textarea
|
||||
.set_text_clearing_elements(&completed_text);
|
||||
self.draft.is_bash_mode = false;
|
||||
self.set_canonical_text_clearing_elements(completed_text);
|
||||
}
|
||||
if !self.draft.textarea.text().is_empty() {
|
||||
self.draft
|
||||
@@ -378,11 +374,10 @@ impl ChatComposer {
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
if self.blocks_direct_input {
|
||||
let command_is_allowed = match &sel {
|
||||
CommandItem::Builtin(cmd) => {
|
||||
parse_slash_name(self.draft.textarea.text()).is_some_and(
|
||||
|(_, args, _)| parent_owned_command_is_allowed(*cmd, args),
|
||||
)
|
||||
}
|
||||
CommandItem::Builtin(cmd) => parse_slash_name(¤t_text)
|
||||
.is_some_and(|(_, args, _)| {
|
||||
parent_owned_command_is_allowed(*cmd, args)
|
||||
}),
|
||||
CommandItem::ServiceTier(_) => false,
|
||||
};
|
||||
if !command_is_allowed {
|
||||
@@ -402,7 +397,7 @@ impl ChatComposer {
|
||||
if !matches!(sel, CommandItem::Builtin(cmd) if cmd.requires_dispatch_validation())
|
||||
{
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.prompt_prefix = PromptPrefix::None;
|
||||
}
|
||||
return (
|
||||
match sel {
|
||||
@@ -432,9 +427,9 @@ impl ChatComposer {
|
||||
return false;
|
||||
}
|
||||
|
||||
let text = self.draft.textarea.text();
|
||||
let text = self.current_text();
|
||||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||||
let cursor = self.draft.textarea.cursor();
|
||||
let cursor = self.current_cursor();
|
||||
if cursor > first_line_end || !text.starts_with('/') || !text.is_char_boundary(cursor) {
|
||||
return false;
|
||||
}
|
||||
@@ -463,6 +458,8 @@ impl ChatComposer {
|
||||
format!("{selected_command_text} ")
|
||||
};
|
||||
|
||||
let prefix_len = self.draft.prompt_prefix.len();
|
||||
let internal_replace_end = replace_end.saturating_sub(prefix_len);
|
||||
let ranges_to_unmark = self
|
||||
.draft
|
||||
.textarea
|
||||
@@ -470,7 +467,8 @@ impl ChatComposer {
|
||||
.into_iter()
|
||||
.filter_map(|element| {
|
||||
let range = element.byte_range.start..element.byte_range.end;
|
||||
(range.start < replace_end && replace_end < range.end).then_some(range)
|
||||
(range.start < internal_replace_end && internal_replace_end < range.end)
|
||||
.then_some(range)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for range in ranges_to_unmark {
|
||||
@@ -478,8 +476,8 @@ impl ChatComposer {
|
||||
}
|
||||
self.draft
|
||||
.textarea
|
||||
.replace_range(0..replace_end, &replacement);
|
||||
self.draft.is_bash_mode = false;
|
||||
.replace_range(0..internal_replace_end, &replacement[prefix_len..]);
|
||||
self.draft.prompt_prefix = PromptPrefix::Slash;
|
||||
self.draft
|
||||
.textarea
|
||||
.set_cursor(self.draft.textarea.text().len());
|
||||
@@ -488,41 +486,27 @@ impl ChatComposer {
|
||||
|
||||
/// Keep slash command elements aligned with the current first line.
|
||||
pub(super) fn sync_slash_command_elements(&mut self) {
|
||||
self.unmark_slash_command_element();
|
||||
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 cursor = self.draft.textarea.cursor();
|
||||
let desired_range = self.slash_input().command_element_range(first_line, cursor);
|
||||
// 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);
|
||||
}
|
||||
let canonical_text = self.current_text();
|
||||
let first_line_end = canonical_text.find('\n').unwrap_or(canonical_text.len());
|
||||
let first_line = &canonical_text[..first_line_end];
|
||||
let cursor = self.current_cursor();
|
||||
let prefix_len = self.draft.prompt_prefix.len();
|
||||
let desired_range = self
|
||||
.slash_input()
|
||||
.command_element_range(first_line, cursor)
|
||||
.map(|range| 0..range.end.saturating_sub(prefix_len));
|
||||
if let Some(range) = desired_range {
|
||||
self.draft.slash_command_element_id = self.draft.textarea.add_element_range(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 unmark_slash_command_element(&mut self) {
|
||||
if let Some(id) = self.draft.slash_command_element_id.take() {
|
||||
self.draft.textarea.remove_element(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,8 +620,8 @@ mod tests {
|
||||
|
||||
fn composer_with_text_at_cursor(text: &str, cursor: usize) -> ChatComposer {
|
||||
let mut composer = test_composer();
|
||||
composer.draft.textarea.set_text_clearing_elements(text);
|
||||
composer.draft.textarea.set_cursor(cursor);
|
||||
composer.set_text_content(text.to_string(), Vec::new(), Vec::new());
|
||||
composer.set_current_cursor(cursor);
|
||||
composer.sync_popups();
|
||||
composer
|
||||
}
|
||||
@@ -650,8 +634,8 @@ mod tests {
|
||||
fn unavailable_side_conversation_command_still_opens_popup() {
|
||||
let mut composer = test_composer();
|
||||
composer.set_side_conversation_active(/*active*/ true);
|
||||
composer.draft.textarea.set_text_clearing_elements("/arch");
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 5);
|
||||
composer.set_text_content("/arch".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_current_cursor(/*cursor*/ 5);
|
||||
composer.sync_popups();
|
||||
|
||||
assert!(matches!(composer.popups.active, ActivePopup::Command(_)));
|
||||
@@ -661,12 +645,12 @@ mod tests {
|
||||
fn side_conversation_tab_completes_available_match_after_unavailable_rows() {
|
||||
let mut composer = test_composer();
|
||||
composer.set_side_conversation_active(/*active*/ true);
|
||||
composer.draft.textarea.set_text_clearing_elements("/a");
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 2);
|
||||
composer.set_text_content("/a".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_current_cursor(/*cursor*/ 2);
|
||||
composer.sync_popups();
|
||||
|
||||
assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
|
||||
assert_eq!(composer.draft.textarea.text(), "/agents ");
|
||||
assert_eq!(composer.current_text(), "/agents ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -674,12 +658,12 @@ mod tests {
|
||||
let mut composer = test_composer();
|
||||
composer.set_side_conversation_active(/*active*/ true);
|
||||
composer.set_task_running(/*running*/ true);
|
||||
composer.draft.textarea.set_text_clearing_elements("/arch");
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 5);
|
||||
composer.set_text_content("/arch".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_current_cursor(/*cursor*/ 5);
|
||||
composer.sync_popups();
|
||||
|
||||
assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
|
||||
assert_eq!(composer.draft.textarea.text(), "/arch");
|
||||
assert_eq!(composer.current_text(), "/arch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -687,11 +671,8 @@ mod tests {
|
||||
let mut composer = test_composer();
|
||||
composer.set_side_conversation_active(/*active*/ true);
|
||||
composer.set_task_running(/*running*/ true);
|
||||
composer
|
||||
.draft
|
||||
.textarea
|
||||
.set_text_clearing_elements("/agents");
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 7);
|
||||
composer.set_text_content("/agents".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_current_cursor(/*cursor*/ 7);
|
||||
composer.sync_popups();
|
||||
|
||||
assert_eq!(
|
||||
@@ -711,12 +692,12 @@ mod tests {
|
||||
let mut composer = test_composer();
|
||||
composer.set_side_conversation_active(/*active*/ true);
|
||||
composer.set_disable_paste_burst(/*disabled*/ true);
|
||||
composer.draft.textarea.set_text_clearing_elements("/new");
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 4);
|
||||
composer.set_text_content("/new".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_current_cursor(/*cursor*/ 4);
|
||||
composer.sync_popups();
|
||||
|
||||
assert_eq!(press(&mut composer, KeyCode::Char('/')), InputResult::None);
|
||||
assert_eq!(composer.draft.textarea.text(), "/new/");
|
||||
assert_eq!(composer.current_text(), "/new/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -727,7 +708,7 @@ mod tests {
|
||||
assert_eq!(press(&mut composer, KeyCode::Esc), InputResult::None);
|
||||
|
||||
assert!(!composer.popup_active());
|
||||
assert_eq!(composer.draft.textarea.text(), "/rev");
|
||||
assert_eq!(composer.current_text(), "/rev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -737,15 +718,15 @@ mod tests {
|
||||
|
||||
let mut composer = composer_with_draft_tail("/re", draft);
|
||||
assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
|
||||
assert_eq!(composer.draft.textarea.text(), expected_text);
|
||||
assert_eq!(composer.draft.textarea.cursor(), expected_text.len());
|
||||
assert_eq!(composer.current_text(), expected_text);
|
||||
assert_eq!(composer.current_cursor(), expected_text.len());
|
||||
|
||||
let mut composer = composer_with_draft_tail("/re", draft);
|
||||
assert_eq!(
|
||||
press(&mut composer, KeyCode::Enter),
|
||||
InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new())
|
||||
);
|
||||
assert_eq!(composer.draft.textarea.text(), expected_text);
|
||||
assert_eq!(composer.current_text(), expected_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -756,21 +737,21 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
|
||||
assert_eq!(composer.draft.textarea.text(), "/status ");
|
||||
assert_eq!(composer.draft.textarea.cursor(), "/status ".len());
|
||||
assert_eq!(composer.current_text(), "/status ");
|
||||
assert_eq!(composer.current_cursor(), "/status ".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_completion_does_not_turn_command_suffix_into_args() {
|
||||
let mut composer = composer_with_text_at_cursor("/review", "/re".len());
|
||||
assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
|
||||
assert_eq!(composer.draft.textarea.text(), "/review ");
|
||||
assert_eq!(composer.current_text(), "/review ");
|
||||
|
||||
let mut composer = composer_with_text_at_cursor("/review", "/re".len());
|
||||
assert_eq!(
|
||||
press(&mut composer, KeyCode::Enter),
|
||||
InputResult::Command(SlashCommand::Review)
|
||||
);
|
||||
assert_eq!(composer.draft.textarea.text(), "/review ");
|
||||
assert_eq!(composer.current_text(), "/review ");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs
|
||||
assertion_line: 231
|
||||
assertion_line: 100
|
||||
expression: "frames.join(\"\\n\\n\")"
|
||||
---
|
||||
60x8, cursor Some((10, 6))
|
||||
|
||||
› /
|
||||
/
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ expression: "frames.join(\"\\n\\n\")"
|
||||
|
||||
24x8, cursor Some((10, 6))
|
||||
|
||||
› /
|
||||
/
|
||||
|
||||
|
||||
|
||||
@@ -25,4 +25,4 @@ expression: "frames.join(\"\\n\\n\")"
|
||||
|
||||
12x3, cursor None
|
||||
|
||||
› /
|
||||
/
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"/ review changes "
|
||||
" "
|
||||
" "
|
||||
" reverse-i-search: /review enter accept · esc cancel "
|
||||
+1
-1
@@ -7,7 +7,7 @@ command during startup:
|
||||
› /status show current session configuration and token usage
|
||||
/statusline configure which items appear in the status line
|
||||
|
||||
› /status
|
||||
/ status
|
||||
|
||||
100% context left
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Buffer {
|
||||
" /mention mention a file ",
|
||||
" /mcp list MCP tools; use /mcp verbose or /mcp login <name> ",
|
||||
" ",
|
||||
"› /m ",
|
||||
"/ m ",
|
||||
" ",
|
||||
" model · high · fast ",
|
||||
],
|
||||
|
||||
@@ -26,6 +26,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
use super::ChatComposer;
|
||||
use super::FooterMode;
|
||||
use super::InputResult;
|
||||
use super::draft_state::PromptPrefix;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::terminal_palette::StdoutColorLevel;
|
||||
use crate::terminal_palette::default_fg;
|
||||
@@ -315,7 +316,9 @@ impl ChatComposer {
|
||||
let Some(body) = text.strip_prefix('/') else {
|
||||
return false;
|
||||
};
|
||||
if !self.slash_commands_enabled() || self.draft.is_bash_mode || text.contains(['\n', '\r'])
|
||||
if !self.slash_commands_enabled()
|
||||
|| self.draft.prompt_prefix == PromptPrefix::Shell
|
||||
|| text.contains(['\n', '\r'])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ fn slash_popup_preserves_footer_and_selection_style() {
|
||||
} = composer.layout_with_options(area, ComposerRenderOptions::default());
|
||||
assert_eq!(popup.bottom(), surface.y);
|
||||
assert_eq!(surface.bottom(), footer.y);
|
||||
assert_eq!(composer.cursor_pos(area), Some((4, textarea.y)));
|
||||
assert_eq!(composer.cursor_pos(area), Some((3, textarea.y)));
|
||||
let footer_text: String = (0..width).map(|x| buf[(x, height - 1)].symbol()).collect();
|
||||
assert!(footer_text.contains("model · high · fast"));
|
||||
if !clipped && width == 80 {
|
||||
|
||||
@@ -15,6 +15,7 @@ use super::super::ChatComposer;
|
||||
use super::super::InputResult;
|
||||
use super::super::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use super::super::PasteBurst;
|
||||
use super::super::draft_state::PromptPrefix;
|
||||
use super::super::tests::new_test_composer;
|
||||
use super::super::tests::snapshot_composer_state_with_width;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
@@ -159,16 +160,19 @@ fn recovered_answers_undo_as_one_vim_edit() {
|
||||
ctrl_r(&mut composer);
|
||||
assert_eq!(composer.draft_snapshot(), after);
|
||||
}
|
||||
for command in ["!echo", "/diff"] {
|
||||
for command in ["!echo", "/diff", "/review "] {
|
||||
let mut composer = vim_composer(command);
|
||||
composer.move_cursor_to_end();
|
||||
composer.sync_popups();
|
||||
let before = composer.draft_snapshot();
|
||||
composer.append_recovered_drafts("answer");
|
||||
assert_eq!(composer.current_text(), format!("\\{command}\n\nanswer"));
|
||||
assert!(composer.draft.textarea.text_elements().is_empty());
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
}
|
||||
let mut composer = vim_composer("echo");
|
||||
composer.draft.is_bash_mode = true;
|
||||
composer.draft.prompt_prefix = PromptPrefix::Shell;
|
||||
let before = composer.draft_snapshot();
|
||||
composer.append_recovered_drafts("answer");
|
||||
assert_eq!(composer.current_text(), "\\!echo\n\nanswer");
|
||||
@@ -672,7 +676,7 @@ fn vim_replace_backspace_restores_characters_in_new_slash_element() {
|
||||
composer.set_vim_enabled(/*enabled*/ true);
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
keys(composer, "R/diff");
|
||||
assert_eq!(composer.draft.textarea.element_payloads(), vec!["/diff"]);
|
||||
assert_eq!(composer.draft.textarea.element_payloads(), vec!["diff"]);
|
||||
for expected in [("/dife tail", 4), ("/dide tail", 3)] {
|
||||
let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
assert_eq!(
|
||||
@@ -684,6 +688,34 @@ fn vim_replace_backspace_restores_characters_in_new_slash_element() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_replace_delete_backward_restores_characters_hidden_by_prompt_prefixes() {
|
||||
for (original, replacement, delete_backward, repeat) in [
|
||||
(
|
||||
"abcde tail",
|
||||
"R/diff",
|
||||
KeyEvent::from(KeyCode::Backspace),
|
||||
5,
|
||||
),
|
||||
(
|
||||
"ébc",
|
||||
"R!",
|
||||
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL),
|
||||
1,
|
||||
),
|
||||
] {
|
||||
let mut composer = vim_composer(original);
|
||||
keys(&mut composer, replacement);
|
||||
for _ in 0..repeat {
|
||||
let _ = composer.handle_key_event(delete_backward);
|
||||
}
|
||||
assert_eq!(
|
||||
(composer.current_text(), composer.current_cursor()),
|
||||
(original.to_owned(), 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_editing_modes_use_the_same_large_and_image_paste_handling() {
|
||||
let temporary = tempfile::tempdir().expect("create image directory");
|
||||
|
||||
+1
-1
@@ -3,5 +3,5 @@ source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› /mo "
|
||||
"/ mo "
|
||||
" "
|
||||
|
||||
+1
-1
@@ -8,6 +8,6 @@ expression: terminal.backend()
|
||||
" commands "
|
||||
" /ide include current selection, open files, and"
|
||||
" "
|
||||
"› / "
|
||||
"/ "
|
||||
" "
|
||||
" 100% context left | Vim: Insert "
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: terminal.backend()
|
||||
---
|
||||
"› /diff show git diff (including untracked files) "
|
||||
" "
|
||||
"› /dide tail "
|
||||
"/ dide tail "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
+1
-1
@@ -5,6 +5,6 @@ expression: "render_snapshot(&pane, area)"
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
|
||||
› /rev
|
||||
/ rev
|
||||
|
||||
tab to queue message 100% context left
|
||||
|
||||
@@ -793,7 +793,7 @@ impl TextArea {
|
||||
}
|
||||
if self.is_vim_replace_mode()
|
||||
&& self.mouse_selection_range().is_none()
|
||||
&& self.editor_keymap.delete_backward.is_pressed(event)
|
||||
&& self.is_delete_backward_key(event)
|
||||
&& self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter)
|
||||
{
|
||||
return;
|
||||
@@ -802,6 +802,10 @@ impl TextArea {
|
||||
self.input_with_keymap(event, &keymap);
|
||||
}
|
||||
|
||||
pub(in crate::bottom_pane) fn is_delete_backward_key(&self, event: KeyEvent) -> bool {
|
||||
self.editor_keymap.delete_backward.is_pressed(event)
|
||||
}
|
||||
|
||||
fn leave_vim_insert_mode(&mut self) {
|
||||
self.mouse_selection = None;
|
||||
let bol = self.beginning_of_current_line();
|
||||
@@ -1680,6 +1684,13 @@ impl TextArea {
|
||||
.map(|element| element.id)
|
||||
}
|
||||
|
||||
pub(crate) fn element_range(&self, id: u64) -> Option<Range<usize>> {
|
||||
self.elements
|
||||
.iter()
|
||||
.find(|element| element.id == id)
|
||||
.map(|element| element.range.clone())
|
||||
}
|
||||
|
||||
/// Renames a single text element in-place, keeping it atomic.
|
||||
///
|
||||
/// Use this when the element payload is an identifier (e.g. a placeholder) that must be
|
||||
@@ -1799,6 +1810,10 @@ impl TextArea {
|
||||
len_before != self.elements.len()
|
||||
}
|
||||
|
||||
pub(crate) fn remove_element(&mut self, id: u64) {
|
||||
self.elements.retain(|element| element.id != id);
|
||||
}
|
||||
|
||||
fn next_element_id(&mut self) -> u64 {
|
||||
let id = self.next_element_id;
|
||||
self.next_element_id = self.next_element_id.saturating_add(1);
|
||||
|
||||
@@ -119,6 +119,22 @@ impl TextArea {
|
||||
self.vim_commands.replace_steps.clear();
|
||||
}
|
||||
|
||||
/// Remove a newly typed prompt marker without losing its Vim Replace recovery step.
|
||||
pub(in crate::bottom_pane) fn absorb_prompt_prefix(&mut self, prefix_len: usize) {
|
||||
if self.is_vim_replace_mode()
|
||||
&& self.cursor_pos == prefix_len
|
||||
&& let Some(step) = self.vim_commands.replace_steps.last_mut()
|
||||
&& step.cursor_before == 0
|
||||
&& step.start == 0
|
||||
&& step.inserted_len == prefix_len
|
||||
{
|
||||
step.inserted_len = 0;
|
||||
self.replace_range_preserving_recovery(0..prefix_len, "");
|
||||
} else {
|
||||
self.replace_range(0..prefix_len, "");
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::bottom_pane) fn replace_vim_text(&mut self, text: &str) {
|
||||
for grapheme in text.graphemes(/*is_extended*/ true) {
|
||||
let cursor_before = self.cursor_pos;
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n
|
||||
Bundled catalog:
|
||||
› /ultrafast The fastest available responses for latency-sensitive work.
|
||||
|
||||
› /ultrafast
|
||||
/ ultrafast
|
||||
|
||||
GPT-6.1 Sol default · /tmp/project
|
||||
|
||||
Custom catalog:
|
||||
› /express My custom Bedrock tier.
|
||||
|
||||
› /express
|
||||
/ express
|
||||
|
||||
GPT-6.1-Sol default · /tmp/project
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n
|
||||
Bundled catalog:
|
||||
› /ultrafast The fastest available responses for latency-sensitive work.
|
||||
|
||||
› /ultrafast
|
||||
/ ultrafast
|
||||
|
||||
GPT-6-Astra default · /tmp/project
|
||||
|
||||
Custom catalog:
|
||||
› /express My custom Bedrock tier.
|
||||
|
||||
› /express
|
||||
/ express
|
||||
|
||||
GPT-6-Astra default · /tmp/project
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n
|
||||
Bundled catalog:
|
||||
› /ultrafast The fastest available responses for latency-sensitive work.
|
||||
|
||||
› /ultrafast
|
||||
/ ultrafast
|
||||
|
||||
GPT-6.1 Sol (Global) default · /tmp/project
|
||||
|
||||
Custom catalog:
|
||||
› /express My custom Bedrock tier.
|
||||
|
||||
› /express
|
||||
/ express
|
||||
|
||||
GPT-6.1-Sol default · /tmp/project
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n
|
||||
Bundled catalog:
|
||||
› /ultrafast The fastest available responses for latency-sensitive work.
|
||||
|
||||
› /ultrafast
|
||||
/ ultrafast
|
||||
|
||||
GPT-6-Astra (Global) default · /tmp/project
|
||||
|
||||
Custom catalog:
|
||||
› /express My custom Bedrock tier.
|
||||
|
||||
› /express
|
||||
/ express
|
||||
|
||||
GPT-6-Astra default · /tmp/project
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n
|
||||
Bundled catalog:
|
||||
› /ultrafast The fastest available responses for latency-sensitive work.
|
||||
|
||||
› /ultrafast
|
||||
/ ultrafast
|
||||
|
||||
GPT-6.1 Sol (US cross-region) default · /tmp/project
|
||||
|
||||
Custom catalog:
|
||||
› /express My custom Bedrock tier.
|
||||
|
||||
› /express
|
||||
/ express
|
||||
|
||||
GPT-6.1-Sol default · /tmp/project
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n
|
||||
Bundled catalog:
|
||||
› /ultrafast The fastest available responses for latency-sensitive work.
|
||||
|
||||
› /ultrafast
|
||||
/ ultrafast
|
||||
|
||||
GPT-6-Astra (US cross-region) default · /tmp/project
|
||||
|
||||
Custom catalog:
|
||||
› /express My custom Bedrock tier.
|
||||
|
||||
› /express
|
||||
/ express
|
||||
|
||||
GPT-6-Astra default · /tmp/project
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
› /fast
|
||||
/ fast
|
||||
|
||||
gpt-5.4 default · /tmp/project
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@ expression: popup
|
||||
---
|
||||
› /worktree start or continue a conversation in a new worktree
|
||||
|
||||
› /work
|
||||
/ work
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@ source: tui/src/chatwidget/tests/slash_commands.rs
|
||||
assertion_line: 3091
|
||||
expression: popup
|
||||
---
|
||||
› /work
|
||||
/ work
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@ expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
› /ultrafast ultrafast processing
|
||||
|
||||
› /ultra
|
||||
/ ultra
|
||||
|
||||
GPT-5.5 default · /tmp/project
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
source: tui/src/chatwidget/tests/slash_commands.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
› /ultra
|
||||
/ ultra
|
||||
|
||||
GPT-5.5 default · /tmp/project
|
||||
|
||||
@@ -184,7 +184,7 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul
|
||||
|
||||
fn submit(terminal: &mut PtyCodex, input: &str) -> Result<()> {
|
||||
terminal.write_input(input.as_bytes())?;
|
||||
terminal.wait_for_screen(input)?;
|
||||
terminal.wait_for_screen(&input.replacen('/', "/ ", 1))?;
|
||||
// Bulk PTY writes resemble a paste; wait out the Enter suppression window.
|
||||
std::thread::sleep(Duration::from_millis(/*millis*/ 250));
|
||||
terminal.write_input(b"\r")?;
|
||||
|
||||
Reference in New Issue
Block a user