Codex Handbook
tui/src/bottom_pane/chat_composer/history_search.rs 968 lines
//! Composer-side Ctrl+R reverse history search state and rendering helpers.//!//! The persistent and local history stores live in `chat_composer_history`, but the composer owns//! the active search session because it has to snapshot/restore the editable draft, preview matches//! in the textarea, and render the footer prompt while the footer line is acting as the search//! input.//!//! This module is responsible for the UI-facing lifecycle of a search session: recognizing the//! keys that enter and drive search mode, keeping the footer query separate from the textarea//! preview, restoring the original draft on cancellation or misses, and translating history search//! results into composer-visible state. It deliberately does not decide which history entries//! match, how duplicate results are skipped, or when persistent history should be fetched; those//! traversal invariants stay with `ChatComposerHistory`.//!//! A search session starts idle with an empty footer query, so opening Ctrl+R never previews the//! latest history entry by itself. Typing a query restarts traversal from newest to oldest,//! repeated Ctrl+R/Up and Ctrl+S/Down move between unique matches, `Enter` accepts the current//! preview as an editable draft, and `Esc` or Ctrl+C restores the exact draft that existed before//! search started.use std::ops::Range;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyEventKind;use crossterm::event::KeyModifiers;use ratatui::layout::Constraint;use ratatui::layout::Layout;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::text::Span;use super::super::chat_composer_history::HistorySearchDirection;use super::super::chat_composer_history::HistorySearchResult;use super::super::footer::footer_height;use super::super::footer::reset_mode_after_activity;use super::ActivePopup;use super::ChatComposer;use super::ComposerDraft;use super::InputResult;use crate::app_event::AppEvent;use crate::key_hint;use crate::key_hint::KeyBinding;use crate::key_hint::KeyBindingListExt;use crate::key_hint::has_ctrl_or_alt;use crate::ui_consts::FOOTER_INDENT_COLS;/// Active composer-owned state for one Ctrl+R search interaction.////// The session is created only by [`ChatComposer::begin_history_search`] and is cleared only by/// accepting, canceling, or replacing the search mode. It stores the original draft separately from/// the footer query so transient previews never destroy the user's in-progress composer content.#[derive(Clone, Debug)]pub(super) struct HistorySearchSession {    /// Draft to restore when search is canceled or a query has no match.    original_draft: ComposerDraft,    /// Footer-owned query text typed while Ctrl+R search is active.    query: String,    /// User-visible search status used to choose footer hints and composer preview behavior.    status: HistorySearchStatus,}/// User-visible phase of the active Ctrl+R search session.////// Search keeps the footer query and the composer preview separate: `Idle` leaves the original/// draft untouched, `Searching` waits for persistent history, `Match` previews a found entry, and/// `NoMatch` restores the original draft while leaving the search input open for more typing.#[derive(Clone, Debug)]enum HistorySearchStatus {    Idle,    Searching,    Match,    NoMatch,}impl ChatComposer {    #[cfg(test)]    pub(super) fn history_search_active(&self) -> bool {        self.history_search.is_some()    }    /// Returns whether a key event should open reverse history search or step to an older match.    ///    /// The check accepts both normal Ctrl+R reports and the raw control character variant that    /// some terminals emit. Callers should only use this before generic text handling; treating the    /// raw control character as ordinary input would insert an invisible byte into the search query    /// or composer draft.    pub(super) fn is_history_search_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool {        bindings.is_pressed(*key_event)    }    fn is_history_search_forward_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool {        bindings.is_pressed(*key_event)    }    /// Opens footer-owned reverse history search without previewing history yet.    ///    /// Entering search mode first flushes pending paste-burst text, then snapshots the full    /// composer draft, clears any file/search popup state, and resets history traversal. The first    /// visible match is produced only after the footer query becomes non-empty, which keeps Ctrl+R    /// from replacing an empty composer with the latest prompt before the user has searched for    /// anything.    pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) {        if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {            self.handle_paste(pasted);        }        self.draft.paste_burst.clear_window_after_non_char();        if self.popups.current_file_query.is_some() {            self.app_event_tx                .send(AppEvent::StartFileSearch(String::new()));            self.popups.current_file_query = None;        }        self.popups.active = ActivePopup::None;        self.attachments.clear_remote_image_selection();        self.history_search = Some(HistorySearchSession {            original_draft: self.snapshot_draft(),            query: String::new(),            status: HistorySearchStatus::Idle,        });        self.history.reset_search();        (InputResult::None, true)    }    /// Handles every key while the footer is acting as the history search input.    ///    /// The method consumes search-mode keys before normal composer editing sees them. It guarantees    /// that `Esc` and Ctrl+C restore the original draft, `Enter` only accepts an actual match, plain    /// characters edit the footer query, and navigation keys delegate traversal to    /// `ChatComposerHistory`. Calling this when no search session exists is harmless for ignored    /// keys but would make query-edit branches no-op, so route here only after    /// `history_search.is_some()` has been established.    pub(super) fn handle_history_search_key(&mut self, key_event: KeyEvent) -> (InputResult, bool) {        if key_event.kind == KeyEventKind::Release {            return (InputResult::None, false);        }        if Self::is_history_search_key(&key_event, &self.history_search_previous_keys)            || matches!(key_event.code, KeyCode::Up)        {            let result = self.history_search_in_direction(HistorySearchDirection::Older);            return (result, true);        }        if Self::is_history_search_forward_key(&key_event, &self.history_search_next_keys)            || matches!(key_event.code, KeyCode::Down)        {            let result = self.history_search_in_direction(HistorySearchDirection::Newer);            return (result, true);        }        match key_event {            KeyEvent {                code: KeyCode::Esc, ..            } => {                self.cancel_history_search();                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Char(c),                modifiers,                ..            } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => {                self.cancel_history_search();                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Char('\u{0003}'),                modifiers: KeyModifiers::NONE,                ..            } => {                self.cancel_history_search();                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Enter,                modifiers: KeyModifiers::NONE,                ..            } => {                if self                    .history_search                    .as_ref()                    .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match))                {                    self.history_search = None;                    self.history.reset_search();                    self.footer.mode = reset_mode_after_activity(self.footer.mode);                    self.move_cursor_to_end();                }                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Backspace,                ..            }            | KeyEvent {                code: KeyCode::Char('h'),                modifiers: KeyModifiers::CONTROL,                ..            } => {                if let Some(search) = self.history_search.as_ref() {                    let mut query = search.query.clone();                    query.pop();                    self.update_history_search_query(query);                }                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Char('u'),                modifiers: KeyModifiers::CONTROL,                ..            } => {                self.update_history_search_query(String::new());                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Char(ch),                modifiers,                ..            } if !has_ctrl_or_alt(modifiers) => {                if let Some(search) = self.history_search.as_ref() {                    let mut query = search.query.clone();                    query.push(ch);                    self.update_history_search_query(query);                }                (InputResult::None, true)            }            _ => (InputResult::None, true),        }    }    fn history_search_in_direction(&mut self, direction: HistorySearchDirection) -> InputResult {        let Some((query, original_draft)) = self            .history_search            .as_ref()            .map(|search| (search.query.clone(), search.original_draft.clone()))        else {            return InputResult::None;        };        if query.is_empty() {            self.history.reset_search();            if let Some(search) = self.history_search.as_mut() {                search.status = HistorySearchStatus::Idle;            }            self.restore_draft(original_draft);            return InputResult::None;        }        let result = self.history.search(            &query,            direction,            /*restart*/ false,            &self.app_event_tx,        );        self.apply_history_search_result(result);        InputResult::None    }    fn update_history_search_query(&mut self, query: String) {        let Some(original_draft) = self            .history_search            .as_ref()            .map(|search| search.original_draft.clone())        else {            return;        };        if let Some(search) = self.history_search.as_mut() {            search.query = query.clone();            search.status = HistorySearchStatus::Searching;        }        self.restore_draft(original_draft);        if query.is_empty() {            self.history.reset_search();            if let Some(search) = self.history_search.as_mut() {                search.status = HistorySearchStatus::Idle;            }            return;        }        let result = self.history.search(            &query,            HistorySearchDirection::Older,            /*restart*/ true,            &self.app_event_tx,        );        self.apply_history_search_result(result);    }    /// Cancels active history search and restores the draft from before search mode opened.    ///    /// This clears normal history navigation as well as search traversal because previewing a match    /// temporarily updates the shared history cursor. Callers that handle global cancellation, such    /// as Ctrl+C, should use the boolean result to consume the key without also clearing the    /// restored draft or triggering quit/interrupt behavior.    pub(crate) fn cancel_history_search(&mut self) -> bool {        let Some(search) = self.history_search.take() else {            return false;        };        self.history.reset_navigation();        self.footer.mode = reset_mode_after_activity(self.footer.mode);        self.restore_draft(search.original_draft);        true    }    /// Applies a traversal result to the composer preview and search status.    ///    /// `Found` previews the matching entry, `Pending` keeps the footer in a waiting state while an    /// async persistent entry lookup is outstanding, `AtBoundary` preserves the current match, and    /// `NotFound` restores the original draft while keeping the query available for further edits.    /// Treating `AtBoundary` like `NotFound` would produce the visible "no match" flicker at the    /// end of a one-result search and desynchronize Up/Down counts.    pub(super) fn apply_history_search_result(&mut self, result: HistorySearchResult) {        match result {            HistorySearchResult::Found(entry) => {                if let Some(search) = self.history_search.as_mut() {                    search.status = HistorySearchStatus::Match;                }                self.apply_history_entry(entry);            }            HistorySearchResult::Pending => {                if let Some(search) = self.history_search.as_mut() {                    search.status = HistorySearchStatus::Searching;                }            }            HistorySearchResult::AtBoundary => {                if let Some(search) = self.history_search.as_mut() {                    search.status = HistorySearchStatus::Match;                }            }            HistorySearchResult::NotFound => {                let original_draft = self                    .history_search                    .as_ref()                    .map(|search| search.original_draft.clone());                if let Some(search) = self.history_search.as_mut() {                    search.status = HistorySearchStatus::NoMatch;                }                if let Some(original_draft) = original_draft {                    self.restore_draft(original_draft);                }            }        }    }    /// Builds the footer line shown while reverse history search is active.    ///    /// The footer displays the query as the editable field and uses the status to decide whether    /// to show searching, match actions, or no-match feedback. The line is intentionally separate    /// from cursor placement so rendering can fall back to normal footer layout if a small terminal    /// cannot allocate a distinct hint row.    pub(super) fn history_search_footer_line(&self) -> Option<Line<'static>> {        let search = self.history_search.as_ref()?;        let mut line = Line::from(vec![            "reverse-i-search: ".dim(),            search.query.clone().cyan(),        ]);        match search.status {            HistorySearchStatus::Idle => {}            HistorySearchStatus::Searching => line.push_span("  searching".dim()),            HistorySearchStatus::Match => {                line.push_span("  ".dim());                line.push_span(Self::history_search_action_key_span(KeyCode::Enter));                line.push_span(" accept".dim());                line.push_span(" · ".dim());                line.push_span(Self::history_search_action_key_span(KeyCode::Esc));                line.push_span(" cancel".dim());            }            HistorySearchStatus::NoMatch => line.push_span("  no match".red()),        }        Some(line)    }    fn history_search_action_key_span(key: KeyCode) -> Span<'static> {        Span::from(key_hint::plain(key)).cyan().bold().not_dim()    }    /// Returns byte ranges that should be highlighted in the current composer preview.    ///    /// Highlights are only exposed while a matched history entry is being previewed. Once the user    /// accepts with `Enter`, the search session is cleared and this returns an empty set so the    /// accepted text becomes an ordinary editable draft again.    pub(super) fn history_search_highlight_ranges(&self) -> Vec<Range<usize>> {        let Some(search) = self.history_search.as_ref() else {            return Vec::new();        };        if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() {            return Vec::new();        }        Self::case_insensitive_match_ranges(self.draft.textarea.text(), &search.query)    }    fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec<Range<usize>> {        if query.is_empty() {            return Vec::new();        }        let query_lower = query            .chars()            .flat_map(char::to_lowercase)            .collect::<String>();        if query_lower.is_empty() {            return Vec::new();        }        let mut folded = String::new();        let mut folded_spans: Vec<(Range<usize>, Range<usize>)> = Vec::new();        for (original_start, ch) in text.char_indices() {            let original_range = original_start..original_start + ch.len_utf8();            for lower in ch.to_lowercase() {                let folded_start = folded.len();                folded.push(lower);                folded_spans.push((folded_start..folded.len(), original_range.clone()));            }        }        let mut ranges = Vec::new();        let mut search_from = 0;        while search_from <= folded.len()            && let Some(relative_start) = folded[search_from..].find(&query_lower)        {            let folded_start = search_from + relative_start;            let folded_end = folded_start + query_lower.len();            if let Some((_, first_original)) = folded_spans.iter().find(|(folded_range, _)| {                folded_range.end > folded_start && folded_range.start < folded_end            }) {                let original_end = folded_spans                    .iter()                    .rev()                    .find(|(folded_range, _)| {                        folded_range.end > folded_start && folded_range.start < folded_end                    })                    .map(|(_, original_range)| original_range.end)                    .unwrap_or(first_original.end);                ranges.push(first_original.start..original_end);            }            search_from = folded_end;        }        ranges    }    /// Returns the screen cursor position for the footer query when search mode is active.    ///    /// The cursor tracks the end of the footer query rather than the textarea preview. If the    /// footer area is collapsed or too narrow, the x coordinate is clamped inside the hint rect so    /// terminal backends do not receive an off-screen cursor position.    pub(super) fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {        let search = self.history_search.as_ref()?;        let [_, _, _, popup_rect] = self.layout_areas(area);        if popup_rect.is_empty() {            return None;        }        let footer_props = self.footer_props();        let footer_hint_height = self            .custom_footer_height()            .unwrap_or_else(|| footer_height(&footer_props));        let footer_spacing = Self::footer_spacing(footer_hint_height);        let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {            let [_, hint_rect] = Layout::vertical([                Constraint::Length(footer_spacing),                Constraint::Length(footer_hint_height),            ])            .areas(popup_rect);            hint_rect        } else {            popup_rect        };        if hint_rect.is_empty() {            return None;        }        let prompt_width = Line::from("reverse-i-search: ").width() as u16;        let query_width = Line::from(search.query.clone()).width() as u16;        let desired_x = hint_rect            .x            .saturating_add(FOOTER_INDENT_COLS as u16)            .saturating_add(prompt_width)            .saturating_add(query_width);        let max_x = hint_rect            .x            .saturating_add(hint_rect.width.saturating_sub(1));        Some((desired_x.min(max_x), hint_rect.y))    }}#[cfg(test)]mod tests {    use crossterm::event::KeyCode;    use crossterm::event::KeyEvent;    use crossterm::event::KeyModifiers;    use pretty_assertions::assert_eq;    use ratatui::buffer::Buffer;    use ratatui::layout::Rect;    use ratatui::style::Modifier;    use tokio::sync::mpsc::unbounded_channel;    use super::super::super::chat_composer_history::HistoryEntry;    use super::super::super::footer::FooterMode;    use super::super::ChatComposer;    use super::HistorySearchStatus;    use crate::app_event::AppEvent;    use crate::app_event_sender::AppEventSender;    use crate::render::renderable::Renderable;    #[test]    fn history_search_opens_without_previewing_latest_entry() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer            .history            .record_local_submission(HistoryEntry::new("remembered command".to_string()));        composer.set_text_content(String::new(), Vec::new(), Vec::new());        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        assert!(composer.history_search_active());        assert!(composer.draft.textarea.is_empty());        assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);    }    #[test]    fn history_search_match_ranges_are_case_insensitive() {        assert_eq!(            ChatComposer::case_insensitive_match_ranges("git status git", "GIT"),            vec![0..3, 11..14]        );        assert_eq!(            ChatComposer::case_insensitive_match_ranges("aİ i", "i"),            vec![1..3, 4..5]        );        assert!(ChatComposer::case_insensitive_match_ranges("git", "").is_empty());    }    #[test]    fn history_search_accepts_matching_entry() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer            .history            .record_local_submission(HistoryEntry::new("git status".to_string()));        composer            .history            .record_local_submission(HistoryEntry::new("cargo test".to_string()));        composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        assert!(composer.history_search_active());        assert_eq!(composer.draft.textarea.text(), "draft");        for ch in ['g', 'i', 't'] {            let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));        }        assert_eq!(composer.draft.textarea.text(), "git status");        assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));        assert!(!composer.history_search_active());        assert_eq!(composer.draft.textarea.text(), "git status");        assert_eq!(            composer.draft.textarea.cursor(),            composer.draft.textarea.text().len()        );    }    #[test]    fn vim_normal_history_search_preview_places_cursor_on_last_char() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer            .history            .record_local_submission(HistoryEntry::new("git status".to_string()));        composer.set_vim_enabled(/*enabled*/ true);        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        for ch in ['g', 'i', 't'] {            let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));        }        assert_eq!(composer.draft.textarea.text(), "git status");        assert_eq!(composer.draft.textarea.cursor(), "git status".len() - 1);        assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);    }    #[test]    fn history_search_stays_on_single_match_at_boundaries() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer.history.record_local_submission(HistoryEntry::new(            "Find and fix a bug in @filename".to_string(),        ));        composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        for ch in ['b', 'u', 'g'] {            let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));        }        assert_eq!(            composer.draft.textarea.text(),            "Find and fix a bug in @filename"        );        for _ in 0..3 {            let _ =                composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        }        assert_eq!(            composer.draft.textarea.text(),            "Find and fix a bug in @filename"        );        assert!(            composer                .history_search                .as_ref()                .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match))        );        for _ in 0..3 {            let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));        }        assert_eq!(            composer.draft.textarea.text(),            "Find and fix a bug in @filename"        );        assert!(            composer                .history_search                .as_ref()                .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match))        );    }    #[test]    fn history_search_footer_action_hints_are_emphasized() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ true,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer            .history            .record_local_submission(HistoryEntry::new("cargo test".to_string()));        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));        let line = composer            .history_search_footer_line()            .expect("expected history search footer line");        assert_eq!(            line.spans                .iter()                .map(|span| span.content.as_ref())                .collect::<Vec<_>>(),            vec![                "reverse-i-search: ",                "c",                "  ",                "enter",                " accept",                " · ",                "esc",                " cancel"            ]        );        let query_style = line.spans[1].style;        assert_eq!(query_style.fg, Some(ratatui::style::Color::Cyan));        let enter_style = line.spans[3].style;        assert_eq!(enter_style.fg, Some(ratatui::style::Color::Cyan));        assert!(enter_style.add_modifier.contains(Modifier::BOLD));        assert!(enter_style.sub_modifier.contains(Modifier::DIM));        let accept_style = line.spans[4].style;        assert!(accept_style.add_modifier.contains(Modifier::DIM));        let separator_style = line.spans[5].style;        assert!(separator_style.add_modifier.contains(Modifier::DIM));        let esc_style = line.spans[6].style;        assert_eq!(esc_style.fg, Some(ratatui::style::Color::Cyan));        assert!(esc_style.add_modifier.contains(Modifier::BOLD));        assert!(esc_style.sub_modifier.contains(Modifier::DIM));        let cancel_style = line.spans[7].style;        assert!(cancel_style.add_modifier.contains(Modifier::DIM));    }    #[test]    fn history_search_highlights_matches_until_accepted() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ true,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer            .history            .record_local_submission(HistoryEntry::new("cargo test".to_string()));        composer            .history            .record_local_submission(HistoryEntry::new("git status".to_string()));        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        for ch in ['g', 'i', 't'] {            let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));        }        let area = Rect::new(0, 0, 60, 8);        let [_, _, textarea_rect, _] = composer.layout_areas(area);        let mut buf = Buffer::empty(area);        composer.render(area, &mut buf);        let x = textarea_rect.x;        let y = textarea_rect.y;        assert_eq!(buf[(x, y)].symbol(), "g");        for offset in 0..3 {            let modifier = buf[(x + offset, y)].style().add_modifier;            assert!(modifier.contains(Modifier::REVERSED));            assert!(modifier.contains(Modifier::BOLD));        }        assert!(            !buf[(x + 3, y)]                .style()                .add_modifier                .contains(Modifier::REVERSED)        );        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));        let [_, _, accepted_textarea_rect, _] = composer.layout_areas(area);        let mut accepted_buf = Buffer::empty(area);        composer.render(area, &mut accepted_buf);        for offset in 0..3 {            let modifier = accepted_buf                [(accepted_textarea_rect.x + offset, accepted_textarea_rect.y)]                .style()                .add_modifier;            assert!(!modifier.contains(Modifier::REVERSED));            assert!(!modifier.contains(Modifier::BOLD));        }    }    #[test]    fn history_search_esc_restores_original_draft() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer            .history            .record_local_submission(HistoryEntry::new("remembered command".to_string()));        composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());        composer.draft.textarea.set_cursor(/*pos*/ 2);        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        assert_eq!(composer.draft.textarea.text(), "draft");        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));        assert_eq!(composer.draft.textarea.text(), "remembered command");        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));        assert!(!composer.history_search_active());        assert_eq!(composer.draft.textarea.text(), "draft");        assert_eq!(composer.draft.textarea.cursor(), 2);    }    #[test]    fn history_search_ctrl_c_restores_original_draft() {        fn composer_with_search_preview() -> ChatComposer {            let (tx, _rx) = unbounded_channel::<AppEvent>();            let sender = AppEventSender::new(tx);            let mut composer = ChatComposer::new(                /*has_input_focus*/ true,                sender,                /*enhanced_keys_supported*/ false,                "Ask Codex to do anything".to_string(),                /*disable_paste_burst*/ false,            );            composer                .history                .record_local_submission(HistoryEntry::new("remembered command".to_string()));            composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());            composer.draft.textarea.set_cursor(/*pos*/ 2);            let _ =                composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));            let _ =                composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));            assert_eq!(composer.draft.textarea.text(), "remembered command");            composer        }        for cancel_key in [            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),            KeyEvent::new(KeyCode::Char('\u{0003}'), KeyModifiers::NONE),        ] {            let mut composer = composer_with_search_preview();            let _ = composer.handle_key_event(cancel_key);            assert!(!composer.history_search_active());            assert_eq!(composer.draft.textarea.text(), "draft");            assert_eq!(composer.draft.textarea.cursor(), 2);        }    }    #[test]    fn history_search_flushes_pending_first_char_before_snapshot() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));        assert!(composer.is_in_paste_burst());        assert_eq!(composer.draft.textarea.text(), "");        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        assert!(composer.history_search_active());        assert!(!composer.is_in_paste_burst());        assert_eq!(composer.draft.textarea.text(), "h");        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));        assert!(!composer.history_search_active());        assert_eq!(composer.draft.textarea.text(), "h");    }    #[test]    fn history_search_flushes_buffered_paste_before_snapshot() {        use std::time::Duration;        use std::time::Instant;        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        let mut now = Instant::now();        for ch in ['p', 'a', 's', 't', 'e'] {            let _ = composer.handle_input_basic_with_time(                KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),                now,            );            now += Duration::from_millis(1);        }        assert!(composer.is_in_paste_burst());        assert_eq!(composer.draft.textarea.text(), "");        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        assert!(composer.history_search_active());        assert!(!composer.is_in_paste_burst());        assert_eq!(composer.draft.textarea.text(), "paste");        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));        assert!(!composer.history_search_active());        assert_eq!(composer.draft.textarea.text(), "paste");    }    #[test]    fn history_search_esc_resets_normal_history_navigation() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer            .history            .record_local_submission(HistoryEntry::new("oldest matching entry".to_string()));        composer            .history            .record_local_submission(HistoryEntry::new("newest entry".to_string()));        composer.set_text_content(String::new(), Vec::new(), Vec::new());        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        for ch in ['m', 'a', 't', 'c', 'h'] {            let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));        }        assert_eq!(composer.draft.textarea.text(), "oldest matching entry");        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));        assert!(!composer.history_search_active());        assert!(composer.draft.textarea.is_empty());        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));        assert_eq!(composer.draft.textarea.text(), "newest entry");    }    #[test]    fn history_search_no_match_restores_preview_but_keeps_search_open() {        let (tx, _rx) = unbounded_channel::<AppEvent>();        let sender = AppEventSender::new(tx);        let mut composer = ChatComposer::new(            /*has_input_focus*/ true,            sender,            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        );        composer            .history            .record_local_submission(HistoryEntry::new("git status".to_string()));        composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());        let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));        for ch in ['z', 'z', 'z'] {            let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));        }        assert!(composer.history_search_active());        assert_eq!(composer.draft.textarea.text(), "draft");        assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);    }}