Files
codex/codex-rs/tui/src/bottom_pane/chat_composer.rs
charley-oai 6709ad8975 Label attached images so agent can understand in-message labels (#8950)
Agent wouldn't "see" attached images and would instead try to use the
view_file tool:
<img width="1516" height="504" alt="image"
src="https://github.com/user-attachments/assets/68a705bb-f962-4fc1-9087-e932a6859b12"
/>

In this PR, we wrap image content items in XML tags with the name of
each image (now just a numbered name like `[Image #1]`), so that the
model can understand inline image references (based on name). We also
put the image content items above the user message which the model seems
to prefer (maybe it's more used to definitions being before references).

We also tweak the view_file tool description which seemed to help a bit

Results on a simple eval set of images:

Before
<img width="980" height="310" alt="image"
src="https://github.com/user-attachments/assets/ba838651-2565-4684-a12e-81a36641bf86"
/>

After
<img width="918" height="322" alt="image"
src="https://github.com/user-attachments/assets/10a81951-7ee6-415e-a27e-e7a3fd0aee6f"
/>

```json
[
  {
    "id": "single_describe",
    "prompt": "Describe the attached image in one sentence.",
    "images": ["image_a.png"]
  },
  {
    "id": "single_color",
    "prompt": "What is the dominant color in the image? Answer with a single color word.",
    "images": ["image_b.png"]
  },
  {
    "id": "orientation_check",
    "prompt": "Is the image portrait or landscape? Answer in one sentence.",
    "images": ["image_c.png"]
  },
  {
    "id": "detail_request",
    "prompt": "Look closely at the image and call out any small details you notice.",
    "images": ["image_d.png"]
  },
  {
    "id": "two_images_compare",
    "prompt": "I attached two images. Are they the same or different? Briefly explain.",
    "images": ["image_a.png", "image_b.png"]
  },
  {
    "id": "two_images_captions",
    "prompt": "Provide a short caption for each image (Image 1, Image 2).",
    "images": ["image_c.png", "image_d.png"]
  },
  {
    "id": "multi_image_rank",
    "prompt": "Rank the attached images from most colorful to least colorful.",
    "images": ["image_a.png", "image_b.png", "image_c.png"]
  },
  {
    "id": "multi_image_choice",
    "prompt": "Which image looks more vibrant? Answer with 'Image 1' or 'Image 2'.",
    "images": ["image_b.png", "image_d.png"]
  }
]
```
2026-01-09 21:33:45 -08:00

4474 lines
164 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::key_hint::has_ctrl_or_alt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef;
use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandItem;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::footer::FooterMode;
use super::footer::FooterProps;
use super::footer::esc_hint_mode;
use super::footer::footer_height;
use super::footer::render_footer;
use super::footer::reset_mode_after_activity;
use super::footer::toggle_shortcut_mode;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use super::skill_popup::SkillPopup;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::bottom_pane::prompt_args::expand_custom_prompt;
use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::prompt_args::prompt_argument_names;
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::style::user_message_style;
use codex_common::fuzzy_match::fuzzy_match;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use codex_protocol::models::local_image_label_text;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
/// Result returned when the user interacts with the text area.
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
}
#[derive(Clone, Debug, PartialEq)]
struct AttachedImage {
placeholder: String,
path: PathBuf,
}
enum PromptSelectionMode {
Completion,
Submit,
}
enum PromptSelectionAction {
Insert { text: String, cursor: Option<usize> },
Submit { text: String },
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
active_popup: ActivePopup,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
large_paste_counters: HashMap<usize, usize>,
has_focus: bool,
attached_images: Vec<AttachedImage>,
placeholder_text: String,
is_task_running: bool,
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
input_enabled: bool,
input_disabled_placeholder: Option<String>,
// Non-bracketed paste burst tracker.
paste_burst: PasteBurst,
// When true, disables paste-burst logic and inserts characters immediately.
disable_paste_burst: bool,
custom_prompts: Vec<CustomPrompt>,
footer_mode: FooterMode,
footer_hint_override: Option<Vec<(String, String)>>,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
}
/// Popup state at most one can be visible at any time.
enum ActivePopup {
None,
Command(CommandPopup),
File(FileSearchPopup),
Skill(SkillPopup),
}
const FOOTER_SPACING_HEIGHT: u16 = 0;
impl ChatComposer {
pub fn new(
has_input_focus: bool,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
placeholder_text: String,
disable_paste_burst: bool,
) -> Self {
let use_shift_enter_hint = enhanced_keys_supported;
let mut this = Self {
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
ctrl_c_quit_hint: false,
esc_backtrack_hint: false,
use_shift_enter_hint,
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
large_paste_counters: HashMap::new(),
has_focus: has_input_focus,
attached_images: Vec::new(),
placeholder_text,
is_task_running: false,
input_enabled: true,
input_disabled_placeholder: None,
paste_burst: PasteBurst::default(),
disable_paste_burst: false,
custom_prompts: Vec::new(),
footer_mode: FooterMode::ShortcutSummary,
footer_hint_override: None,
context_window_percent: None,
context_window_used_tokens: None,
skills: None,
dismissed_skill_popup_token: None,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
this
}
pub fn set_skill_mentions(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.skills = skills;
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
let popup_constraint = match &self.active_popup {
ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::Skill(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::None => Constraint::Max(footer_total_height),
};
let [composer_rect, popup_rect] =
Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area);
let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1));
[composer_rect, textarea_rect, popup_rect]
}
fn footer_spacing(footer_hint_height: u16) -> u16 {
if footer_hint_height == 0 {
0
} else {
FOOTER_SPACING_HEIGHT
}
}
/// Returns true if the composer currently contains no user input.
pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty()
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
/// that the composer can navigate cross-session history.
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
self.history.set_metadata(log_id, entry_count);
}
/// Integrate an asynchronous response to an on-demand history lookup. If
/// the entry is present and the offset matches the current cursor we
/// immediately populate the textarea.
pub(crate) fn on_history_entry_response(
&mut self,
log_id: u64,
offset: usize,
entry: Option<String>,
) -> bool {
let Some(text) = self.history.on_entry_response(log_id, offset, entry) else {
return false;
};
self.set_text_content(text);
true
}
pub fn handle_paste(&mut self, pasted: String) -> bool {
let char_count = pasted.chars().count();
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
let placeholder = self.next_large_paste_placeholder(char_count);
self.textarea.insert_element(&placeholder);
self.pending_pastes.push((placeholder, pasted));
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
self.textarea.insert_str(" ");
} else {
self.textarea.insert_str(&pasted);
}
// Explicit paste events should not trigger Enter suppression.
self.paste_burst.clear_after_explicit_paste();
self.sync_popups();
true
}
pub fn handle_paste_image_path(&mut self, pasted: String) -> bool {
let Some(path_buf) = normalize_pasted_path(&pasted) else {
return false;
};
// normalize_pasted_path already handles Windows → WSL path conversion,
// so we can directly try to read the image dimensions.
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::info!("OK: {pasted}");
tracing::debug!("image dimensions={}x{}", width, height);
let format = pasted_image_format(&path_buf);
tracing::debug!("attached image format={}", format.label());
self.attach_image(path_buf);
true
}
Err(err) => {
tracing::trace!("ERR: {err}");
false
}
}
}
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
let was_disabled = self.disable_paste_burst;
self.disable_paste_burst = disabled;
if disabled && !was_disabled {
self.paste_burst.clear_window_after_non_char();
}
}
/// Replace the composer content with text from an external editor.
/// Clears pending paste placeholders and keeps only attachments whose
/// placeholder labels still appear in the new text. Cursor is placed at
/// the end after rebuilding elements.
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.pending_pastes.clear();
// Count placeholder occurrences in the new text.
let mut placeholder_counts: HashMap<String, usize> = HashMap::new();
for placeholder in self.attached_images.iter().map(|img| &img.placeholder) {
if placeholder_counts.contains_key(placeholder) {
continue;
}
let count = text.match_indices(placeholder).count();
if count > 0 {
placeholder_counts.insert(placeholder.clone(), count);
}
}
// Keep attachments only while we have matching occurrences left.
let mut kept_images = Vec::new();
for img in self.attached_images.drain(..) {
if let Some(count) = placeholder_counts.get_mut(&img.placeholder)
&& *count > 0
{
*count -= 1;
kept_images.push(img);
}
}
self.attached_images = kept_images;
// Rebuild textarea so placeholders become elements again.
self.textarea.set_text("");
let mut remaining: HashMap<&str, usize> = HashMap::new();
for img in &self.attached_images {
*remaining.entry(img.placeholder.as_str()).or_insert(0) += 1;
}
let mut occurrences: Vec<(usize, &str)> = Vec::new();
for placeholder in remaining.keys() {
for (pos, _) in text.match_indices(placeholder) {
occurrences.push((pos, *placeholder));
}
}
occurrences.sort_unstable_by_key(|(pos, _)| *pos);
let mut idx = 0usize;
for (pos, ph) in occurrences {
let Some(count) = remaining.get_mut(ph) else {
continue;
};
if *count == 0 {
continue;
}
if pos > idx {
self.textarea.insert_str(&text[idx..pos]);
}
self.textarea.insert_element(ph);
*count -= 1;
idx = pos + ph.len();
}
if idx < text.len() {
self.textarea.insert_str(&text[idx..]);
}
self.textarea.set_cursor(self.textarea.text().len());
self.sync_popups();
}
pub(crate) fn current_text_with_pending(&self) -> String {
let mut text = self.textarea.text().to_string();
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
text
}
/// Override the footer hint items displayed beneath the composer. Passing
/// `None` restores the default shortcut footer.
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
self.footer_hint_override = items;
}
/// Replace the entire composer content with `text` and reset cursor.
pub(crate) fn set_text_content(&mut self, text: String) {
// Clear any existing content, placeholders, and attachments first.
self.textarea.set_text("");
self.pending_pastes.clear();
self.attached_images.clear();
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
self.sync_popups();
}
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
if self.is_empty() {
return None;
}
let previous = self.current_text();
self.set_text_content(String::new());
self.history.reset_navigation();
self.history.record_local_submission(&previous);
Some(previous)
}
/// Get the current composer text.
pub(crate) fn current_text(&self) -> String {
self.textarea.text().to_string()
}
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
pub fn attach_image(&mut self, path: PathBuf) {
let image_number = self.attached_images.len() + 1;
let placeholder = local_image_label_text(image_number);
// Insert as an element to match large paste placeholder behavior:
// styled distinctly and treated atomically for cursor/mutations.
self.textarea.insert_element(&placeholder);
self.attached_images
.push(AttachedImage { placeholder, path });
}
pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
let images = std::mem::take(&mut self.attached_images);
images.into_iter().map(|img| img.path).collect()
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
self.handle_paste_burst_flush(Instant::now())
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.paste_burst.is_active()
}
pub(crate) fn recommended_paste_flush_delay() -> Duration {
PasteBurst::recommended_flush_delay()
}
/// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Only apply if user is still editing a token starting with `query`.
let current_opt = Self::current_at_token(&self.textarea);
let Some(current_token) = current_opt else {
return;
};
if !current_token.starts_with(&query) {
return;
}
if let ActivePopup::File(popup) = &mut self.active_popup {
popup.set_matches(&query, matches);
}
}
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show;
if show {
self.footer_mode = FooterMode::CtrlCReminder;
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
self.set_has_focus(has_focus);
}
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
let base = format!("[Pasted Content {char_count} chars]");
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
*next_suffix += 1;
if *next_suffix == 1 {
base
} else {
format!("{base} #{next_suffix}")
}
}
pub(crate) fn insert_str(&mut self, text: &str) {
self.textarea.insert_str(text);
self.sync_popups();
}
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if !self.input_enabled {
return (InputResult::None, false);
}
let result = match &mut self.active_popup {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
self.sync_popups();
result
}
/// Return true if either the slash-command popup or the file-search popup is active.
pub(crate) fn popup_active(&self) -> bool {
!matches!(self.active_popup, ActivePopup::None)
}
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::Command(popup) = &mut self.active_popup else {
unreachable!();
};
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
// Dismiss the slash popup; keep the current input untouched.
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
// Ensure popup filtering/selection reflects the latest composer text
// before applying completion.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
if let Some(sel) = popup.selected_item() {
let mut cursor_target: Option<usize> = None;
match sel {
CommandItem::Builtin(cmd) => {
if cmd == SlashCommand::Skills {
self.textarea.set_text("");
return (InputResult::Command(cmd), true);
}
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
if !starts_with_cmd {
self.textarea.set_text(&format!("/{} ", cmd.command()));
}
if !self.textarea.text().is_empty() {
cursor_target = Some(self.textarea.text().len());
}
}
CommandItem::UserPrompt(idx) => {
if let Some(prompt) = popup.prompt(idx) {
match prompt_selection_action(
prompt,
first_line,
PromptSelectionMode::Completion,
) {
PromptSelectionAction::Insert { text, cursor } => {
let target = cursor.unwrap_or(text.len());
self.textarea.set_text(&text);
cursor_target = Some(target);
}
PromptSelectionAction::Submit { .. } => {}
}
}
}
}
if let Some(pos) = cursor_target {
self.textarea.set_cursor(pos);
}
}
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
// If the current line starts with a custom prompt name and includes
// positional args for a numeric-style template, expand and submit
// immediately regardless of the popup selection.
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, _rest)) = parse_slash_name(first_line)
&& let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:"))
&& let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name)
&& let Some(expanded) =
expand_if_numeric_with_positional_args(prompt, first_line)
{
self.textarea.set_text("");
return (InputResult::Submitted(expanded), true);
}
if let Some(sel) = popup.selected_item() {
match sel {
CommandItem::Builtin(cmd) => {
self.textarea.set_text("");
return (InputResult::Command(cmd), true);
}
CommandItem::UserPrompt(idx) => {
if let Some(prompt) = popup.prompt(idx) {
match prompt_selection_action(
prompt,
first_line,
PromptSelectionMode::Submit,
) {
PromptSelectionAction::Submit { text } => {
self.textarea.set_text("");
return (InputResult::Submitted(text), true);
}
PromptSelectionAction::Insert { text, cursor } => {
let target = cursor.unwrap_or(text.len());
self.textarea.set_text(&text);
self.textarea.set_cursor(target);
return (InputResult::None, true);
}
}
}
return (InputResult::None, true);
}
}
}
// Fallback to default newline handling if no command selected.
self.handle_key_event_without_popup(key_event)
}
input => self.handle_input_basic(input),
}
}
#[inline]
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
let mut p = pos.min(text.len());
if p < text.len() && !text.is_char_boundary(p) {
p = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= p)
.last()
.unwrap_or(0);
}
p
}
#[inline]
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
if let KeyEvent {
code: KeyCode::Char(ch),
..
} = input
{
let now = Instant::now();
if self.paste_burst.try_append_char_if_active(ch, now) {
return (InputResult::None, true);
}
// Non-ASCII input often comes from IMEs and can arrive in quick bursts.
// We do not want to hold the first char (flicker suppression) on this path, but we
// still want to detect paste-like bursts. Before applying any non-ASCII input, flush
// any existing burst buffer (including a pending first char from the ASCII path) so
// we don't carry that transient state forward.
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) {
match decision {
CharDecision::BufferAppend => {
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::BeginBuffer { retro_chars } => {
let cur = self.textarea.cursor();
let txt = self.textarea.text();
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
let before = &txt[..safe_cur];
// If decision is to buffer, seed the paste burst buffer with the grabbed chars + new.
// Otherwise, fall through to normal insertion below.
if let Some(grab) =
self.paste_burst
.decide_begin_buffer(now, before, retro_chars as usize)
{
if !grab.grabbed.is_empty() {
self.textarea.replace_range(grab.start_byte..safe_cur, "");
}
// seed the paste burst buffer with everything (grabbed + new)
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
}
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
}
}
}
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
(InputResult::None, true)
}
/// Handle key events when file search popup is visible.
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::File(popup) = &mut self.active_popup else {
unreachable!();
};
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
// Hide popup without modifying text, remember token to avoid immediate reopen.
if let Some(tok) = Self::current_at_token(&self.textarea) {
self.dismissed_file_popup_token = Some(tok);
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let Some(sel) = popup.selected_match() else {
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
};
let sel_path = sel.to_string();
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
let is_image = Self::is_image_path(&sel_path);
if is_image {
// Determine dimensions; if that fails fall back to normal path insertion.
let path_buf = PathBuf::from(&sel_path);
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::debug!("selected image dimensions={}x{}", width, height);
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
// using the flat text and byte-offset cursor API.
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
// Clamp to a valid char boundary to avoid panics when slicing.
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Determine token boundaries in the full text.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
self.textarea.replace_range(start_idx..end_idx, "");
self.textarea.set_cursor(start_idx);
self.attach_image(path_buf);
// Add a trailing space to keep typing fluid.
self.textarea.insert_str(" ");
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
// Fallback to plain path insertion if metadata read fails.
self.insert_selected_path(&sel_path);
}
}
} else {
// Non-image: inserting file path.
self.insert_selected_path(&sel_path);
}
// No selection: treat Enter as closing the popup/session.
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
}
fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::Skill(popup) = &mut self.active_popup else {
unreachable!();
};
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some(tok) = self.current_skill_token() {
self.dismissed_skill_popup_token = Some(tok);
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let selected = popup.selected_skill().map(|skill| skill.name.clone());
if let Some(name) = selected {
self.insert_selected_skill(&name);
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
}
fn is_image_path(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
}
fn skills_enabled(&self) -> bool {
self.skills.as_ref().is_some_and(|s| !s.is_empty())
}
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
self.skills.as_ref()
}
/// Extract a token prefixed with `prefix` under the cursor, if any.
///
/// The returned string **does not** include the prefix.
///
/// Behavior:
/// - The cursor may be anywhere *inside* the token (including on the
/// leading prefix). It does **not** need to be at the end of the line.
/// - A token is delimited by ASCII whitespace (space, tab, newline).
/// - If the token under the cursor starts with `prefix`, that token is
/// returned without the leading prefix. When `allow_empty` is true, a
/// lone prefix character yields `Some(String::new())` to surface hints.
fn current_prefixed_token(
textarea: &TextArea,
prefix: char,
allow_empty: bool,
) -> Option<String> {
let cursor_offset = textarea.cursor();
let text = textarea.text();
// Adjust the provided byte offset to the nearest valid char boundary at or before it.
let mut safe_cursor = cursor_offset.min(text.len());
// If we're not on a char boundary, move back to the start of the current char.
if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) {
// Find the last valid boundary <= cursor_offset.
safe_cursor = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= cursor_offset)
.last()
.unwrap_or(0);
}
// Split the line around the (now safe) cursor position.
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Detect whether we're on whitespace at the cursor boundary.
let at_whitespace = if safe_cursor < text.len() {
text[safe_cursor..]
.chars()
.next()
.map(char::is_whitespace)
.unwrap_or(false)
} else {
false
};
// Left candidate: token containing the cursor position.
let start_left = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_left_rel = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_left = safe_cursor + end_left_rel;
let token_left = if start_left < end_left {
Some(&text[start_left..end_left])
} else {
None
};
// Right candidate: token immediately after any whitespace from the cursor.
let ws_len_right: usize = after_cursor
.chars()
.take_while(|c| c.is_whitespace())
.map(char::len_utf8)
.sum();
let start_right = safe_cursor + ws_len_right;
let end_right_rel = text[start_right..]
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(text.len() - start_right);
let end_right = start_right + end_right_rel;
let token_right = if start_right < end_right {
Some(&text[start_right..end_right])
} else {
None
};
let prefix_str = prefix.to_string();
let left_match = token_left.filter(|t| t.starts_with(prefix));
let right_match = token_right.filter(|t| t.starts_with(prefix));
let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string());
let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string());
if at_whitespace {
if right_prefixed.is_some() {
return right_prefixed;
}
if token_left.is_some_and(|t| t == prefix_str) {
return allow_empty.then(String::new);
}
return left_prefixed;
}
if after_cursor.starts_with(prefix) {
return right_prefixed.or(left_prefixed);
}
left_prefixed.or(right_prefixed)
}
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
fn current_at_token(textarea: &TextArea) -> Option<String> {
Self::current_prefixed_token(textarea, '@', false)
}
fn current_skill_token(&self) -> Option<String> {
if !self.skills_enabled() {
return None;
}
Self::current_prefixed_token(&self.textarea, '$', true)
}
/// Replace the active `@token` (the one under the cursor) with `path`.
///
/// The algorithm mirrors `current_at_token` so replacement works no matter
/// where the cursor is within the token and regardless of how many
/// `@tokens` exist in the line.
fn insert_selected_path(&mut self, path: &str) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
// Clamp to a valid char boundary to avoid panics when slicing.
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Determine token boundaries.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
// If the path contains whitespace, wrap it in double quotes so the
// local prompt arg parser treats it as a single argument. Avoid adding
// quotes when the path already contains one to keep behavior simple.
let needs_quotes = path.chars().any(char::is_whitespace);
let inserted = if needs_quotes && !path.contains('"') {
format!("\"{path}\"")
} else {
path.to_string()
};
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
new_text.push_str(&text[..start_idx]);
new_text.push_str(&inserted);
new_text.push(' ');
new_text.push_str(&text[end_idx..]);
self.textarea.set_text(&new_text);
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
self.textarea.set_cursor(new_cursor);
}
fn insert_selected_skill(&mut self, skill_name: &str) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
let inserted = format!("${skill_name}");
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
new_text.push_str(&text[..start_idx]);
new_text.push_str(&inserted);
new_text.push(' ');
new_text.push_str(&text[end_idx..]);
self.textarea.set_text(&new_text);
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
self.textarea.set_cursor(new_cursor);
}
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
if self.is_empty() {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
match key_event {
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} if self.is_empty() => {
self.app_event_tx.send(AppEvent::ExitRequest);
(InputResult::None, true)
}
// -------------------------------------------------------------
// History navigation (Up / Down) only when the composer is not
// empty or when the cursor is at the correct position, to avoid
// interfering with normal cursor movement.
// -------------------------------------------------------------
KeyEvent {
code: KeyCode::Up | KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('p') | KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
if self
.history
.should_handle_navigation(self.textarea.text(), self.textarea.cursor())
{
let replace_text = match key_event.code {
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx),
KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx),
_ => unreachable!(),
};
if let Some(text) = replace_text {
self.set_text_content(text);
return (InputResult::None, true);
}
}
self.handle_input_basic(key_event)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
// If the first line is a bare built-in slash command (no args),
// dispatch it even when the slash popup isn't visible. This preserves
// the workflow: type a prefix ("/di"), press Tab to complete to
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
// the '/name' token and our caret-based heuristic hides the popup,
// but Enter should still dispatch the command rather than submit
// literal text.
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest)) = parse_slash_name(first_line)
&& rest.is_empty()
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active()
|| *cmd != SlashCommand::ElevateSandbox
})
.find(|(n, _)| *n == name)
{
self.textarea.set_text("");
return (InputResult::Command(cmd), true);
}
// If we're in a paste-like burst capture, treat Enter as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat Enter as paste inside a slash-command context.
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/');
if self.paste_burst.is_active() && !in_slash_context {
let now = Instant::now();
if self.paste_burst.append_newline_if_active(now) {
return (InputResult::None, true);
}
}
// If we have pending placeholder pastes, replace them in the textarea text
// and continue to the normal submission flow to handle slash commands.
if !self.pending_pastes.is_empty() {
let mut text = self.textarea.text().to_string();
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.textarea.set_text(&text);
self.pending_pastes.clear();
}
// During a paste-like burst, treat Enter as a newline instead of submit.
let now = Instant::now();
if self
.paste_burst
.newline_should_insert_instead_of_submit(now)
&& !in_slash_context
{
self.textarea.insert_str("\n");
self.paste_burst.extend_window(now);
return (InputResult::None, true);
}
let mut text = self.textarea.text().to_string();
let original_input = text.clone();
let input_starts_with_space = original_input.starts_with(' ');
self.textarea.set_text("");
// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.pending_pastes.clear();
// If there is neither text nor attachments, suppress submission entirely.
let has_attachments = !self.attached_images.is_empty();
text = text.trim().to_string();
if let Some((name, _rest)) = parse_slash_name(&text) {
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active()
|| *cmd != SlashCommand::ElevateSandbox
})
.any(|(command_name, _)| command_name == name);
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
.map(|prompt_name| {
self.custom_prompts
.iter()
.any(|prompt| prompt.name == prompt_name)
})
.unwrap_or(false);
if !is_builtin && !is_known_prompt {
let message = format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(message, None),
)));
self.textarea.set_text(&original_input);
self.textarea.set_cursor(original_input.len());
return (InputResult::None, true);
}
}
}
if !input_starts_with_space
&& let Some((name, rest)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.find(|(command_name, _)| *command_name == name)
&& cmd == SlashCommand::Review
{
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
}
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.textarea.set_text(&original_input);
self.textarea.set_cursor(original_input.len());
return (InputResult::None, true);
}
};
if let Some(expanded) = expanded_prompt {
text = expanded;
}
if text.is_empty() && !has_attachments {
return (InputResult::None, true);
}
if !text.is_empty() {
self.history.record_local_submission(&text);
}
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
(InputResult::Submitted(text), true)
}
input => self.handle_input_basic(input),
}
}
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(pasted) => {
self.handle_paste(pasted);
true
}
FlushResult::Typed(ch) => {
// Mirror insert_str() behavior so popups stay in sync when a
// pending fast char flushes as normal typed input.
self.textarea.insert_str(ch.to_string().as_str());
self.sync_popups();
true
}
FlushResult::None => false,
}
}
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// If we have a buffered non-bracketed paste burst and enough time has
// elapsed since the last char, flush it before handling a new input.
let now = Instant::now();
self.handle_paste_burst_flush(now);
if !matches!(input.code, KeyCode::Esc) {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
&& self.paste_burst.is_active()
&& self.paste_burst.append_newline_if_active(now)
{
return (InputResult::None, true);
}
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
if let KeyEvent {
code: KeyCode::Char(ch),
modifiers,
..
} = input
{
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if !has_ctrl_or_alt {
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
// holding the first char while still allowing burst detection for paste input.
if !ch.is_ascii() {
return self.handle_non_ascii_char(input);
}
match self.paste_burst.on_plain_char(ch, now) {
CharDecision::BufferAppend => {
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::BeginBuffer { retro_chars } => {
let cur = self.textarea.cursor();
let txt = self.textarea.text();
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
let before = &txt[..safe_cur];
if let Some(grab) =
self.paste_burst
.decide_begin_buffer(now, before, retro_chars as usize)
{
if !grab.grabbed.is_empty() {
self.textarea.replace_range(grab.start_byte..safe_cur, "");
}
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
// If decide_begin_buffer opted not to start buffering,
// fall through to normal insertion below.
}
CharDecision::BeginBufferFromPending => {
// First char was held; now append the current one.
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::RetainFirstChar => {
// Keep the first fast char pending momentarily.
return (InputResult::None, true);
}
}
}
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
}
// Backspace at the start of an image placeholder should delete that placeholder (rather
// than deleting content before it). Do this without scanning the full text by consulting
// the textarea's element list.
if matches!(input.code, KeyCode::Backspace)
&& self.try_remove_image_element_at_cursor_start()
{
return (InputResult::None, true);
}
// For non-char inputs (or after flushing), handle normally.
// Track element removals so we can drop any corresponding placeholders without scanning
// the full text. (Placeholders are atomic elements; when deleted, the element disappears.)
let elements_before = if self.pending_pastes.is_empty() && self.attached_images.is_empty() {
None
} else {
Some(self.textarea.element_payloads())
};
self.textarea.input(input);
if let Some(elements_before) = elements_before {
self.reconcile_deleted_elements(elements_before);
}
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
let crossterm::event::KeyEvent {
code, modifiers, ..
} = input;
match code {
KeyCode::Char(_) => {
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if has_ctrl_or_alt {
self.paste_burst.clear_window_after_non_char();
}
}
KeyCode::Enter => {
// Keep burst window alive (supports blank lines in paste).
}
_ => {
// Other keys: clear burst window (buffer should have been flushed above if needed).
self.paste_burst.clear_window_after_non_char();
}
}
(InputResult::None, true)
}
fn try_remove_image_element_at_cursor_start(&mut self) -> bool {
if self.attached_images.is_empty() {
return false;
}
let p = self.textarea.cursor();
let Some(payload) = self.textarea.element_payload_starting_at(p) else {
return false;
};
let Some(idx) = self
.attached_images
.iter()
.position(|img| img.placeholder == payload)
else {
return false;
};
self.textarea.replace_range(p..p + payload.len(), "");
self.attached_images.remove(idx);
self.relabel_attached_images_and_update_placeholders();
true
}
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
let elements_after: HashSet<String> =
self.textarea.element_payloads().into_iter().collect();
let mut removed_any_image = false;
for removed in elements_before
.into_iter()
.filter(|payload| !elements_after.contains(payload))
{
self.pending_pastes.retain(|(ph, _)| ph != &removed);
if let Some(idx) = self
.attached_images
.iter()
.position(|img| img.placeholder == removed)
{
self.attached_images.remove(idx);
removed_any_image = true;
}
}
if removed_any_image {
self.relabel_attached_images_and_update_placeholders();
}
}
fn relabel_attached_images_and_update_placeholders(&mut self) {
for idx in 0..self.attached_images.len() {
let expected = local_image_label_text(idx + 1);
let current = self.attached_images[idx].placeholder.clone();
if current == expected {
continue;
}
self.attached_images[idx].placeholder = expected.clone();
let _renamed = self.textarea.replace_element_payload(&current, &expected);
}
}
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press {
return false;
}
let toggles = matches!(key_event.code, KeyCode::Char('?'))
&& !has_ctrl_or_alt(key_event.modifiers)
&& self.is_empty()
&& !self.is_in_paste_burst();
if !toggles {
return false;
}
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
let changed = next != self.footer_mode;
self.footer_mode = next;
changed
}
fn footer_props(&self) -> FooterProps {
FooterProps {
mode: self.footer_mode(),
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
is_task_running: self.is_task_running,
context_window_percent: self.context_window_percent,
context_window_used_tokens: self.context_window_used_tokens,
}
}
fn footer_mode(&self) -> FooterMode {
match self.footer_mode {
FooterMode::EscHint => FooterMode::EscHint,
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
other => other,
}
}
fn custom_footer_height(&self) -> Option<u16> {
self.footer_hint_override
.as_ref()
.map(|items| if items.is_empty() { 0 } else { 1 })
}
fn sync_popups(&mut self) {
let file_token = Self::current_at_token(&self.textarea);
let browsing_history = self
.history
.should_handle_navigation(self.textarea.text(), self.textarea.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 {
self.active_popup = ActivePopup::None;
return;
}
let skill_token = self.current_skill_token();
let allow_command_popup = file_token.is_none() && skill_token.is_none();
self.sync_command_popup(allow_command_popup);
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
self.dismissed_skill_popup_token = None;
return;
}
if let Some(token) = skill_token {
self.sync_skill_popup(token);
return;
}
self.dismissed_skill_popup_token = None;
if let Some(token) = file_token {
self.sync_file_search_popup(token);
return;
}
self.dismissed_file_popup_token = None;
if matches!(
self.active_popup,
ActivePopup::File(_) | ActivePopup::Skill(_)
) {
self.active_popup = ActivePopup::None;
}
}
/// If the cursor is currently within a slash command on the first line,
/// extract the command name and the rest of the line after it.
/// Returns None if the cursor is outside a slash command.
fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> {
if !first_line.starts_with('/') {
return None;
}
let name_start = 1usize;
let name_end = first_line[name_start..]
.find(char::is_whitespace)
.map(|idx| name_start + idx)
.unwrap_or_else(|| first_line.len());
if cursor > name_end {
return None;
}
let name = &first_line[name_start..name_end];
let rest_start = first_line[name_end..]
.find(|c: char| !c.is_whitespace())
.map(|idx| name_end + idx)
.unwrap_or(name_end);
let rest = &first_line[rest_start..];
Some((name, rest))
}
/// Heuristic for whether the typed slash command looks like a valid
/// prefix for any known command (built-in or custom prompt).
/// Empty names only count when there is no extra content after the '/'.
fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool {
if name.is_empty() {
return rest_after_name.is_empty();
}
let builtin_match = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
})
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
if builtin_match {
return true;
}
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
self.custom_prompts
.iter()
.any(|p| fuzzy_match(&format!("{prompt_prefix}{}", p.name), name).is_some())
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
fn sync_command_popup(&mut self, allow: bool) {
if !allow {
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.active_popup = ActivePopup::None;
}
return;
}
// Determine whether the caret is inside the initial '/name' token on the first line.
let text = self.textarea.text();
let first_line_end = text.find('\n').unwrap_or(text.len());
let first_line = &text[..first_line_end];
let cursor = self.textarea.cursor();
let caret_on_first_line = cursor <= first_line_end;
let is_editing_slash_command_name = caret_on_first_line
&& Self::slash_command_under_cursor(first_line, cursor)
.is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest));
// If the cursor is currently positioned within an `@token`, prefer the
// file-search popup over the slash popup so users can insert a file path
// as an argument to the command (e.g., "/review @docs/...").
if Self::current_at_token(&self.textarea).is_some() {
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.active_popup = ActivePopup::None;
}
return;
}
match &mut self.active_popup {
ActivePopup::Command(popup) => {
if is_editing_slash_command_name {
popup.on_composer_text_change(first_line.to_string());
} else {
self.active_popup = ActivePopup::None;
}
}
_ => {
if is_editing_slash_command_name {
let skills_enabled = self.skills_enabled();
let mut command_popup =
CommandPopup::new(self.custom_prompts.clone(), skills_enabled);
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
}
}
}
}
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
self.custom_prompts = prompts.clone();
if let ActivePopup::Command(popup) = &mut self.active_popup {
popup.set_prompts(prompts);
}
}
/// Synchronize `self.file_search_popup` with the current text in the textarea.
/// Note this is only called when self.active_popup is NOT Command.
fn sync_file_search_popup(&mut self, query: String) {
// If user dismissed popup for this exact query, don't reopen until text changes.
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
return;
}
if !query.is_empty() {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
}
match &mut self.active_popup {
ActivePopup::File(popup) => {
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
}
_ => {
let mut popup = FileSearchPopup::new();
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
self.active_popup = ActivePopup::File(popup);
}
}
self.current_file_query = Some(query);
self.dismissed_file_popup_token = None;
}
fn sync_skill_popup(&mut self, query: String) {
if self.dismissed_skill_popup_token.as_ref() == Some(&query) {
return;
}
let skills = match self.skills.as_ref() {
Some(skills) if !skills.is_empty() => skills.clone(),
_ => {
self.active_popup = ActivePopup::None;
return;
}
};
match &mut self.active_popup {
ActivePopup::Skill(popup) => {
popup.set_query(&query);
popup.set_skills(skills);
}
_ => {
let mut popup = SkillPopup::new(skills);
popup.set_query(&query);
self.active_popup = ActivePopup::Skill(popup);
}
}
}
fn set_has_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
}
#[allow(dead_code)]
pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option<String>) {
self.input_enabled = enabled;
self.input_disabled_placeholder = if enabled { None } else { placeholder };
// Avoid leaving interactive popups open while input is blocked.
if !enabled && !matches!(self.active_popup, ActivePopup::None) {
self.active_popup = ActivePopup::None;
}
}
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
}
pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) {
if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens
{
return;
}
self.context_window_percent = percent;
self.context_window_used_tokens = used_tokens;
}
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
self.esc_backtrack_hint = show;
if show {
self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
}
}
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if !self.input_enabled {
return None;
}
let [_, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
self.textarea
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
+ 2
+ match &self.active_popup {
ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
ActivePopup::Skill(c) => c.calculate_required_height(width),
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area);
match &self.active_popup {
ActivePopup::Command(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::File(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::Skill(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
let footer_props = self.footer_props();
let custom_height = self.custom_footer_height();
let footer_hint_height =
custom_height.unwrap_or_else(|| footer_height(footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([
Constraint::Length(footer_spacing),
Constraint::Length(footer_hint_height),
])
.areas(popup_rect);
hint_rect
} else {
popup_rect
};
if let Some(items) = self.footer_hint_override.as_ref() {
if !items.is_empty() {
let mut spans = Vec::with_capacity(items.len() * 4);
for (idx, (key, label)) in items.iter().enumerate() {
spans.push(" ".into());
spans.push(Span::styled(key.clone(), Style::default().bold()));
spans.push(format!(" {label}").into());
if idx + 1 != items.len() {
spans.push(" ".into());
}
}
let mut custom_rect = hint_rect;
if custom_rect.width > 2 {
custom_rect.x += 2;
custom_rect.width = custom_rect.width.saturating_sub(2);
}
Line::from(spans).render_ref(custom_rect, buf);
}
} else {
render_footer(hint_rect, buf, footer_props);
}
}
}
let style = user_message_style();
Block::default().style(style).render_ref(composer_rect, buf);
if !textarea_rect.is_empty() {
let prompt = if self.input_enabled {
"".bold()
} else {
"".dim()
};
buf.set_span(
textarea_rect.x - LIVE_PREFIX_COLS,
textarea_rect.y,
&prompt,
textarea_rect.width,
);
}
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
let text = if self.input_enabled {
self.placeholder_text.as_str().to_string()
} else {
self.input_disabled_placeholder
.as_deref()
.unwrap_or("Input disabled.")
.to_string()
};
let placeholder = Span::from(text).dim().italic();
Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
}
}
}
fn prompt_selection_action(
prompt: &CustomPrompt,
first_line: &str,
mode: PromptSelectionMode,
) -> PromptSelectionAction {
let named_args = prompt_argument_names(&prompt.content);
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
match mode {
PromptSelectionMode::Completion => {
if !named_args.is_empty() {
let (text, cursor) =
prompt_command_with_arg_placeholders(&prompt.name, &named_args);
return PromptSelectionAction::Insert {
text,
cursor: Some(cursor),
};
}
if has_numeric {
let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name);
return PromptSelectionAction::Insert { text, cursor: None };
}
let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name);
PromptSelectionAction::Insert { text, cursor: None }
}
PromptSelectionMode::Submit => {
if !named_args.is_empty() {
let (text, cursor) =
prompt_command_with_arg_placeholders(&prompt.name, &named_args);
return PromptSelectionAction::Insert {
text,
cursor: Some(cursor),
};
}
if has_numeric {
if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) {
return PromptSelectionAction::Submit { text: expanded };
}
let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name);
return PromptSelectionAction::Insert { text, cursor: None };
}
PromptSelectionAction::Submit {
text: prompt.content.clone(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::ImageBuffer;
use image::Rgba;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::tempdir;
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::AttachedImage;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line;
use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn footer_hint_row_is_separated_from_composer() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let row_to_string = |y: u16| {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
row
};
let mut hint_row: Option<(u16, String)> = None;
for y in 0..area.height {
let row = row_to_string(y);
if row.contains("? for shortcuts") {
hint_row = Some((y, row));
break;
}
}
let (hint_row_idx, hint_row_contents) =
hint_row.expect("expected footer hint row to be rendered");
assert_eq!(
hint_row_idx,
area.height - 1,
"hint row should occupy the bottom line: {hint_row_contents:?}",
);
assert!(
hint_row_idx > 0,
"expected a spacing row above the footer hints",
);
let spacing_row = row_to_string(hint_row_idx - 1);
assert_eq!(
spacing_row.trim(),
"",
"expected blank spacing row above hints but saw: {spacing_row:?}",
);
}
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
where
F: FnOnce(&mut ChatComposer),
{
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let width = 100;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
enhanced_keys_supported,
"Ask Codex to do anything".to_string(),
false,
);
setup(&mut composer);
let footer_props = composer.footer_props();
let footer_lines = footer_height(footer_props);
let footer_spacing = ChatComposer::footer_spacing(footer_lines);
let height = footer_lines + footer_spacing + 8;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend());
}
#[test]
fn footer_mode_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| {
composer.set_esc_backtrack_hint(true);
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
});
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
composer.set_task_running(true);
composer.set_ctrl_c_quit_hint(true, true);
});
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| {
composer.set_esc_backtrack_hint(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state(
"footer_mode_overlay_then_external_esc_hint",
true,
|composer| {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
composer.set_esc_backtrack_hint(true);
},
);
snapshot_composer_state("footer_mode_hidden_while_typing", true, |composer| {
type_chars_humanlike(composer, &['h']);
});
}
#[test]
fn esc_hint_stays_hidden_with_draft_content() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['d']);
assert!(!composer.is_empty());
assert_eq!(composer.current_text(), "d");
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert!(matches!(composer.active_popup, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert!(!composer.esc_backtrack_hint);
}
#[test]
fn clear_for_ctrl_c_records_cleared_draft() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("draft text".to_string());
assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string()));
assert!(composer.is_empty());
assert_eq!(
composer.history.navigate_up(&composer.app_event_tx),
Some("draft text".to_string())
);
}
#[test]
fn question_mark_only_toggles_on_first_char() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(needs_redraw, "toggling overlay should request redraw");
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
// Toggle back to prompt mode so subsequent typing captures characters.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
type_chars_humanlike(&mut composer, &['h']);
assert_eq!(composer.textarea.text(), "h");
assert_eq!(composer.footer_mode(), FooterMode::ContextOnly);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(needs_redraw, "typing should still mark the view dirty");
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "h?");
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert_eq!(composer.footer_mode(), FooterMode::ContextOnly);
}
#[test]
fn question_mark_does_not_toggle_during_paste_burst() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Force an active paste burst so this test doesn't depend on tight timing.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
for ch in ['h', 'i', '?', 't', 'h', 'e', 'r', 'e'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(composer.is_in_paste_burst());
assert_eq!(composer.textarea.text(), "");
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "hi?there");
assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay);
}
#[test]
fn shortcut_overlay_persists_while_task_running() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
composer.set_task_running(true);
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay);
}
#[test]
fn test_current_at_token_basic_cases() {
let test_cases = vec![
// Valid @ tokens
("@hello", 3, Some("hello".to_string()), "Basic ASCII token"),
(
"@file.txt",
4,
Some("file.txt".to_string()),
"ASCII with extension",
),
(
"hello @world test",
8,
Some("world".to_string()),
"ASCII token in middle",
),
(
"@test123",
5,
Some("test123".to_string()),
"ASCII with numbers",
),
// Unicode examples
("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"),
(
"@testЙЦУ.rs",
8,
Some("testЙЦУ.rs".to_string()),
"Mixed ASCII and Cyrillic",
),
("@诶", 2, Some("".to_string()), "Chinese character"),
("@👍", 2, Some("👍".to_string()), "Emoji token"),
// Invalid cases (should return None)
("hello", 2, None, "No @ symbol"),
(
"@",
1,
Some("".to_string()),
"Only @ symbol triggers empty query",
),
("@ hello", 2, None, "@ followed by space"),
("test @ world", 6, None, "@ with spaces around"),
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
result, expected,
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
);
}
}
#[test]
fn test_current_at_token_cursor_positions() {
let test_cases = vec![
// Different cursor positions within a token
("@test", 0, Some("test".to_string()), "Cursor at @"),
("@test", 1, Some("test".to_string()), "Cursor after @"),
("@test", 5, Some("test".to_string()), "Cursor at end"),
// Multiple tokens - cursor determines which token
("@file1 @file2", 0, Some("file1".to_string()), "First token"),
(
"@file1 @file2",
8,
Some("file2".to_string()),
"Second token",
),
// Edge cases
("@", 0, Some("".to_string()), "Only @ symbol"),
("@a", 2, Some("a".to_string()), "Single character after @"),
("", 0, None, "Empty input"),
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
result, expected,
"Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}",
);
}
}
#[test]
fn test_current_at_token_whitespace_boundaries() {
let test_cases = vec![
// Space boundaries
(
"aaa@aaa",
4,
None,
"Connected @ token - no completion by design",
),
(
"aaa @aaa",
5,
Some("aaa".to_string()),
"@ token after space",
),
(
"test @file.txt",
7,
Some("file.txt".to_string()),
"@ token after space",
),
// Full-width space boundaries
(
"test @İstanbul",
8,
Some("İstanbul".to_string()),
"@ token after full-width space",
),
(
"@ЙЦУ @诶",
10,
Some("".to_string()),
"Full-width space between Unicode tokens",
),
// Tab and newline boundaries
(
"test\t@file",
6,
Some("file".to_string()),
"@ token after tab",
),
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
result, expected,
"Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}",
);
}
}
#[test]
fn ascii_prefix_survives_non_ascii_followup() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "1あ"),
_ => panic!("expected Submitted"),
}
}
#[test]
fn non_ascii_char_inserts_immediately_without_burst_state() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "");
assert!(!composer.is_in_paste_burst());
}
// test a variety of non-ascii char sequences to ensure we are handling them correctly
#[test]
fn non_ascii_burst_handles_newline() {
let test_cases = [
// triggers on windows
"天地玄黄 宇宙洪荒
日月盈昃 辰宿列张
寒来暑往 秋收冬藏
你好世界 编码测试
汉字处理 UTF-8
终端显示 正确无误
风吹竹林 月照大江
白云千载 青山依旧
程序员 与 Unicode 同行",
// Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics.
"你 好\nhi",
];
for test_case in test_cases {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
for c in test_case.chars() {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
assert!(
composer.textarea.text().is_empty(),
"non-empty textarea before flush: {test_case}",
);
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), test_case);
}
}
#[test]
fn ascii_burst_treats_enter_as_newline() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Force an active burst so this test doesn't depend on tight timing.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(result, InputResult::None),
"Enter during a burst should insert newline, not submit"
);
for ch in ['t', 'h', 'e', 'r', 'e'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "hi\nthere");
}
#[test]
fn handle_paste_small_inserts_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw);
assert_eq!(composer.textarea.text(), "hello");
assert!(composer.pending_pastes.is_empty());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "hello"),
_ => panic!("expected Submitted"),
}
}
#[test]
fn empty_enter_returns_none() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Ensure composer is empty and press Enter.
assert!(composer.textarea.text().is_empty());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::None => {}
other => panic!("expected None for empty enter, got: {other:?}"),
}
}
#[test]
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
let needs_redraw = composer.handle_paste(large.clone());
assert!(needs_redraw);
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
assert_eq!(composer.textarea.text(), placeholder);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, placeholder);
assert_eq!(composer.pending_pastes[0].1, large);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, large),
_ => panic!("expected Submitted"),
}
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn edit_clears_pending_paste() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.handle_paste(large);
assert_eq!(composer.pending_pastes.len(), 1);
// Any edit that removes the placeholder should clear pending_paste
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn ui_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
let test_cases = vec![
("empty", None),
("small", Some("short".to_string())),
("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))),
("multiple_pastes", None),
("backspace_after_pastes", None),
];
for (name, input) in test_cases {
// Create a fresh composer for each test case
let mut composer = ChatComposer::new(
true,
sender.clone(),
false,
"Ask Codex to do anything".to_string(),
false,
);
if let Some(text) = input {
composer.handle_paste(text);
} else if name == "multiple_pastes" {
// First large paste
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3));
// Second large paste
composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7));
// Small paste
composer.handle_paste(" another short paste".to_string());
} else if name == "backspace_after_pastes" {
// Three large pastes
composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2));
composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4));
composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6));
// Move cursor to end and press backspace
composer.textarea.set_cursor(composer.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
insta::assert_snapshot!(name, terminal.backend());
}
}
#[test]
fn slash_popup_model_first_for_mo_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Type "/mo" humanlike so paste-burst doesnt interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let mut terminal = match Terminal::new(TestBackend::new(60, 5)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
// Visual snapshot should show the slash popup with /model as the first entry.
insta::assert_snapshot!("slash_popup_mo", terminal.backend());
}
#[test]
fn slash_popup_model_first_for_mo_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "model")
}
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt selected for '/mo'")
}
None => panic!("no selected command for '/mo'"),
},
_ => panic!("slash popup not active after typing '/mo'"),
}
}
#[test]
fn slash_popup_resume_for_res_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Type "/res" humanlike so paste-burst doesnt interfere.
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.expect("draw composer");
// Snapshot should show /resume as the first entry for /res.
insta::assert_snapshot!("slash_popup_res", terminal.backend());
}
#[test]
fn slash_popup_resume_for_res_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "resume")
}
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt selected for '/res'")
}
None => panic!("no selected command for '/res'"),
},
_ => panic!("slash popup not active after typing '/res'"),
}
}
fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool {
std::thread::sleep(PasteBurst::recommended_active_flush_delay());
composer.flush_paste_burst_if_due()
}
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
for &ch in chars {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let _ = composer.flush_paste_burst_if_due();
}
}
#[test]
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Type the slash command.
type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']);
// Press Enter to dispatch the selected command.
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// When a slash command is dispatched, the composer should return a
// Command result (not submit literal text) and clear its textarea.
match result {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "init");
}
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/init'"),
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = extract_positional_args_for_prompt_line(
"/prompts:review \"docs/My File.md\"",
"review",
);
assert_eq!(args, vec!["docs/My File.md".to_string()]);
}
#[test]
fn extract_args_supports_mixed_quoted_and_unquoted() {
let args =
extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd");
assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]);
}
#[test]
fn slash_tab_completion_moves_cursor_to_end() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'c']);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "/compact ");
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
}
#[test]
fn slash_tab_then_enter_dispatches_builtin_command() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Type a prefix and complete with Tab, which inserts a trailing space
// and moves the cursor beyond the '/name' token (hides the popup).
type_chars_humanlike(&mut composer, &['/', 'd', 'i']);
let (_res, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "/diff ");
// Press Enter: should dispatch the command, not submit literal text.
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
InputResult::None => panic!("expected Command result for '/diff'"),
}
assert!(composer.textarea.is_empty());
}
#[test]
fn slash_mention_dispatches_command_and_inserts_at() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "mention");
}
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/mention'"),
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
composer.insert_str("@");
assert_eq!(composer.textarea.text(), "@");
}
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Define test cases: (paste content, is_large)
let test_cases = [
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
(" and ".to_string(), false),
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
];
// Expected states after each paste
let mut expected_text = String::new();
let mut expected_pending_count = 0;
// Apply all pastes and build expected state
let states: Vec<_> = test_cases
.iter()
.map(|(content, is_large)| {
composer.handle_paste(content.clone());
if *is_large {
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
expected_text.push_str(&placeholder);
expected_pending_count += 1;
} else {
expected_text.push_str(content);
}
(expected_text.clone(), expected_pending_count)
})
.collect();
// Verify all intermediate states were correct
assert_eq!(
states,
vec![
(
format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()),
1
),
(
format!(
"[Pasted Content {} chars] and ",
test_cases[0].0.chars().count()
),
1
),
(
format!(
"[Pasted Content {} chars] and [Pasted Content {} chars]",
test_cases[0].0.chars().count(),
test_cases[2].0.chars().count()
),
2
),
]
);
// Submit and verify final expansion
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted(text) = result {
assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
} else {
panic!("expected Submitted");
}
}
#[test]
fn test_placeholder_deletion() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Define test cases: (content, is_large)
let test_cases = [
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
(" and ".to_string(), false),
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
];
// Apply all pastes
let mut current_pos = 0;
let states: Vec<_> = test_cases
.iter()
.map(|(content, is_large)| {
composer.handle_paste(content.clone());
if *is_large {
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
current_pos += placeholder.len();
} else {
current_pos += content.len();
}
(
composer.textarea.text().to_string(),
composer.pending_pastes.len(),
current_pos,
)
})
.collect();
// Delete placeholders one by one and collect states
let mut deletion_states = vec![];
// First deletion
composer.textarea.set_cursor(states[0].2);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.textarea.text().to_string(),
composer.pending_pastes.len(),
));
// Second deletion
composer.textarea.set_cursor(composer.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.textarea.text().to_string(),
composer.pending_pastes.len(),
));
// Verify all states
assert_eq!(
deletion_states,
vec![
(" and [Pasted Content 1006 chars]".to_string(), 1),
(" and ".to_string(), 0),
]
);
}
#[test]
fn deleting_duplicate_length_pastes_removes_only_target() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count());
let placeholder_second = format!("{placeholder_base} #2");
composer.handle_paste(paste.clone());
composer.handle_paste(paste.clone());
assert_eq!(
composer.textarea.text(),
format!("{placeholder_base}{placeholder_second}")
);
assert_eq!(composer.pending_pastes.len(), 2);
composer.textarea.set_cursor(composer.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), placeholder_base);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, placeholder_base);
assert_eq!(composer.pending_pastes[0].1, paste);
}
#[test]
fn large_paste_numbering_does_not_reuse_after_deletion() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let base = format!("[Pasted Content {} chars]", paste.chars().count());
let second = format!("{base} #2");
let third = format!("{base} #3");
composer.handle_paste(paste.clone());
composer.handle_paste(paste.clone());
assert_eq!(composer.textarea.text(), format!("{base}{second}"));
composer.textarea.set_cursor(base.len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), second);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, second);
composer.textarea.set_cursor(composer.textarea.text().len());
composer.handle_paste(paste);
assert_eq!(composer.textarea.text(), format!("{second}{third}"));
assert_eq!(composer.pending_pastes.len(), 2);
assert_eq!(composer.pending_pastes[0].0, second);
assert_eq!(composer.pending_pastes[1].0, third);
}
#[test]
fn test_partial_placeholder_deletion() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Define test cases: (cursor_position_from_end, expected_pending_count)
let test_cases = [
5, // Delete from middle - should clear tracking
0, // Delete from end - should clear tracking
];
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let placeholder = format!("[Pasted Content {} chars]", paste.chars().count());
let states: Vec<_> = test_cases
.into_iter()
.map(|pos_from_end| {
composer.handle_paste(paste.clone());
composer
.textarea
.set_cursor(placeholder.len() - pos_from_end);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let result = (
composer.textarea.text().contains(&placeholder),
composer.pending_pastes.len(),
);
composer.textarea.set_text("");
result
})
.collect();
assert_eq!(
states,
vec![
(false, 0), // After deleting from middle
(false, 0), // After deleting from end
]
);
}
// --- Image attachment tests ---
#[test]
fn attach_image_and_submit_includes_image_paths() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image1.png");
composer.attach_image(path.clone());
composer.handle_paste(" hi".into());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(vec![path], imgs);
}
#[test]
fn attach_image_without_text_submits_empty_text_and_images() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image2.png");
composer.attach_image(path.clone());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(imgs.len(), 1);
assert_eq!(imgs[0], path);
assert!(composer.attached_images.is_empty());
}
#[test]
fn duplicate_image_placeholders_get_suffix() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image_dup.png");
composer.attach_image(path.clone());
composer.handle_paste(" ".into());
composer.attach_image(path);
let text = composer.textarea.text().to_string();
assert!(text.contains("[Image #1]"));
assert!(text.contains("[Image #2]"));
assert_eq!(composer.attached_images[0].placeholder, "[Image #1]");
assert_eq!(composer.attached_images[1].placeholder, "[Image #2]");
}
#[test]
fn image_placeholder_backspace_behaves_like_text_placeholder() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image3.png");
composer.attach_image(path.clone());
let placeholder = composer.attached_images[0].placeholder.clone();
// Case 1: backspace at end
composer.textarea.move_cursor_to_end_of_line(false);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(!composer.textarea.text().contains(&placeholder));
assert!(composer.attached_images.is_empty());
// Re-add and test backspace in middle: should break the placeholder string
// and drop the image mapping (same as text placeholder behavior).
composer.attach_image(path);
let placeholder2 = composer.attached_images[0].placeholder.clone();
// Move cursor to roughly middle of placeholder
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
let mid_pos = start_pos + (placeholder2.len() / 2);
composer.textarea.set_cursor(mid_pos);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(!composer.textarea.text().contains(&placeholder2));
assert!(composer.attached_images.is_empty());
} else {
panic!("Placeholder not found in textarea");
}
}
#[test]
fn backspace_with_multibyte_text_before_placeholder_does_not_panic() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Insert an image placeholder at the start
let path = PathBuf::from("/tmp/image_multibyte.png");
composer.attach_image(path);
// Add multibyte text after the placeholder
composer.textarea.insert_str("日本語");
// Cursor is at end; pressing backspace should delete the last character
// without panicking and leave the placeholder intact.
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(composer.attached_images.len(), 1);
assert!(composer.textarea.text().starts_with("[Image #1]"));
}
#[test]
fn deleting_one_of_duplicate_image_placeholders_removes_one_entry() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path1 = PathBuf::from("/tmp/image_dup1.png");
let path2 = PathBuf::from("/tmp/image_dup2.png");
composer.attach_image(path1);
// separate placeholders with a space for clarity
composer.handle_paste(" ".into());
composer.attach_image(path2.clone());
let placeholder1 = composer.attached_images[0].placeholder.clone();
let placeholder2 = composer.attached_images[1].placeholder.clone();
let text = composer.textarea.text().to_string();
let start1 = text.find(&placeholder1).expect("first placeholder present");
let end1 = start1 + placeholder1.len();
composer.textarea.set_cursor(end1);
// Backspace should delete the first placeholder and its mapping.
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let new_text = composer.textarea.text().to_string();
assert_eq!(
1,
new_text.matches(&placeholder1).count(),
"one placeholder remains after deletion"
);
assert_eq!(
0,
new_text.matches(&placeholder2).count(),
"second placeholder was relabeled"
);
assert_eq!(
1,
new_text.matches("[Image #1]").count(),
"remaining placeholder relabeled to #1"
);
assert_eq!(
vec![AttachedImage {
path: path2,
placeholder: "[Image #1]".to_string()
}],
composer.attached_images,
"one image mapping remains"
);
}
#[test]
fn deleting_first_text_element_renumbers_following_text_element() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path1 = PathBuf::from("/tmp/image_first.png");
let path2 = PathBuf::from("/tmp/image_second.png");
// Insert two adjacent atomic elements.
composer.attach_image(path1);
composer.attach_image(path2.clone());
assert_eq!(composer.textarea.text(), "[Image #1][Image #2]");
assert_eq!(composer.attached_images.len(), 2);
// Delete the first element using normal textarea editing (Delete at cursor start).
composer.textarea.set_cursor(0);
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
// Remaining image should be renumbered and the textarea element updated.
assert_eq!(composer.attached_images.len(), 1);
assert_eq!(composer.attached_images[0].path, path2);
assert_eq!(composer.attached_images[0].placeholder, "[Image #1]");
assert_eq!(composer.textarea.text(), "[Image #1]");
}
#[test]
fn pasting_filepath_attaches_image() {
let tmp = tempdir().expect("create TempDir");
let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png");
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255]));
img.save(&tmp_path).expect("failed to write temp png");
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
assert!(needs_redraw);
assert!(composer.textarea.text().starts_with("[Image #1] "));
let imgs = composer.take_recent_submission_images();
assert_eq!(imgs, vec![tmp_path]);
}
#[test]
fn selecting_custom_prompt_without_args_submits_content() {
let prompt_text = "Hello from saved prompt";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Inject prompts as if received via event.
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
type_chars_humanlike(
&mut composer,
&[
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm',
'p', 't',
],
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::Submitted(prompt_text.to_string()), result);
assert!(composer.textarea.is_empty());
}
#[test]
fn custom_prompt_submission_expands_arguments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes on $BRANCH".to_string(),
description: None,
argument_hint: None,
}]);
composer
.textarea
.set_text("/prompts:my-prompt USER=Alice BRANCH=main");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
InputResult::Submitted("Review Alice changes on main".to_string()),
result
);
assert!(composer.textarea.is_empty());
}
#[test]
fn custom_prompt_submission_accepts_quoted_values() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Pair $USER with $BRANCH".to_string(),
description: None,
argument_hint: None,
}]);
composer
.textarea
.set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
InputResult::Submitted("Pair Alice Smith with dev-main".to_string()),
result
);
assert!(composer.textarea.is_empty());
}
#[test]
fn custom_prompt_with_large_paste_expands_correctly() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Create a custom prompt with positional args (no named args like $USER)
composer.set_custom_prompts(vec![CustomPrompt {
name: "code-review".to_string(),
path: "/tmp/code-review.md".to_string().into(),
content: "Please review the following code:\n\n$1".to_string(),
description: None,
argument_hint: None,
}]);
// Type the slash command
let command_text = "/prompts:code-review ";
composer.textarea.set_text(command_text);
composer.textarea.set_cursor(command_text.len());
// Paste large content (>3000 chars) to trigger placeholder
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3000);
composer.handle_paste(large_content.clone());
// Verify placeholder was created
let placeholder = format!("[Pasted Content {} chars]", large_content.chars().count());
assert_eq!(
composer.textarea.text(),
format!("/prompts:code-review {}", placeholder)
);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, placeholder);
assert_eq!(composer.pending_pastes[0].1, large_content);
// Submit by pressing Enter
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Verify the custom prompt was expanded with the large content as positional arg
match result {
InputResult::Submitted(text) => {
// The prompt should be expanded, with the large content replacing $1
assert_eq!(
text,
format!("Please review the following code:\n\n{}", large_content),
"Expected prompt expansion with large content as $1"
);
}
_ => panic!("expected Submitted, got: {result:?}"),
}
assert!(composer.textarea.is_empty());
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn slash_path_input_submits_without_command_error() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.textarea
.set_text("/Users/example/project/src/main.rs");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted(text) = result {
assert_eq!(text, "/Users/example/project/src/main.rs");
} else {
panic!("expected Submitted");
}
assert!(composer.textarea.is_empty());
match rx.try_recv() {
Ok(event) => panic!("unexpected event: {event:?}"),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(err) => panic!("unexpected channel state: {err:?}"),
}
}
#[test]
fn slash_with_leading_space_submits_as_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.textarea.set_text(" /this-looks-like-a-command");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted(text) = result {
assert_eq!(text, "/this-looks-like-a-command");
} else {
panic!("expected Submitted");
}
assert!(composer.textarea.is_empty());
match rx.try_recv() {
Ok(event) => panic!("unexpected event: {event:?}"),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(err) => panic!("unexpected channel state: {err:?}"),
}
}
#[test]
fn custom_prompt_invalid_args_reports_error() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes".to_string(),
description: None,
argument_hint: None,
}]);
composer
.textarea
.set_text("/prompts:my-prompt USER=Alice stray");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!(
"/prompts:my-prompt USER=Alice stray",
composer.textarea.text()
);
let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains("expected key=value"));
found_error = true;
break;
}
}
assert!(found_error, "expected error history cell to be sent");
}
#[test]
fn custom_prompt_missing_required_args_reports_error() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes on $BRANCH".to_string(),
description: None,
argument_hint: None,
}]);
// Provide only one of the required args
composer.textarea.set_text("/prompts:my-prompt USER=Alice");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!("/prompts:my-prompt USER=Alice", composer.textarea.text());
let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.to_lowercase().contains("missing required args"));
assert!(message.contains("BRANCH"));
found_error = true;
break;
}
}
assert!(
found_error,
"expected missing args error history cell to be sent"
);
}
#[test]
fn selecting_custom_prompt_with_args_expands_placeholders() {
// Support $1..$9 and $ARGUMENTS in prompt content.
let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
// Type the slash command with two args and hit Enter to submit.
type_chars_humanlike(
&mut composer,
&[
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm',
'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r',
],
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string();
assert_eq!(InputResult::Submitted(expected), result);
}
#[test]
fn numeric_prompt_positional_args_does_not_error() {
// Ensure that a prompt with only numeric placeholders does not trigger
// key=value parsing errors when given positional arguments.
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "elegant".to_string(),
path: "/tmp/elegant.md".to_string().into(),
content: "Echo: $ARGUMENTS".to_string(),
description: None,
argument_hint: None,
}]);
// Type positional args; should submit with numeric expansion, no errors.
composer.textarea.set_text("/prompts:elegant hi");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result);
assert!(composer.textarea.is_empty());
}
#[test]
fn selecting_custom_prompt_with_no_args_inserts_template() {
let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "p".to_string(),
path: "/tmp/p.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
type_chars_humanlike(
&mut composer,
&['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'],
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// With no args typed, selecting the prompt inserts the command template
// and does not submit immediately.
assert_eq!(InputResult::None, result);
assert_eq!("/prompts:p ", composer.textarea.text());
}
#[test]
fn selecting_custom_prompt_preserves_literal_dollar_dollar() {
// '$$' should remain untouched.
let prompt_text = "Cost: $$ and first: $1";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "price".to_string(),
path: "/tmp/price.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
type_chars_humanlike(
&mut composer,
&[
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x',
],
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
InputResult::Submitted("Cost: $$ and first: x".to_string()),
result
);
}
#[test]
fn selecting_custom_prompt_reuses_cached_arguments_join() {
let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "repeat".to_string(),
path: "/tmp/repeat.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
type_chars_humanlike(
&mut composer,
&[
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ',
'o', 'n', 'e', ' ', 't', 'w', 'o',
],
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let expected = "First: one two\nSecond: one two".to_string();
assert_eq!(InputResult::Submitted(expected), result);
}
#[test]
fn pending_first_ascii_char_flushes_as_typed() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert!(composer.textarea.text().is_empty());
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let flushed = composer.flush_paste_burst_if_due();
assert!(flushed, "expected pending first char to flush");
assert_eq!(composer.textarea.text(), "h");
assert!(!composer.is_in_paste_burst());
}
#[test]
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let count = 32;
for _ in 0..count {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert!(
composer.is_in_paste_burst(),
"expected active paste burst during fast typing"
);
assert!(
composer.textarea.text().is_empty(),
"text should not appear during burst"
);
}
assert!(
composer.textarea.text().is_empty(),
"text should remain empty until flush"
);
let flushed = flush_after_paste_burst(&mut composer);
assert!(flushed, "expected buffered text to flush after stop");
assert_eq!(composer.textarea.text(), "a".repeat(count));
assert!(
composer.pending_pastes.is_empty(),
"no placeholder for small burst"
);
}
#[test]
fn burst_paste_fast_large_inserts_placeholder_on_flush() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder
for _ in 0..count {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
}
// Nothing should appear until we stop and flush
assert!(composer.textarea.text().is_empty());
let flushed = flush_after_paste_burst(&mut composer);
assert!(flushed, "expected flush after stopping fast input");
let expected_placeholder = format!("[Pasted Content {count} chars]");
assert_eq!(composer.textarea.text(), expected_placeholder);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, expected_placeholder);
assert_eq!(composer.pending_pastes[0].1.len(), count);
assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x'));
}
#[test]
fn humanlike_typing_1000_chars_appears_live_no_placeholder() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config
let chars: Vec<char> = vec!['z'; count];
type_chars_humanlike(&mut composer, &chars);
assert_eq!(composer.textarea.text(), "z".repeat(count));
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn slash_popup_not_activated_for_slash_space_text_history_like_input() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Simulate history-like content: "/ test"
composer.set_text_content("/ test".to_string());
// After set_text_content -> sync_popups is called; popup should NOT be Command.
assert!(
matches!(composer.active_popup, ActivePopup::None),
"expected no slash popup for '/ test'"
);
// Up should be handled by history navigation path, not slash popup handler.
let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
}
#[test]
fn slash_popup_activated_for_bare_slash_and_valid_prefixes() {
// use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tokio::sync::mpsc::unbounded_channel;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Case 1: bare "/"
composer.set_text_content("/".to_string());
assert!(
matches!(composer.active_popup, ActivePopup::Command(_)),
"bare '/' should activate slash popup"
);
// Case 2: valid prefix "/re" (matches /review, /resume, etc.)
composer.set_text_content("/re".to_string());
assert!(
matches!(composer.active_popup, ActivePopup::Command(_)),
"'/re' should activate slash popup via prefix match"
);
// Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback)
composer.set_text_content("/ac".to_string());
assert!(
matches!(composer.active_popup, ActivePopup::Command(_)),
"'/ac' should activate slash popup via fuzzy match"
);
// Case 4: invalid prefix "/zzz" still allowed to open popup if it
// matches no built-in command; our current logic will not open popup.
// Verify that explicitly.
composer.set_text_content("/zzz".to_string());
assert!(
matches!(composer.active_popup, ActivePopup::None),
"'/zzz' should not activate slash popup because it is not a prefix of any built-in command"
);
}
#[test]
fn apply_external_edit_rebuilds_text_and_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = "[image 10x10]".to_string();
composer.textarea.insert_element(&placeholder);
composer.attached_images.push(AttachedImage {
placeholder: placeholder.clone(),
path: PathBuf::from("img.png"),
});
composer
.pending_pastes
.push(("[Pasted]".to_string(), "data".to_string()));
composer.apply_external_edit(format!("Edited {placeholder} text"));
assert_eq!(
composer.current_text(),
format!("Edited {placeholder} text")
);
assert!(composer.pending_pastes.is_empty());
assert_eq!(composer.attached_images.len(), 1);
assert_eq!(composer.attached_images[0].placeholder, placeholder);
assert_eq!(composer.textarea.cursor(), composer.current_text().len());
}
#[test]
fn apply_external_edit_drops_missing_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = "[image 10x10]".to_string();
composer.textarea.insert_element(&placeholder);
composer.attached_images.push(AttachedImage {
placeholder: placeholder.clone(),
path: PathBuf::from("img.png"),
});
composer.apply_external_edit("No images here".to_string());
assert_eq!(composer.current_text(), "No images here".to_string());
assert!(composer.attached_images.is_empty());
}
#[test]
fn current_text_with_pending_expands_placeholders() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = "[Pasted Content 5 chars]".to_string();
composer.textarea.insert_element(&placeholder);
composer
.pending_pastes
.push((placeholder.clone(), "hello".to_string()));
assert_eq!(
composer.current_text_with_pending(),
"hello".to_string(),
"placeholder should expand to actual text"
);
}
#[test]
fn apply_external_edit_limits_duplicates_to_occurrences() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = "[image 10x10]".to_string();
composer.textarea.insert_element(&placeholder);
composer.attached_images.push(AttachedImage {
placeholder: placeholder.clone(),
path: PathBuf::from("img.png"),
});
composer.apply_external_edit(format!("{placeholder} extra {placeholder}"));
assert_eq!(
composer.current_text(),
format!("{placeholder} extra {placeholder}")
);
assert_eq!(composer.attached_images.len(), 1);
}
#[test]
fn input_disabled_ignores_keypresses_and_hides_cursor() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("hello".to_string());
composer.set_input_enabled(false, Some("Input disabled for test.".to_string()));
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(!needs_redraw);
assert_eq!(composer.current_text(), "hello");
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 5,
};
assert_eq!(composer.cursor_pos(area), None);
}
}