//! The chat composer history module owns shell-style recall and incremental search traversal.//!//! It combines persistent cross-session entries with local in-session entries into one offset//! space. Persistent entries are fetched lazily and re-enter this state machine through//! [`ChatComposerHistory::on_entry_response`], while local entries are already available with full//! draft metadata.//!//! Ctrl+R search is modeled separately from normal Up/Down navigation because it has different//! guarantees: query edits restart from the newest match, repeated Older/Newer keys move through//! unique matching text, pending persistent fetches continue the same scan after the response//! arrives, and boundary hits must not advance hidden cursor state. Search deduplication is scoped//! to a single active search session and uses exact prompt text; it does not mutate stored history//! or change normal history browsing.use std::collections::HashMap;use std::collections::HashSet;use std::path::PathBuf;use crate::app_event::AppEvent;use crate::app_event_sender::AppEventSender;use crate::bottom_pane::MentionBinding;use crate::mention_codec::decode_history_mentions_with_at_mentions;use codex_protocol::ThreadId;use codex_protocol::user_input::TextElement;/// A composer history entry that can rehydrate draft state.#[derive(Debug, Clone, PartialEq)]pub(crate) struct HistoryEntry { /// Raw text stored in history (may include placeholder strings). pub(crate) text: String, /// Text element ranges for placeholders inside `text`. pub(crate) text_elements: Vec<TextElement>, /// Local image paths captured alongside `text_elements`. pub(crate) local_image_paths: Vec<PathBuf>, /// Remote image URLs restored with this draft. pub(crate) remote_image_urls: Vec<String>, /// Mention bindings for tool/app/skill references inside `text`. pub(crate) mention_bindings: Vec<MentionBinding>, /// Placeholder-to-payload pairs used to restore large paste content. pub(crate) pending_pastes: Vec<(String, String)>,}impl HistoryEntry { /// Creates a text-only history entry and decodes persisted mention bindings. /// /// Persistent history does not store attachment payloads or text-element metadata, so this /// constructor intentionally leaves those fields empty. Local in-session submissions should be /// recorded with the full `HistoryEntry` value built by the composer; using `new` for a local /// image or paste submission would make recall lose placeholder ownership. pub(crate) fn new(text: String) -> Self { Self::new_with_at_mentions(text, /*at_mentions_enabled*/ true) } pub(crate) fn new_with_at_mentions(text: String, at_mentions_enabled: bool) -> Self { let decoded = decode_history_mentions_with_at_mentions(&text, at_mentions_enabled); Self { text: decoded.text, text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), mention_bindings: decoded .mentions .into_iter() .map(|mention| MentionBinding { sigil: mention.sigil, mention: mention.mention, path: mention.path, }) .collect(), pending_pastes: Vec::new(), } } #[cfg(test)] pub(crate) fn with_pending( text: String, text_elements: Vec<TextElement>, local_image_paths: Vec<PathBuf>, pending_pastes: Vec<(String, String)>, ) -> Self { Self { text, text_elements, local_image_paths, remote_image_urls: Vec::new(), mention_bindings: Vec::new(), pending_pastes, } } #[cfg(test)] pub(crate) fn with_pending_and_remote( text: String, text_elements: Vec<TextElement>, local_image_paths: Vec<PathBuf>, pending_pastes: Vec<(String, String)>, remote_image_urls: Vec<String>, ) -> Self { Self { text, text_elements, local_image_paths, remote_image_urls, mention_bindings: Vec::new(), pending_pastes, } }}/// State machine that manages shell-style history navigation (Up/Down) inside/// the chat composer. This struct is intentionally decoupled from the/// rendering widget so the logic remains isolated and easier to test.pub(crate) struct ChatComposerHistory { /// Thread that owns persistent lookup responses for this metadata snapshot. thread_id: Option<ThreadId>, /// Identifier of the persistent history log used for stale lookup rejection. persistent_log_id: Option<u64>, /// Number of entries already present in the persistent cross-session /// history file when the session started. persistent_entry_count: usize, /// Messages submitted by the user *during this UI session* (newest at END). /// Local entries retain full draft state (text elements, image paths, pending pastes, remote image URLs). local_history: Vec<HistoryEntry>, /// Local entries seeded from resumed transcript replay. replay_seeded_history: Vec<HistoryEntry>, /// Cache of persistent history entries fetched on-demand (text-only). fetched_history: HashMap<usize, HistoryEntry>, /// Current cursor within the combined (persistent + local) history. `None` /// indicates the user is *not* currently browsing history. history_cursor: Option<isize>, pending_navigation_direction: Option<HistorySearchDirection>, /// The text that was last inserted into the composer as a result of /// history navigation. Used to decide if further Up/Down presses should be /// treated as navigation versus normal cursor movement, together with the /// "cursor at line boundary" check in [`Self::should_handle_navigation`]. last_history_text: Option<String>, /// Active incremental history search, if Ctrl+R search mode is open. search: Option<HistorySearchState>, /// Whether persistent history restore should rehydrate `@` tool mentions. at_mention_restore_enabled: bool,}#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub(crate) enum HistorySearchDirection { /// Traverse toward older history offsets. Older, /// Traverse toward newer history offsets. Newer,}/// Result of a single incremental history search step.////// `Pending` means a persistent entry lookup has been requested and the caller should keep the/// visible search session open until [`ChatComposerHistory::on_entry_response`] supplies the next/// result. `AtBoundary` means the current selected match is still valid but the requested direction/// has no further unique match; callers should avoid treating it like a query miss.#[derive(Clone, Debug, PartialEq)]pub(crate) enum HistorySearchResult { Found(HistoryEntry), Pending, AtBoundary, NotFound,}/// Result of integrating an asynchronous persistent history response.////// A response can satisfy normal Up/Down navigation, resume a pending Ctrl+R search scan, or be/// ignored if it belongs to a stale log or an offset the composer no longer needs.#[derive(Clone, Debug, PartialEq)]pub(crate) enum HistoryEntryResponse { Found(HistoryEntry), Search(HistorySearchResult), Ignored,}/// State for one active Ctrl+R search query.////// The state keeps two cursors: `selected_offset` is the raw combined-history offset used to/// continue scanning, while `selected_match_index` points into `unique_matches` so already/// discovered unique results can be revisited without rescanning duplicate offsets. `seen_texts`/// intentionally keys on exact prompt text because the UI previews and accepts text, not the/// storage identity of each historical record.#[derive(Clone, Debug)]struct HistorySearchState { query: String, query_lower: String, selected_offset: Option<usize>, unique_matches: Vec<UniqueHistoryMatch>, selected_match_index: Option<usize>, seen_texts: HashSet<String>, awaiting: Option<PendingHistorySearch>, exhausted_older: bool, exhausted_newer: bool,}/// A unique search match cached with enough draft state to be selected again.////// The vector of these matches is kept in newest-to-oldest offset order. Storing the entry beside/// the offset avoids depending on later cache lookups when the user moves Newer/Older among matches/// that have already been discovered.#[derive(Clone, Debug)]struct UniqueHistoryMatch { offset: usize, entry: HistoryEntry,}/// Persistent-history lookup currently blocking an incremental search scan.////// The pending request records the direction and boundary behavior that were active when the fetch/// was issued so the response can either return a unique match or continue scanning as if no async/// gap had occurred.#[derive(Clone, Copy, Debug)]struct PendingHistorySearch { offset: usize, direction: HistorySearchDirection, boundary_if_exhausted: bool,}impl ChatComposerHistory { /// Creates an empty history state machine with no persistent metadata. /// /// The caller must provide session metadata before cross-session history can be fetched, but /// local in-session entries can still be recorded and traversed. Keeping construction cheap and /// metadata-free lets the composer reset and reuse this helper across session lifecycles. pub fn new() -> Self { Self { thread_id: None, persistent_log_id: None, persistent_entry_count: 0, local_history: Vec::new(), replay_seeded_history: Vec::new(), fetched_history: HashMap::new(), history_cursor: None, pending_navigation_direction: None, last_history_text: None, search: None, at_mention_restore_enabled: false, } } pub fn set_at_mention_restore_enabled(&mut self, enabled: bool) { if self.at_mention_restore_enabled == enabled { return; } self.at_mention_restore_enabled = enabled; self.fetched_history.clear(); self.history_cursor = None; self.last_history_text = None; self.search = None; } /// Updates persistent history metadata when a new session is configured. /// /// This clears fetched entries, local entries, navigation cursors, and active search state /// because offsets only make sense within one history log snapshot. Reusing old offsets after a /// log-id change would allow a stale async response to hydrate the wrong prompt. pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) { self.thread_id = Some(thread_id); self.persistent_log_id = Some(log_id); self.persistent_entry_count = entry_count; self.fetched_history.clear(); self.local_history.clear(); self.replay_seeded_history.clear(); self.history_cursor = None; self.pending_navigation_direction = None; self.last_history_text = None; self.search = None; } /// Records a current-session submission so it can be recalled with full draft metadata. /// /// Empty submissions are ignored, adjacent duplicates are collapsed, and active navigation or /// search state is reset because a new newest entry changes the combined history offset space. pub fn record_local_submission(&mut self, entry: HistoryEntry) { self.record_local_submission_inner(entry); } pub fn record_replayed_submission(&mut self, entry: HistoryEntry) { if self.record_local_submission_inner(entry.clone()) { self.replay_seeded_history.push(entry); } } fn record_local_submission_inner(&mut self, entry: HistoryEntry) -> bool { if entry.text.is_empty() && entry.text_elements.is_empty() && entry.local_image_paths.is_empty() && entry.remote_image_urls.is_empty() && entry.mention_bindings.is_empty() && entry.pending_pastes.is_empty() { return false; } self.history_cursor = None; self.pending_navigation_direction = None; self.last_history_text = None; self.search = None; // Avoid inserting a duplicate if identical to the previous entry. if self.local_history.last().is_some_and(|prev| prev == &entry) { return false; } self.local_history.push(entry); true } /// Resets normal history navigation so the next Up key resumes from the newest entry. /// /// This also clears any active incremental search, since normal browsing and Ctrl+R search /// maintain different cursor semantics. Failing to clear search here would let an old query /// influence later Up/Down recall. pub fn reset_navigation(&mut self) { self.history_cursor = None; self.pending_navigation_direction = None; self.last_history_text = None; self.search = None; } /// Clears only the active incremental search state. /// /// The normal Up/Down navigation cursor and cached persistent entries are left intact. Composer /// search mode calls this when it accepts a match or returns to an empty query so the next /// search starts with a fresh unique-result cache. pub fn reset_search(&mut self) { self.search = None; } /// Returns whether Up/Down should navigate history for the current textarea state. /// /// Empty text always enables history traversal. For non-empty text, this requires both: /// /// - the current text exactly matching the last recalled history entry, and /// - the cursor being at a line boundary (start or end). /// /// This boundary gate keeps multiline cursor movement usable while preserving shell-like /// history recall. If callers moved the cursor into the middle of a recalled entry and still /// forced navigation, users would lose normal vertical movement within the draft. pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { if self.persistent_entry_count == 0 && self.local_history.is_empty() { return false; } if text.is_empty() { return true; } // Textarea is not empty – only navigate when text matches the last // recalled history entry and the cursor is at a line boundary. This // keeps shell-like Up/Down recall working while still allowing normal // multiline cursor movement from interior positions. if cursor != 0 && cursor != text.len() { return false; } matches!(&self.last_history_text, Some(prev) if prev == text) } /// Handles Up by moving toward older entries in the combined history space. /// /// Local entries can be returned immediately, while missing persistent entries emit a /// `LookupMessageHistoryEntry` and return `None` until the response arrives. Calling this while /// Ctrl+R search is active intentionally exits search traversal. pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option<HistoryEntry> { self.search = None; let total_entries = self.persistent_entry_count + self.local_history.len(); if total_entries == 0 { return None; } let next_idx = match self.history_cursor { None => (total_entries as isize) - 1, Some(0) => return None, // already at oldest Some(idx) => idx - 1, }; self.history_cursor = Some(next_idx); self.populate_history_at_index( next_idx as usize, HistorySearchDirection::Older, app_event_tx, ) } /// Handles Down by moving toward newer entries or clearing the composer past the newest entry. /// /// Returning an empty `HistoryEntry` means the user moved past the newest known entry and the /// caller should clear the composer draft. As with Up, invoking this during Ctrl+R search clears /// search state and resumes normal shell-style browsing. pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option<HistoryEntry> { self.search = None; let total_entries = self.persistent_entry_count + self.local_history.len(); if total_entries == 0 { return None; } let next_idx_opt = match self.history_cursor { None => return None, // not browsing Some(idx) if (idx as usize) + 1 >= total_entries => None, Some(idx) => Some(idx + 1), }; match next_idx_opt { Some(idx) => { self.history_cursor = Some(idx); self.populate_history_at_index( idx as usize, HistorySearchDirection::Newer, app_event_tx, ) } None => { // Past newest – clear and exit browsing mode. self.history_cursor = None; self.pending_navigation_direction = None; self.last_history_text = None; Some(HistoryEntry::new(String::new())) } } } /// Integrates a persistent history entry response into navigation or active search. /// /// Responses with a stale log id are ignored, matching responses update the persistent cache, /// and pending Ctrl+R searches resume their scan from the returned offset. The caller should /// route `HistoryEntryResponse::Search` back to the composer search session rather than normal /// history recall; otherwise an async search hit could be accepted without updating footer /// status or match highlighting. pub fn on_entry_response( &mut self, log_id: u64, offset: usize, entry: Option<String>, app_event_tx: &AppEventSender, ) -> HistoryEntryResponse { if self.persistent_log_id != Some(log_id) { return HistoryEntryResponse::Ignored; } let entry = entry.map(|entry| { HistoryEntry::new_with_at_mentions(entry, self.at_mention_restore_enabled) }); if let Some(entry) = entry.clone() { self.fetched_history.insert(offset, entry); } if self .search .as_ref() .and_then(|search| search.awaiting) .is_some_and(|pending| pending.offset == offset) { let pending = self .search .as_ref() .and_then(|search| search.awaiting) .unwrap_or(PendingHistorySearch { offset, direction: HistorySearchDirection::Older, boundary_if_exhausted: false, }); if let Some(entry) = entry && self.search_matches(&entry) && self.search_result_is_unique(&entry) { return HistoryEntryResponse::Search(self.search_match(offset, entry)); } return HistoryEntryResponse::Search(self.advance_search_after( offset, pending.direction, pending.boundary_if_exhausted, app_event_tx, )); } if self.history_cursor == Some(offset as isize) { let direction = self.pending_navigation_direction.take(); let Some(entry) = entry else { return HistoryEntryResponse::Ignored; }; if self.persistent_entry_duplicates_local(&entry) && let Some(direction) = direction { let Some(offset) = self.next_history_offset(offset, direction) else { return HistoryEntryResponse::Ignored; }; self.history_cursor = Some(offset as isize); return self .populate_history_at_index(offset, direction, app_event_tx) .map(HistoryEntryResponse::Found) .unwrap_or(HistoryEntryResponse::Ignored); } self.last_history_text = Some(entry.text.clone()); return HistoryEntryResponse::Found(entry); } HistoryEntryResponse::Ignored } /// Advance the active Ctrl+R search and return the next visible search state. /// /// Callers pass `restart` after opening search or editing the query; that clears the unique /// match cache and starts from the end of combined history. Repeated calls with the same query /// and `restart == false` move relative to the current unique match, preserving the selected /// entry at boundaries. Calling this while a previous persistent lookup is still pending will /// keep returning `Pending`; otherwise a stale response could race with a newer user action and /// replace the composer with an unexpected entry. pub fn search( &mut self, query: &str, direction: HistorySearchDirection, restart: bool, app_event_tx: &AppEventSender, ) -> HistorySearchResult { let total_entries = self.total_entries(); if total_entries == 0 { self.search = Some(HistorySearchState::new(query)); return HistorySearchResult::NotFound; } let query_changed = self .search .as_ref() .is_none_or(|search| search.query != query); if !query_changed && !restart && self .search .as_ref() .and_then(|search| search.awaiting) .is_some() { return HistorySearchResult::Pending; } if query_changed || restart || self.search.is_none() { self.search = Some(HistorySearchState::new(query)); } else if let Some(search) = self.search.as_mut() { search.awaiting = None; } let boundary_if_exhausted = !restart && self .search .as_ref() .and_then(|search| search.selected_offset) .is_some(); if !restart && !query_changed && let Some(result) = self.select_cached_unique_match(direction) { return result; } if boundary_if_exhausted && self .search .as_ref() .is_some_and(|search| search.is_exhausted(direction)) { return HistorySearchResult::AtBoundary; } let start_offset = self.search_start_offset(total_entries, direction, query_changed || restart); let Some(start_offset) = start_offset else { return self.exhausted_search_result(direction, boundary_if_exhausted); }; let result = self.advance_search_from(start_offset, direction, boundary_if_exhausted, app_event_tx); if matches!(result, HistorySearchResult::NotFound) { self.exhausted_search_result(direction, boundary_if_exhausted) } else { result } } // --------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------- fn total_entries(&self) -> usize { self.persistent_entry_count + self.local_history.len() } fn search_start_offset( &self, total_entries: usize, direction: HistorySearchDirection, restart: bool, ) -> Option<usize> { let selected = self .search .as_ref() .and_then(|search| search.selected_offset); match direction { HistorySearchDirection::Older => { if restart { total_entries.checked_sub(1) } else { selected.and_then(|offset| offset.checked_sub(1)) } } HistorySearchDirection::Newer => { if restart { Some(0) } else { selected .and_then(|offset| offset.checked_add(1)) .filter(|offset| *offset < total_entries) } } } } fn advance_search_after( &mut self, offset: usize, direction: HistorySearchDirection, boundary_if_exhausted: bool, app_event_tx: &AppEventSender, ) -> HistorySearchResult { let next_offset = match direction { HistorySearchDirection::Older => offset.checked_sub(1), HistorySearchDirection::Newer => offset .checked_add(1) .filter(|next| *next < self.total_entries()), }; let Some(next_offset) = next_offset else { return self.exhausted_search_result(direction, boundary_if_exhausted); }; let result = self.advance_search_from(next_offset, direction, boundary_if_exhausted, app_event_tx); if matches!(result, HistorySearchResult::NotFound) { self.exhausted_search_result(direction, boundary_if_exhausted) } else { result } } fn advance_search_from( &mut self, mut offset: usize, direction: HistorySearchDirection, boundary_if_exhausted: bool, app_event_tx: &AppEventSender, ) -> HistorySearchResult { let total_entries = self.total_entries(); while offset < total_entries { if let Some(entry) = self.entry_at_cached_offset(offset) { if self.search_matches(&entry) && self.search_result_is_unique(&entry) { return self.search_match(offset, entry); } } else if offset < self.persistent_entry_count && let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { if let Some(search) = self.search.as_mut() { search.awaiting = Some(PendingHistorySearch { offset, direction, boundary_if_exhausted, }); } app_event_tx.send(AppEvent::LookupMessageHistoryEntry { thread_id, offset, log_id, }); return HistorySearchResult::Pending; } let next_offset = match direction { HistorySearchDirection::Older => offset.checked_sub(1), HistorySearchDirection::Newer => { offset.checked_add(1).filter(|next| *next < total_entries) } }; let Some(next_offset) = next_offset else { return HistorySearchResult::NotFound; }; offset = next_offset; } HistorySearchResult::NotFound } fn entry_at_cached_offset(&self, offset: usize) -> Option<HistoryEntry> { if offset >= self.persistent_entry_count { self.local_history .get(offset - self.persistent_entry_count) .cloned() } else { self.fetched_history.get(&offset).cloned() } } fn search_matches(&self, entry: &HistoryEntry) -> bool { let Some(search) = self.search.as_ref() else { return false; }; search.query.is_empty() || entry.text.to_lowercase().contains(&search.query_lower) } fn search_result_is_unique(&self, entry: &HistoryEntry) -> bool { self.search .as_ref() .is_none_or(|search| !search.seen_texts.contains(entry.text.as_str())) } fn search_match(&mut self, offset: usize, entry: HistoryEntry) -> HistorySearchResult { self.history_cursor = Some(offset as isize); self.last_history_text = Some(entry.text.clone()); if let Some(search) = self.search.as_mut() { search.selected_offset = Some(offset); search.record_match(offset, &entry); search.awaiting = None; search.exhausted_older = false; search.exhausted_newer = false; } HistorySearchResult::Found(entry) } fn select_cached_unique_match( &mut self, direction: HistorySearchDirection, ) -> Option<HistorySearchResult> { let next_index = { let search = self.search.as_ref()?; let selected_index = search.selected_match_index?; match direction { HistorySearchDirection::Older => { let next_index = selected_index + 1; (next_index < search.unique_matches.len()).then_some(next_index)? } HistorySearchDirection::Newer => selected_index.checked_sub(1)?, } }; let history_match = self.search.as_ref()?.unique_matches[next_index].clone(); self.history_cursor = Some(history_match.offset as isize); self.last_history_text = Some(history_match.entry.text.clone()); if let Some(search) = self.search.as_mut() { search.select_match(next_index); } Some(HistorySearchResult::Found(history_match.entry)) } fn exhausted_search_result( &mut self, direction: HistorySearchDirection, boundary_if_exhausted: bool, ) -> HistorySearchResult { if let Some(search) = self.search.as_mut() { search.awaiting = None; if boundary_if_exhausted { search.mark_exhausted(direction); } } if boundary_if_exhausted { HistorySearchResult::AtBoundary } else { HistorySearchResult::NotFound } } fn populate_history_at_index( &mut self, global_idx: usize, direction: HistorySearchDirection, app_event_tx: &AppEventSender, ) -> Option<HistoryEntry> { let mut global_idx = global_idx; loop { if let Some(entry) = self.entry_at_cached_offset(global_idx) { if global_idx < self.persistent_entry_count && self.persistent_entry_duplicates_local(&entry) { let Some(next_idx) = self.next_history_offset(global_idx, direction) else { self.pending_navigation_direction = None; return None; }; self.history_cursor = Some(next_idx as isize); global_idx = next_idx; continue; } self.pending_navigation_direction = None; self.last_history_text = Some(entry.text.clone()); return Some(entry); } if global_idx >= self.persistent_entry_count { return None; } if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { self.pending_navigation_direction = Some(direction); app_event_tx.send(AppEvent::LookupMessageHistoryEntry { thread_id, offset: global_idx, log_id, }); } return None; } } fn next_history_offset( &self, offset: usize, direction: HistorySearchDirection, ) -> Option<usize> { match direction { HistorySearchDirection::Older => offset.checked_sub(1), HistorySearchDirection::Newer => offset .checked_add(1) .filter(|next| *next < self.total_entries()), } } fn persistent_entry_duplicates_local(&self, entry: &HistoryEntry) -> bool { self.replay_seeded_history.iter().any(|local_entry| { local_entry.text == entry.text && local_entry.mention_bindings == entry.mention_bindings }) }}impl HistorySearchState { fn new(query: &str) -> Self { Self { query: query.to_string(), query_lower: query.to_lowercase(), selected_offset: None, unique_matches: Vec::new(), selected_match_index: None, seen_texts: HashSet::new(), awaiting: None, exhausted_older: false, exhausted_newer: false, } } fn is_exhausted(&self, direction: HistorySearchDirection) -> bool { match direction { HistorySearchDirection::Older => self.exhausted_older, HistorySearchDirection::Newer => self.exhausted_newer, } } fn mark_exhausted(&mut self, direction: HistorySearchDirection) { match direction { HistorySearchDirection::Older => self.exhausted_older = true, HistorySearchDirection::Newer => self.exhausted_newer = true, } } fn record_match(&mut self, offset: usize, entry: &HistoryEntry) { if let Some(index) = self .unique_matches .iter() .position(|history_match| history_match.offset == offset) { self.select_match(index); return; } self.seen_texts.insert(entry.text.clone()); let insert_index = self .unique_matches .partition_point(|history_match| history_match.offset > offset); self.unique_matches.insert( insert_index, UniqueHistoryMatch { offset, entry: entry.clone(), }, ); self.select_match(insert_index); } fn select_match(&mut self, index: usize) { let Some(history_match) = self.unique_matches.get(index) else { return; }; self.selected_offset = Some(history_match.offset); self.selected_match_index = Some(index); self.awaiting = None; self.exhausted_older = false; self.exhausted_newer = false; }}#[cfg(test)]mod tests { use super::*; use crate::app_event::AppEvent; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; fn test_thread_id() -> ThreadId { ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") .expect("thread id should parse") } #[test] fn duplicate_submissions_are_not_recorded() { let mut history = ChatComposerHistory::new(); // Empty submissions are ignored. history.record_local_submission(HistoryEntry::new(String::new())); assert_eq!(history.local_history.len(), 0); // First entry is recorded. history.record_local_submission(HistoryEntry::new("hello".to_string())); assert_eq!(history.local_history.len(), 1); assert_eq!( history.local_history.last().unwrap(), &HistoryEntry::new("hello".to_string()) ); // Identical consecutive entry is skipped. history.record_local_submission(HistoryEntry::new("hello".to_string())); assert_eq!(history.local_history.len(), 1); // Different entry is recorded. history.record_local_submission(HistoryEntry::new("world".to_string())); assert_eq!(history.local_history.len(), 2); assert_eq!( history.local_history.last().unwrap(), &HistoryEntry::new("world".to_string()) ); } #[test] fn persistent_restore_gates_at_mentions() { let (tx, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); history.set_metadata(test_thread_id(), /*log_id*/ 42, /*entry_count*/ 1); assert!(history.navigate_up(&tx).is_none()); let disabled = history.on_entry_response( /*log_id*/ 42, /*offset*/ 0, Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()), &tx, ); assert_eq!( disabled, HistoryEntryResponse::Found(HistoryEntry { text: "$sample and $figma".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), mention_bindings: vec![ MentionBinding { sigil: '$', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }, MentionBinding { sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }, ], pending_pastes: Vec::new(), }) ); history.set_at_mention_restore_enabled(/*enabled*/ true); assert!(history.navigate_up(&tx).is_none()); let enabled = history.on_entry_response( /*log_id*/ 42, /*offset*/ 0, Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()), &tx, ); assert_eq!( enabled, HistoryEntryResponse::Found(HistoryEntry { text: "@sample and $figma".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), mention_bindings: vec![ MentionBinding { sigil: '@', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }, MentionBinding { sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }, ], pending_pastes: Vec::new(), }) ); } #[test] fn navigation_with_async_fetch() { let (tx, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); // Pretend there are 3 persistent entries. let thread_id = test_thread_id(); history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); history.record_local_submission(HistoryEntry::new("latest".to_string())); // First Up should recall current-session local history. assert!(history.should_handle_navigation("", /*cursor*/ 0)); assert_eq!( Some(HistoryEntry::new("latest".to_string())), history.navigate_up(&tx) ); // Next Up should request offset 2 and await async data. assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet // Verify that a history lookup request was sent. let event = rx.try_recv().expect("expected AppEvent to be sent"); let AppEvent::LookupMessageHistoryEntry { thread_id: response_thread_id, offset, log_id, } = event else { panic!("unexpected event variant"); }; assert_eq!(response_thread_id, thread_id); assert_eq!(offset, 2); assert_eq!(log_id, 1); // Inject the async response. assert_eq!( HistoryEntryResponse::Found(HistoryEntry::new("latest".to_string())), history.on_entry_response( /*log_id*/ 1, /*offset*/ 2, Some("latest".into()), &tx ) ); // Next Up should move to offset 1. assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet // Verify second lookup request for offset 1. let event2 = rx.try_recv().expect("expected second event"); let AppEvent::LookupMessageHistoryEntry { thread_id: response_thread_id, offset, log_id, } = event2 else { panic!("unexpected event variant"); }; assert_eq!(response_thread_id, thread_id); assert_eq!(offset, 1); assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Found(HistoryEntry::new("older".to_string())), history.on_entry_response( /*log_id*/ 1, /*offset*/ 1, Some("older".into()), &tx ) ); } #[test] fn search_matches_local_history_and_stops_at_boundaries() { let (tx, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); history.record_local_submission(HistoryEntry::new("git status".to_string())); history.record_local_submission(HistoryEntry::new("cargo test -p codex-tui".to_string())); history.record_local_submission(HistoryEntry::new("git diff".to_string())); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), history.search( "git", HistorySearchDirection::Older, /*restart*/ true, &tx ) ); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), history.search( "git", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); assert_eq!( HistorySearchResult::AtBoundary, history.search( "git", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); assert_eq!( HistorySearchResult::AtBoundary, history.search( "git", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), history.search( "git", HistorySearchDirection::Newer, /*restart*/ false, &tx ) ); assert_eq!( HistorySearchResult::AtBoundary, history.search( "git", HistorySearchDirection::Newer, /*restart*/ false, &tx ) ); } #[test] fn search_skips_duplicate_local_matches() { let (tx, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); history.record_local_submission(HistoryEntry::new("git status".to_string())); history.record_local_submission(HistoryEntry::new("cargo test -p codex-tui".to_string())); history.record_local_submission(HistoryEntry::new("git status".to_string())); history.record_local_submission(HistoryEntry::new("git diff".to_string())); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), history.search( "git", HistorySearchDirection::Older, /*restart*/ true, &tx ) ); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), history.search( "git", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); assert_eq!( HistorySearchResult::AtBoundary, history.search( "git", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), history.search( "git", HistorySearchDirection::Newer, /*restart*/ false, &tx ) ); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), history.search( "git", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); } #[test] fn repeated_boundary_search_does_not_refetch_persistent_history() { let (tx, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); assert_eq!( HistorySearchResult::Pending, history.search( "needle", HistorySearchDirection::Older, /*restart*/ true, &tx ) ); let _ = rx.try_recv().expect("expected latest lookup"); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( "needle latest".to_string() ))), history.on_entry_response( /*log_id*/ 1, /*offset*/ 2, Some("needle latest".into()), &tx, ) ); assert_eq!( HistorySearchResult::Pending, history.search( "needle", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); let _ = rx.try_recv().expect("expected next older lookup"); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Pending), history.on_entry_response( /*log_id*/ 1, /*offset*/ 1, Some("not a match".into()), &tx, ) ); let _ = rx.try_recv().expect("expected oldest lookup"); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::AtBoundary), history.on_entry_response( /*log_id*/ 1, /*offset*/ 0, Some("also not a match".into()), &tx, ) ); assert!(rx.try_recv().is_err()); assert_eq!( HistorySearchResult::AtBoundary, history.search( "needle", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); assert!(rx.try_recv().is_err()); } #[test] fn search_fetches_persistent_history_until_match() { let (tx, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); let thread_id = test_thread_id(); history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); assert_eq!( HistorySearchResult::Pending, history.search( "older", HistorySearchDirection::Older, /*restart*/ true, &tx ) ); let AppEvent::LookupMessageHistoryEntry { thread_id: response_thread_id, offset, log_id, } = rx.try_recv().expect("expected latest lookup") else { panic!("unexpected event variant"); }; assert_eq!(response_thread_id, thread_id); assert_eq!(offset, 2); assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Pending), history.on_entry_response( /*log_id*/ 1, /*offset*/ 2, Some("latest".into()), &tx ) ); let AppEvent::LookupMessageHistoryEntry { thread_id: response_thread_id, offset, log_id, } = rx.try_recv().expect("expected next lookup") else { panic!("unexpected event variant"); }; assert_eq!(response_thread_id, thread_id); assert_eq!(offset, 1); assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( "older command".to_string() ))), history.on_entry_response( /*log_id*/ 1, /*offset*/ 1, Some("older command".into()), &tx ) ); } #[test] fn search_skips_duplicate_persistent_matches() { let (tx, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 4); assert_eq!( HistorySearchResult::Pending, history.search( "needle", HistorySearchDirection::Older, /*restart*/ true, &tx ) ); let _ = rx.try_recv().expect("expected latest lookup"); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( "needle same".to_string() ))), history.on_entry_response( /*log_id*/ 1, /*offset*/ 3, Some("needle same".into()), &tx, ) ); assert_eq!( HistorySearchResult::Pending, history.search( "needle", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); let _ = rx.try_recv().expect("expected duplicate lookup"); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Pending), history.on_entry_response( /*log_id*/ 1, /*offset*/ 2, Some("needle same".into()), &tx, ) ); let _ = rx.try_recv().expect("expected next lookup after duplicate"); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Pending), history.on_entry_response( /*log_id*/ 1, /*offset*/ 1, Some("not a match".into()), &tx, ) ); let _ = rx.try_recv().expect("expected oldest lookup"); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( "needle older".to_string() ))), history.on_entry_response( /*log_id*/ 1, /*offset*/ 0, Some("needle older".into()), &tx, ) ); assert_eq!( HistorySearchResult::AtBoundary, history.search( "needle", HistorySearchDirection::Older, /*restart*/ false, &tx ) ); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("needle same".to_string())), history.search( "needle", HistorySearchDirection::Newer, /*restart*/ false, &tx ) ); } #[test] fn search_is_case_insensitive_and_empty_query_finds_latest() { let (tx, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); history.record_local_submission(HistoryEntry::new("Build Release".to_string())); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("Build Release".to_string())), history.search( "release", HistorySearchDirection::Older, /*restart*/ true, &tx ) ); assert_eq!( HistorySearchResult::Found(HistoryEntry::new("Build Release".to_string())), history.search( "", HistorySearchDirection::Older, /*restart*/ true, &tx ) ); } #[test] fn reset_navigation_resets_cursor() { let (tx, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); history .fetched_history .insert(1, HistoryEntry::new("command2".to_string())); history .fetched_history .insert(2, HistoryEntry::new("command3".to_string())); assert_eq!( Some(HistoryEntry::new("command3".to_string())), history.navigate_up(&tx) ); assert_eq!( Some(HistoryEntry::new("command2".to_string())), history.navigate_up(&tx) ); history.reset_navigation(); assert!(history.history_cursor.is_none()); assert!(history.last_history_text.is_none()); assert_eq!( Some(HistoryEntry::new("command3".to_string())), history.navigate_up(&tx) ); } #[test] fn should_handle_navigation_when_cursor_is_at_line_boundaries() { let mut history = ChatComposerHistory::new(); history.record_local_submission(HistoryEntry::new("hello".to_string())); history.last_history_text = Some("hello".to_string()); assert!(history.should_handle_navigation("hello", /*cursor*/ 0)); assert!(history.should_handle_navigation("hello", "hello".len())); assert!(!history.should_handle_navigation("hello", /*cursor*/ 1)); assert!(!history.should_handle_navigation("other", /*cursor*/ 0)); }}