Codex Handbook
tui/src/bottom_pane/chat_composer_history.rs 1505 lines
//! 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));    }}