mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
show bash mode in the TUI (#18271)
## What - Explicitly show our "bash mode" by changing the color and adding a callout similar to how we do for `Plan mode (shift + tab to cycle)` - Also replace our `›` composer prefix with a bang `!`  ## Why - It was unclear that we had a Bash mode - This feels more responsive - It looks cool! --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
a3ed5068c1
commit
ef071cf816
@@ -225,6 +225,10 @@ use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(test)]
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
@@ -315,6 +319,7 @@ impl ChatComposerConfig {
|
||||
pub(crate) struct ChatComposer {
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
is_bash_mode: bool,
|
||||
active_popup: ActivePopup,
|
||||
app_event_tx: AppEventSender,
|
||||
history: ChatComposerHistory,
|
||||
@@ -471,6 +476,7 @@ impl ChatComposer {
|
||||
let mut this = Self {
|
||||
textarea: TextArea::new(),
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
is_bash_mode: false,
|
||||
active_popup: ActivePopup::None,
|
||||
app_event_tx,
|
||||
history: ChatComposerHistory::new(),
|
||||
@@ -710,6 +716,7 @@ impl ChatComposer {
|
||||
/// Returns true if the composer currently contains no user-entered input.
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.textarea.is_empty()
|
||||
&& !self.is_bash_mode
|
||||
&& self.attached_images.is_empty()
|
||||
&& self.remote_image_urls.is_empty()
|
||||
}
|
||||
@@ -847,6 +854,7 @@ impl ChatComposer {
|
||||
/// remote images). Cursor is placed at the end after rebuilding elements.
|
||||
pub(crate) fn apply_external_edit(&mut self, text: String) {
|
||||
self.pending_pastes.clear();
|
||||
let (text, _) = self.imported_text_for_textarea(text, Vec::new());
|
||||
|
||||
// Count placeholder occurrences in the new text.
|
||||
let mut placeholder_counts: HashMap<String, usize> = HashMap::new();
|
||||
@@ -914,7 +922,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub(crate) fn current_text_with_pending(&self) -> String {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let mut text = self.current_text();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
@@ -928,7 +936,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) {
|
||||
let text = self.textarea.text().to_string();
|
||||
let text = self.current_text();
|
||||
self.pending_pastes = pending_pastes
|
||||
.into_iter()
|
||||
.filter(|(placeholder, _)| text.contains(placeholder))
|
||||
@@ -1015,10 +1023,12 @@ impl ChatComposer {
|
||||
) {
|
||||
// Clear any existing content, placeholders, and attachments first.
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
self.is_bash_mode = false;
|
||||
self.pending_pastes.clear();
|
||||
self.attached_images.clear();
|
||||
self.mention_bindings.clear();
|
||||
|
||||
let (text, text_elements) = self.imported_text_for_textarea(text, text_elements);
|
||||
self.textarea.set_text_with_elements(&text, &text_elements);
|
||||
|
||||
for (idx, path) in local_image_paths.into_iter().enumerate() {
|
||||
@@ -1034,10 +1044,51 @@ impl ChatComposer {
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
fn current_cursor(&self) -> usize {
|
||||
self.textarea.cursor() + if self.is_bash_mode { 1 } else { 0 }
|
||||
}
|
||||
|
||||
fn history_navigation_cursor(&self) -> usize {
|
||||
if self.is_bash_mode && self.textarea.cursor() == 0 {
|
||||
0
|
||||
} else {
|
||||
self.current_cursor()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor(&mut self, cursor: usize) {
|
||||
let visible_cursor = if self.is_bash_mode {
|
||||
cursor.saturating_sub(1)
|
||||
} else {
|
||||
cursor
|
||||
};
|
||||
self.textarea
|
||||
.set_cursor(visible_cursor.min(self.textarea.text().len()));
|
||||
}
|
||||
|
||||
fn current_text_elements(&self) -> Vec<TextElement> {
|
||||
let shift = if self.is_bash_mode { 1 } else { 0 };
|
||||
self.textarea
|
||||
.text_elements()
|
||||
.into_iter()
|
||||
.filter_map(|element| Self::shift_text_element(element, shift))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn shift_text_element(element: TextElement, shift: isize) -> Option<TextElement> {
|
||||
let start = element.byte_range.start.checked_add_signed(shift)?;
|
||||
let end = element.byte_range.end.checked_add_signed(shift)?;
|
||||
if start >= end {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(element.map_range(|_| (start..end).into()))
|
||||
}
|
||||
|
||||
fn snapshot_draft(&self) -> ComposerDraft {
|
||||
ComposerDraft {
|
||||
text: self.textarea.text().to_string(),
|
||||
text_elements: self.textarea.text_elements(),
|
||||
text: self.current_text(),
|
||||
text_elements: self.current_text_elements(),
|
||||
local_image_paths: self
|
||||
.attached_images
|
||||
.iter()
|
||||
@@ -1046,7 +1097,7 @@ impl ChatComposer {
|
||||
remote_image_urls: self.remote_image_urls.clone(),
|
||||
mention_bindings: self.snapshot_mention_bindings(),
|
||||
pending_pastes: self.pending_pastes.clone(),
|
||||
cursor: self.textarea.cursor(),
|
||||
cursor: self.current_cursor(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,8 +1119,7 @@ impl ChatComposer {
|
||||
mention_bindings,
|
||||
);
|
||||
self.set_pending_pastes(pending_pastes);
|
||||
self.textarea
|
||||
.set_cursor(cursor.min(self.textarea.text().len()));
|
||||
self.set_current_cursor(cursor);
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
@@ -1084,12 +1134,36 @@ impl ChatComposer {
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
/// Convert canonical composer text into the textarea's internal representation.
|
||||
///
|
||||
/// Bash mode stores the leading `!` 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 let Some(stripped) = text.strip_prefix('!') {
|
||||
self.is_bash_mode = true;
|
||||
(
|
||||
stripped.to_string(),
|
||||
text_elements
|
||||
.into_iter()
|
||||
.filter_map(|element| Self::shift_text_element(element, /*shift*/ -1))
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
self.is_bash_mode = false;
|
||||
(text, text_elements)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let previous = self.current_text();
|
||||
let text_elements = self.textarea.text_elements();
|
||||
let text_elements = self.current_text_elements();
|
||||
let local_image_paths = self
|
||||
.attached_images
|
||||
.iter()
|
||||
@@ -1115,7 +1189,11 @@ impl ChatComposer {
|
||||
|
||||
/// Get the current composer text.
|
||||
pub(crate) fn current_text(&self) -> String {
|
||||
self.textarea.text().to_string()
|
||||
if self.is_bash_mode {
|
||||
format!("!{}", self.textarea.text())
|
||||
} else {
|
||||
self.textarea.text().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Rehydrate a history entry into the composer with shell-like cursor placement.
|
||||
@@ -1146,7 +1224,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub(crate) fn text_elements(&self) -> Vec<TextElement> {
|
||||
self.textarea.text_elements()
|
||||
self.current_text_elements()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1325,6 +1403,7 @@ impl ChatComposer {
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.textarea.insert_str(text);
|
||||
self.sync_bash_mode_from_text();
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
@@ -1426,6 +1505,7 @@ impl ChatComposer {
|
||||
if cmd == SlashCommand::Skills {
|
||||
self.stage_selected_slash_command_history(cmd);
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
self.is_bash_mode = false;
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
|
||||
@@ -1466,6 +1546,7 @@ impl ChatComposer {
|
||||
if !starts_with_cmd {
|
||||
self.textarea
|
||||
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
|
||||
self.is_bash_mode = false;
|
||||
}
|
||||
if !self.textarea.text().is_empty() {
|
||||
self.textarea.set_cursor(self.textarea.text().len());
|
||||
@@ -1482,6 +1563,7 @@ impl ChatComposer {
|
||||
let CommandItem::Builtin(cmd) = sel;
|
||||
self.stage_selected_slash_command_history(cmd);
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
self.is_bash_mode = false;
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
// Fallback to default newline handling if no command selected.
|
||||
@@ -2241,9 +2323,9 @@ impl ChatComposer {
|
||||
record_history: bool,
|
||||
slash_validation: SlashValidation,
|
||||
) -> Option<(String, Vec<TextElement>)> {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let mut text = self.current_text();
|
||||
let original_input = text.clone();
|
||||
let original_text_elements = self.textarea.text_elements();
|
||||
let original_text_elements = self.current_text_elements();
|
||||
let original_mention_bindings = self.snapshot_mention_bindings();
|
||||
let original_local_image_paths = self
|
||||
.attached_images
|
||||
@@ -2255,6 +2337,7 @@ impl ChatComposer {
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.recent_submission_mention_bindings.clear();
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
self.is_bash_mode = false;
|
||||
|
||||
if !self.pending_pastes.is_empty() {
|
||||
// Expand placeholders so element byte ranges stay aligned.
|
||||
@@ -2394,6 +2477,7 @@ impl ChatComposer {
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
// Do not treat as paste inside a slash-command context.
|
||||
let in_slash_context = self.slash_commands_enabled()
|
||||
&& !self.is_bash_mode
|
||||
&& (matches!(self.active_popup, ActivePopup::Command(_))
|
||||
|| self
|
||||
.textarea
|
||||
@@ -2422,8 +2506,8 @@ impl ChatComposer {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
let original_input = self.textarea.text().to_string();
|
||||
let original_text_elements = self.textarea.text_elements();
|
||||
let original_input = self.current_text();
|
||||
let original_text_elements = self.current_text_elements();
|
||||
let original_mention_bindings = self.snapshot_mention_bindings();
|
||||
let original_local_image_paths = self
|
||||
.attached_images
|
||||
@@ -2473,7 +2557,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> {
|
||||
if !self.slash_commands_enabled() {
|
||||
if !self.slash_commands_enabled() || self.is_bash_mode {
|
||||
return None;
|
||||
}
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
@@ -2489,6 +2573,7 @@ impl ChatComposer {
|
||||
}
|
||||
self.stage_slash_command_history();
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
self.is_bash_mode = false;
|
||||
Some(InputResult::Command(cmd))
|
||||
} else {
|
||||
None
|
||||
@@ -2498,7 +2583,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> {
|
||||
if !self.slash_commands_enabled() {
|
||||
if !self.slash_commands_enabled() || self.is_bash_mode {
|
||||
return None;
|
||||
}
|
||||
let text = self.textarea.text().to_string();
|
||||
@@ -2783,10 +2868,10 @@ impl ChatComposer {
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
if self
|
||||
.history
|
||||
.should_handle_navigation(self.textarea.text(), self.textarea.cursor())
|
||||
{
|
||||
if self.history.should_handle_navigation(
|
||||
&self.current_text(),
|
||||
self.history_navigation_cursor(),
|
||||
) {
|
||||
let replace_entry = match key_event.code {
|
||||
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
|
||||
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
|
||||
@@ -2819,7 +2904,13 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
fn is_bang_shell_command(&self) -> bool {
|
||||
self.textarea.text().trim_start().starts_with('!')
|
||||
self.current_text().trim_start().starts_with('!')
|
||||
}
|
||||
|
||||
fn shell_mode_footer_line(&self) -> Option<Line<'static>> {
|
||||
self.is_bang_shell_command()
|
||||
.then_some(())
|
||||
.map(|_| Line::from(vec![Span::from("Bash mode").light_red()]))
|
||||
}
|
||||
|
||||
/// Applies any due `PasteBurst` flush at time `now`.
|
||||
@@ -2837,8 +2928,7 @@ impl ChatComposer {
|
||||
true
|
||||
}
|
||||
FlushResult::Typed(ch) => {
|
||||
self.textarea.insert_str(ch.to_string().as_str());
|
||||
self.sync_popups();
|
||||
self.insert_str(ch.to_string().as_str());
|
||||
true
|
||||
}
|
||||
FlushResult::None => false,
|
||||
@@ -2972,7 +3062,16 @@ impl ChatComposer {
|
||||
Some(self.textarea.element_payloads())
|
||||
};
|
||||
|
||||
if self.is_bash_mode
|
||||
&& matches!(input.code, KeyCode::Backspace)
|
||||
&& self.textarea.cursor() == 0
|
||||
{
|
||||
self.is_bash_mode = false;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
self.textarea.input(input);
|
||||
self.sync_bash_mode_from_text();
|
||||
|
||||
if let Some(elements_before) = elements_before {
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
@@ -3001,6 +3100,13 @@ impl ChatComposer {
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
fn sync_bash_mode_from_text(&mut self) {
|
||||
if !self.is_bash_mode && self.textarea.text().starts_with('!') {
|
||||
self.textarea.replace_range(0..1, "");
|
||||
self.is_bash_mode = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
|
||||
let elements_after: HashSet<String> =
|
||||
self.textarea.element_payloads().into_iter().collect();
|
||||
@@ -3156,7 +3262,7 @@ impl ChatComposer {
|
||||
let file_token = Self::current_at_token(&self.textarea);
|
||||
let browsing_history = self
|
||||
.history
|
||||
.should_handle_navigation(self.textarea.text(), self.textarea.cursor());
|
||||
.should_handle_navigation(&self.current_text(), self.history_navigation_cursor());
|
||||
// When browsing input history (shell-style Up/Down recall), skip all popup
|
||||
// synchronization so nothing steals focus from continued history navigation.
|
||||
if browsing_history {
|
||||
@@ -3170,8 +3276,10 @@ impl ChatComposer {
|
||||
}
|
||||
let mention_token = self.current_mention_token();
|
||||
|
||||
let allow_command_popup =
|
||||
self.slash_commands_enabled() && file_token.is_none() && mention_token.is_none();
|
||||
let allow_command_popup = self.slash_commands_enabled()
|
||||
&& !self.is_bash_mode
|
||||
&& file_token.is_none()
|
||||
&& mention_token.is_none();
|
||||
self.sync_command_popup(allow_command_popup);
|
||||
|
||||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||||
@@ -3255,6 +3363,9 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
fn slash_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;
|
||||
@@ -3856,6 +3967,8 @@ impl ChatComposer {
|
||||
self.side_conversation_context_label.as_ref()
|
||||
{
|
||||
Some(side_conversation_context_line(label))
|
||||
} else if let Some(line) = self.shell_mode_footer_line() {
|
||||
Some(line)
|
||||
} else if status_line_active {
|
||||
let full =
|
||||
mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint);
|
||||
@@ -4027,7 +4140,13 @@ impl ChatComposer {
|
||||
}
|
||||
if !textarea_rect.is_empty() {
|
||||
let prompt = if self.input_enabled {
|
||||
if is_zellij {
|
||||
if self.is_bash_mode {
|
||||
if is_zellij {
|
||||
Span::from("!").light_red()
|
||||
} else {
|
||||
Span::from("!").light_red().bold()
|
||||
}
|
||||
} else if is_zellij {
|
||||
Span::styled("›", style.fg(ratatui::style::Color::Cyan))
|
||||
} else {
|
||||
"›".bold()
|
||||
@@ -4046,7 +4165,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
let mut state = self.textarea_state.borrow_mut();
|
||||
let textarea_is_empty = self.textarea.text().is_empty();
|
||||
let textarea_is_empty = self.textarea.text().is_empty() && !self.is_bash_mode;
|
||||
if let Some(mask_char) = mask_char {
|
||||
self.textarea.render_ref_masked(
|
||||
textarea_rect,
|
||||
@@ -4475,6 +4594,78 @@ mod tests {
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_composer_state(
|
||||
"footer_mode_shell_command_absorbs_bang",
|
||||
/*enhanced_keys_supported*/ true,
|
||||
|composer| {
|
||||
composer.set_status_line_enabled(/*enabled*/ true);
|
||||
composer.set_status_line(Some(Line::from(
|
||||
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
|
||||
)));
|
||||
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_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("!git".to_string(), Vec::new(), Vec::new());
|
||||
composer.move_cursor_to_end();
|
||||
assert_eq!(composer.cursor_pos(area), Some((5, 1)));
|
||||
|
||||
composer.set_text_content("! git".to_string(), Vec::new(), Vec::new());
|
||||
composer.move_cursor_to_end();
|
||||
assert_eq!(composer.cursor_pos(area), Some((6, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_uses_bash_accent_style() {
|
||||
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,
|
||||
);
|
||||
composer.set_status_line_enabled(/*enabled*/ true);
|
||||
composer.set_status_line(Some(Line::from(
|
||||
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
|
||||
)));
|
||||
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
let area = Rect::new(0, 0, 100, 9);
|
||||
let mut buf = Buffer::empty(area);
|
||||
composer.render(area, &mut buf);
|
||||
|
||||
let prompt_cell = &buf[(0, 1)];
|
||||
assert_eq!(prompt_cell.symbol(), "!");
|
||||
assert_eq!(prompt_cell.style().fg, Some(Color::LightRed));
|
||||
|
||||
let footer_y = area.height - 1;
|
||||
let footer_text = (0..area.width)
|
||||
.map(|x| buf[(x, footer_y)].symbol().chars().next().unwrap_or(' '))
|
||||
.collect::<String>();
|
||||
let bash_label_x = footer_text
|
||||
.find("Bash mode")
|
||||
.expect("expected bash mode footer label");
|
||||
assert_eq!(
|
||||
buf[(bash_label_x as u16, footer_y)].style().fg,
|
||||
Some(Color::LightRed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7026,11 +7217,38 @@ mod tests {
|
||||
|
||||
assert!(matches!(result, InputResult::None));
|
||||
assert!(
|
||||
composer.textarea.text().starts_with("!ls"),
|
||||
composer.current_text().starts_with("!ls"),
|
||||
"expected Tab not to submit or clear a `!` command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bang_prefixed_slash_text_submits_literal_shell_command() {
|
||||
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*/ false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['!', '/', 'd', 'i', 'f', 'f']);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { ref text, .. } if text == "!/diff"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_mention_dispatches_command_and_inserts_at() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -7632,6 +7850,42 @@ mod tests {
|
||||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_navigation_from_start_of_bang_command_recalls_older_entry() {
|
||||
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*/ false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||||
|
||||
type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']);
|
||||
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");
|
||||
|
||||
composer.textarea.set_cursor(/*pos*/ 0);
|
||||
let (_result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(composer.current_text(), "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_content_reattaches_images_without_placeholder_metadata() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -8552,6 +8806,66 @@ mod tests {
|
||||
assert_eq!(composer.textarea.cursor(), composer.current_text().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_external_edit_absorbs_bash_prefix_without_duplication() {
|
||||
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*/ false,
|
||||
);
|
||||
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
composer.apply_external_edit("!git status".to_string());
|
||||
|
||||
assert!(composer.is_bash_mode);
|
||||
assert_eq!(composer.textarea.text(), "git status");
|
||||
assert_eq!(composer.current_text(), "!git status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_external_edit_can_leave_bash_mode() {
|
||||
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*/ false,
|
||||
);
|
||||
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
composer.apply_external_edit("git status".to_string());
|
||||
|
||||
assert!(!composer.is_bash_mode);
|
||||
assert_eq!(composer.textarea.text(), "git status");
|
||||
assert_eq!(composer.current_text(), "git status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_external_edit_can_enter_bash_mode() {
|
||||
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*/ false,
|
||||
);
|
||||
composer.set_text_content("git status".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
composer.apply_external_edit("!git status".to_string());
|
||||
|
||||
assert!(composer.is_bash_mode);
|
||||
assert_eq!(composer.textarea.text(), "git status");
|
||||
assert_eq!(composer.current_text(), "!git status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_external_edit_drops_missing_attachments() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"! git status "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" gpt-5.4 high fast · ~/code/codex-1 · Context 0% used Bash mode "
|
||||
Reference in New Issue
Block a user