use codex_core::protocol::TokenUsage; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; 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::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use codex_file_search::FileMatch; use std::cell::RefCell; /// 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. pub enum InputResult { Submitted(String), None, } struct TokenUsageInfo { total_token_usage: TokenUsage, last_token_usage: TokenUsage, model_context_window: Option, /// Baseline token count present in the context before the user's first /// message content is considered. This is used to normalize the /// "context left" percentage so it reflects the portion the user can /// influence rather than fixed prompt overhead (system prompt, tool /// instructions, etc.). /// /// Preferred source is `cached_input_tokens` from the first turn (when /// available), otherwise we fall back to 0. initial_prompt_tokens: u64, } pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, active_popup: ActivePopup, app_event_tx: AppEventSender, history: ChatComposerHistory, ctrl_c_quit_hint: bool, use_shift_enter_hint: bool, dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, token_usage_info: Option, has_focus: bool, placeholder_text: String, } /// Popup state – at most one can be visible at any time. enum ActivePopup { None, Command(CommandPopup), File(FileSearchPopup), } impl ChatComposer { pub fn new( has_input_focus: bool, app_event_tx: AppEventSender, enhanced_keys_supported: bool, placeholder_text: String, ) -> Self { let use_shift_enter_hint = enhanced_keys_supported; 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, use_shift_enter_hint, dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), token_usage_info: None, has_focus: has_input_focus, placeholder_text, } } pub fn desired_height(&self, width: u16) -> u16 { self.textarea.desired_height(width - 1) + match &self.active_popup { ActivePopup::None => 1u16, ActivePopup::Command(c) => c.calculate_required_height(), ActivePopup::File(c) => c.calculate_required_height(), } } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { let popup_height = match &self.active_popup { ActivePopup::Command(popup) => popup.calculate_required_height(), ActivePopup::File(popup) => popup.calculate_required_height(), ActivePopup::None => 1, }; let [textarea_rect, _] = Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area); let mut textarea_rect = textarea_rect; textarea_rect.width = textarea_rect.width.saturating_sub(1); textarea_rect.x += 1; let state = self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, &state) } /// Returns true if the composer currently contains no user input. pub(crate) fn is_empty(&self) -> bool { self.textarea.is_empty() } /// Update the cached *context-left* percentage and refresh the placeholder /// text. The UI relies on the placeholder to convey the remaining /// context when the composer is empty. pub(crate) fn set_token_usage( &mut self, total_token_usage: TokenUsage, last_token_usage: TokenUsage, model_context_window: Option, ) { let initial_prompt_tokens = self .token_usage_info .as_ref() .map(|info| info.initial_prompt_tokens) .unwrap_or_else(|| last_token_usage.cached_input_tokens.unwrap_or(0)); self.token_usage_info = Some(TokenUsageInfo { total_token_usage, last_token_usage, model_context_window, initial_prompt_tokens, }); } /// 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, ) -> bool { let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { return false; }; self.textarea.set_text(&text); self.textarea.set_cursor(0); 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 = format!("[Pasted Content {char_count} chars]"); self.textarea.insert_element(&placeholder); self.pending_pastes.push((placeholder, pasted)); } else { self.textarea.insert_str(&pasted); } self.sync_command_popup(); self.sync_file_search_popup(); true } /// Integrate results from an asynchronous file search. pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { // 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; self.set_has_focus(has_focus); } pub(crate) fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); self.sync_command_popup(); self.sync_file_search_popup(); } /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { 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::None => self.handle_key_event_without_popup(key_event), }; // Update (or hide/show) popup after processing the key. self.sync_command_popup(); if matches!(self.active_popup, ActivePopup::Command(_)) { self.dismissed_file_popup_token = None; } else { self.sync_file_search_popup(); } result } /// Handle key event when the slash-command popup is visible. fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let ActivePopup::Command(popup) = &mut self.active_popup else { unreachable!(); }; match key_event { KeyEvent { code: KeyCode::Up, .. } => { popup.move_up(); (InputResult::None, true) } KeyEvent { code: KeyCode::Down, .. } => { popup.move_down(); (InputResult::None, true) } KeyEvent { code: KeyCode::Tab, .. } => { if let Some(cmd) = popup.selected_command() { let first_line = self.textarea.text().lines().next().unwrap_or(""); let starts_with_cmd = first_line .trim_start() .starts_with(&format!("/{}", cmd.command())); if !starts_with_cmd { self.textarea.set_text(&format!("/{} ", cmd.command())); self.textarea.set_cursor(self.textarea.text().len()); } // After completing the command, move cursor to the end. if !self.textarea.text().is_empty() { let end = self.textarea.text().len(); self.textarea.set_cursor(end); } } (InputResult::None, true) } KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => { if let Some(cmd) = popup.selected_command() { // Send command to the app layer. self.app_event_tx.send(AppEvent::DispatchCommand(*cmd)); // Clear textarea so no residual text remains. self.textarea.set_text(""); // Hide popup since the command has been dispatched. self.active_popup = ActivePopup::None; 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), } } /// Handle key events when file search popup is visible. fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let ActivePopup::File(popup) = &mut self.active_popup else { unreachable!(); }; match key_event { KeyEvent { code: KeyCode::Up, .. } => { popup.move_up(); (InputResult::None, true) } KeyEvent { code: KeyCode::Down, .. } => { 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.to_string()); } self.active_popup = ActivePopup::None; (InputResult::None, true) } KeyEvent { code: KeyCode::Tab, .. } | KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => { if let Some(sel) = popup.selected_match() { let sel_path = sel.to_string(); // Drop popup borrow before using self mutably again. self.insert_selected_path(&sel_path); self.active_popup = ActivePopup::None; return (InputResult::None, true); } (InputResult::None, false) } input => self.handle_input_basic(input), } } /// Extract the `@token` that the cursor is currently positioned on, if any. /// /// The returned string **does not** include the leading `@`. /// /// Behavior: /// - The cursor may be anywhere *inside* the token (including on the /// leading `@`). 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 `@`, that token is /// returned without the leading `@`. This includes the case where the /// token is just "@" (empty query), which is used to trigger a UI hint fn current_at_token(textarea: &TextArea) -> Option { 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(|c| c.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(|c| c.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 left_at = token_left .filter(|t| t.starts_with('@')) .map(|t| t[1..].to_string()); let right_at = token_right .filter(|t| t.starts_with('@')) .map(|t| t[1..].to_string()); if at_whitespace { if right_at.is_some() { return right_at; } if token_left.is_some_and(|t| t == "@") { return None; } return left_at; } if after_cursor.starts_with('@') { return right_at.or(left_at); } left_at.or(right_at) } /// 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(); let before_cursor = &text[..cursor_offset]; let after_cursor = &text[cursor_offset..]; // 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 = cursor_offset + end_rel_idx; // 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) + path.len() + 1); new_text.push_str(&text[..start_idx]); new_text.push_str(path); new_text.push(' '); new_text.push_str(&text[end_idx..]); self.textarea.set_text(&new_text); let new_cursor = start_idx.saturating_add(path.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) { match key_event { // ------------------------------------------------------------- // 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, .. } => { 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), _ => unreachable!(), }; if let Some(text) = replace_text { self.textarea.set_text(&text); self.textarea.set_cursor(0); return (InputResult::None, true); } } self.handle_input_basic(key_event) } KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => { let mut text = self.textarea.text().to_string(); 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 text.is_empty() { (InputResult::None, true) } else { self.history.record_local_submission(&text); (InputResult::Submitted(text), true) } } input => self.handle_input_basic(input), } } /// Handle generic Input events that modify the textarea content. fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { // Normal input handling self.textarea.input(input); let text_after = self.textarea.text(); // Check if any placeholders were removed and remove their corresponding pending pastes self.pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); (InputResult::None, true) } /// 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) { let first_line = self.textarea.text().lines().next().unwrap_or(""); let input_starts_with_slash = first_line.starts_with('/'); match &mut self.active_popup { ActivePopup::Command(popup) => { if input_starts_with_slash { popup.on_composer_text_change(first_line.to_string()); } else { self.active_popup = ActivePopup::None; } } _ => { if input_starts_with_slash { let mut command_popup = CommandPopup::new(); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } } } } /// 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) { // Determine if there is an @token underneath the cursor. let query = match Self::current_at_token(&self.textarea) { Some(token) => token, None => { self.active_popup = ActivePopup::None; self.dismissed_file_popup_token = None; return; } }; // 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 set_has_focus(&mut self, has_focus: bool) { self.has_focus = has_focus; } } impl WidgetRef for &ChatComposer { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let popup_height = match &self.active_popup { ActivePopup::Command(popup) => popup.calculate_required_height(), ActivePopup::File(popup) => popup.calculate_required_height(), ActivePopup::None => 1, }; let [textarea_rect, popup_rect] = Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).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::None => { let bottom_line_rect = popup_rect; let key_hint_style = Style::default().fg(Color::Cyan); let mut hint = if self.ctrl_c_quit_hint { vec![ Span::from(" "), "Ctrl+C again".set_style(key_hint_style), Span::from(" to quit"), ] } else { let newline_hint_key = if self.use_shift_enter_hint { "Shift+⏎" } else { "Ctrl+J" }; vec![ Span::from(" "), "⏎".set_style(key_hint_style), Span::from(" send "), newline_hint_key.set_style(key_hint_style), Span::from(" newline "), "Ctrl+C".set_style(key_hint_style), Span::from(" quit"), ] }; // Append token/context usage info to the footer hints when available. if let Some(token_usage_info) = &self.token_usage_info { let token_usage = &token_usage_info.total_token_usage; hint.push(Span::from(" ")); hint.push( Span::from(format!("{} tokens used", token_usage.blended_total())) .style(Style::default().add_modifier(Modifier::DIM)), ); let last_token_usage = &token_usage_info.last_token_usage; if let Some(context_window) = token_usage_info.model_context_window { let percent_remaining: u8 = if context_window > 0 { last_token_usage.percent_of_context_window_remaining( context_window, token_usage_info.initial_prompt_tokens, ) } else { 100 }; hint.push(Span::from(" ")); hint.push( Span::from(format!("{percent_remaining}% context left")) .style(Style::default().add_modifier(Modifier::DIM)), ); } } Line::from(hint) .style(Style::default().dim()) .render_ref(bottom_line_rect, buf); } } let border_style = if self.has_focus { Style::default().fg(Color::Cyan) } else { Style::default().add_modifier(Modifier::DIM) }; Block::default() .borders(Borders::LEFT) .border_type(BorderType::QuadrantOutside) .border_style(border_style) .render_ref( Rect::new(textarea_rect.x, textarea_rect.y, 1, textarea_rect.height), buf, ); let mut textarea_rect = textarea_rect; textarea_rect.width = textarea_rect.width.saturating_sub(1); textarea_rect.x += 1; let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); if self.textarea.text().is_empty() { Line::from(self.placeholder_text.as_str()) .style(Style::default().dim()) .render_ref(textarea_rect.inner(Margin::new(1, 0)), buf); } } } #[cfg(test)] mod tests { 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::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::textarea::TextArea; #[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 handle_paste_small_inserts_text() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); 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 handle_paste_large_uses_placeholder_and_replaces_on_submit() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); 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) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); 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 insta::assert_snapshot; use ratatui::Terminal; use ratatui::backend::TestBackend; let (tx, _rx) = std::sync::mpsc::channel(); 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(), ); 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| f.render_widget_ref(&composer, f.area())) .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); assert_snapshot!(name, terminal.backend()); } } #[test] fn slash_init_dispatches_command_and_does_not_submit_literal_text() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use std::sync::mpsc::TryRecvError; let (tx, rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); // Type the slash command. for ch in [ '/', 'i', 'n', 'i', 't', // "/init" ] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } // 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 not submit // literal text and should clear its textarea. match result { InputResult::None => {} InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } } assert!(composer.textarea.is_empty(), "composer should be cleared"); // Verify a DispatchCommand event for the "init" command was sent. match rx.try_recv() { Ok(AppEvent::DispatchCommand(cmd)) => { assert_eq!(cmd.command(), "init"); } Ok(_other) => panic!("unexpected app event"), Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/init'"), Err(TryRecvError::Disconnected) => panic!("app event channel disconnected"), } } #[test] fn slash_tab_completion_moves_cursor_to_end() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); for ch in ['/', 'c'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } 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_mention_dispatches_command_and_inserts_at() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use std::sync::mpsc::TryRecvError; let (tx, rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::None => {} InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } } assert!(composer.textarea.is_empty(), "composer should be cleared"); match rx.try_recv() { Ok(AppEvent::DispatchCommand(cmd)) => { assert_eq!(cmd.command(), "mention"); composer.insert_str("@"); } Ok(_other) => panic!("unexpected app event"), Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/mention'"), Err(TryRecvError::Disconnected) => { panic!("app event channel disconnected") } } 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) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); // 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) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); // 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 test_partial_placeholder_deletion() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); // 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) as usize); 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 ] ); } }