mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add reverse history search to composer (#17550)
## Problem The TUI had shell-style Up/Down history recall, but `Ctrl+R` did not provide the reverse incremental search workflow users expect from shells. Users needed a way to search older prompts without immediately replacing the current draft, and the interaction needed to handle async persistent history, repeated navigation keys, duplicate prompt text, footer hints, and preview highlighting without making the main composer file even harder to review. https://github.com/user-attachments/assets/5165affd-4c9a-46e9-adbd-89088f5f7b6b <img width="1227" height="722" alt="image" src="https://github.com/user-attachments/assets/8bc83289-eeca-47c7-b0c3-8975101901af" /> ## Mental model `Ctrl+R` opens a temporary search session owned by the composer. The footer line becomes the search input, the composer body previews the current match only after the query has text, and `Enter` accepts that preview as an editable draft while `Esc` restores the draft that existed before search started. The history layer provides a combined offset space over persistent and local history, but search navigation exposes unique prompt text rather than every physical history row. ## Non-goals This change does not rewrite stored history, change normal Up/Down browsing semantics, add fuzzy matching, or add persistent metadata for attachments in cross-session history. Search deduplication is deliberately scoped to the active Ctrl+R search session and uses exact prompt text, so case, whitespace, punctuation, and attachment-only differences are not normalized. ## Tradeoffs The implementation keeps search state in the existing composer and history state machines instead of adding a new cross-module controller. That keeps ownership local and testable, but it means the composer still coordinates visible search status, draft restoration, footer rendering, cursor placement, and match highlighting while `ChatComposerHistory` owns traversal, async fetch continuation, boundary clamping, and unique-result caching. Unique-result caching stores cloned `HistoryEntry` values so known matches can be revisited without cache lookups; this is simple and robust for interactive search sizes, but it is not a global history index. ## Architecture `ChatComposer` detects `Ctrl+R`, snapshots the current draft, switches the footer to `FooterMode::HistorySearch`, and routes search-mode keys before normal editing. Query edits call `ChatComposerHistory::search` with `restart = true`, which starts from the newest combined-history offset. Repeated `Ctrl+R` or Up searches older; Down searches newer through already discovered unique matches or continues the scan. Persistent history entries still arrive asynchronously through `on_entry_response`, where a pending search either accepts the response, skips a duplicate, or requests the next offset. The composer-facing pieces now live in `codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs`, leaving `chat_composer.rs` responsible for routing and rendering integration instead of owning every search helper inline. `codex-rs/tui/src/bottom_pane/chat_composer_history.rs` remains the owner of stored history, combined offsets, async fetch state, boundary semantics, and duplicate suppression. Match highlighting is computed from the current composer text while search is active and disappears when the match is accepted. ## Observability There are no new logs or telemetry. The practical debug path is state inspection: `ChatComposer.history_search` tells whether the footer query is idle, searching, matched, or unmatched; `ChatComposerHistory.search` tracks selected raw offsets, pending persistent fetches, exhausted directions, and unique match cache state. If a user reports skipped or repeated results, first inspect the exact stored prompt text, the selected offset, whether an async persistent response is still pending, and whether a query edit restarted the search session. ## Tests The change is covered by focused `codex-tui` unit tests for opening search without previewing the latest entry, accepting and canceling search, no-match restoration, boundary clamping, footer hints, case-insensitive highlighting, local duplicate skipping, and persistent duplicate skipping through async responses. Snapshot coverage captures the footer-mode visual changes. Local verification used `just fmt`, `cargo test -p codex-tui history_search`, `cargo test -p codex-tui`, and `just fix -p codex-tui`.
This commit is contained in:
committed by
GitHub
Unverified
parent
d840b247d7
commit
0393a485ed
@@ -28,6 +28,9 @@
|
||||
//! When recalling a persistent entry, only the text is restored.
|
||||
//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like
|
||||
//! history traversal semantics instead of dropping to column 0.
|
||||
//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the
|
||||
//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as
|
||||
//! an editable draft and `Esc` restores the draft that was active when search started.
|
||||
//!
|
||||
//! Slash commands are staged for local history instead of being recorded immediately. Command
|
||||
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after
|
||||
@@ -133,6 +136,8 @@ use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Margin;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
@@ -143,6 +148,7 @@ use ratatui::widgets::WidgetRef;
|
||||
|
||||
use super::chat_composer_history::ChatComposerHistory;
|
||||
use super::chat_composer_history::HistoryEntry;
|
||||
use super::chat_composer_history::HistoryEntryResponse;
|
||||
use super::command_popup::CommandItem;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::command_popup::CommandPopupFlags;
|
||||
@@ -186,6 +192,9 @@ use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
|
||||
mod history_search;
|
||||
|
||||
use self::history_search::HistorySearchSession;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -356,6 +365,7 @@ pub(crate) struct ChatComposer {
|
||||
status_line_enabled: bool,
|
||||
// Agent label injected into the footer's contextual row when multi-agent mode is active.
|
||||
active_agent_label: Option<String>,
|
||||
history_search: Option<HistorySearchSession>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -364,6 +374,17 @@ struct FooterFlash {
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ComposerDraft {
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
remote_image_urls: Vec<String>,
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ComposerMentionBinding {
|
||||
mention: String,
|
||||
@@ -481,6 +502,7 @@ impl ChatComposer {
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
active_agent_label: None,
|
||||
history_search: None,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -648,6 +670,10 @@ impl ChatComposer {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(pos) = self.history_search_cursor_pos(area) {
|
||||
return Some(pos);
|
||||
}
|
||||
|
||||
let [_, _, textarea_rect, _] = self.layout_areas(area);
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
@@ -677,13 +703,22 @@ impl ChatComposer {
|
||||
offset: usize,
|
||||
entry: Option<String>,
|
||||
) -> bool {
|
||||
let Some(entry) = self.history.on_entry_response(log_id, offset, entry) else {
|
||||
return false;
|
||||
};
|
||||
// Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting
|
||||
// attachments), but local in-session ↑/↓ history can rehydrate elements and image paths.
|
||||
self.apply_history_entry(entry);
|
||||
true
|
||||
match self
|
||||
.history
|
||||
.on_entry_response(log_id, offset, entry, &self.app_event_tx)
|
||||
{
|
||||
HistoryEntryResponse::Found(entry) => {
|
||||
// Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting
|
||||
// attachments), but local in-session ↑/↓ history can rehydrate elements and image paths.
|
||||
self.apply_history_entry(entry);
|
||||
true
|
||||
}
|
||||
HistoryEntryResponse::Search(result) => {
|
||||
self.apply_history_search_result(result);
|
||||
true
|
||||
}
|
||||
HistoryEntryResponse::Ignored => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrate pasted text into the composer.
|
||||
@@ -970,6 +1005,45 @@ impl ChatComposer {
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
fn snapshot_draft(&self) -> ComposerDraft {
|
||||
ComposerDraft {
|
||||
text: self.textarea.text().to_string(),
|
||||
text_elements: self.textarea.text_elements(),
|
||||
local_image_paths: self
|
||||
.attached_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect(),
|
||||
remote_image_urls: self.remote_image_urls.clone(),
|
||||
mention_bindings: self.snapshot_mention_bindings(),
|
||||
pending_pastes: self.pending_pastes.clone(),
|
||||
cursor: self.textarea.cursor(),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_draft(&mut self, draft: ComposerDraft) {
|
||||
let ComposerDraft {
|
||||
text,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
remote_image_urls,
|
||||
mention_bindings,
|
||||
pending_pastes,
|
||||
cursor,
|
||||
} = draft;
|
||||
self.set_remote_image_urls(remote_image_urls);
|
||||
self.set_text_content_with_mention_bindings(
|
||||
text,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
mention_bindings,
|
||||
);
|
||||
self.set_pending_pastes(pending_pastes);
|
||||
self.textarea
|
||||
.set_cursor(cursor.min(self.textarea.text().len()));
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
/// Update the placeholder text without changing input enablement.
|
||||
pub(crate) fn set_placeholder_text(&mut self, placeholder: String) {
|
||||
self.placeholder_text = placeholder;
|
||||
@@ -1235,6 +1309,14 @@ impl ChatComposer {
|
||||
return (InputResult::None, false);
|
||||
}
|
||||
|
||||
if self.history_search.is_some() {
|
||||
return self.handle_history_search_key(key_event);
|
||||
}
|
||||
|
||||
if Self::is_history_search_key(&key_event) {
|
||||
return self.begin_history_search();
|
||||
}
|
||||
|
||||
let result = match &mut self.active_popup {
|
||||
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
||||
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
|
||||
@@ -1248,7 +1330,7 @@ impl ChatComposer {
|
||||
|
||||
/// Return true if either the slash-command popup or the file-search popup is active.
|
||||
pub(crate) fn popup_active(&self) -> bool {
|
||||
!matches!(self.active_popup, ActivePopup::None)
|
||||
self.history_search.is_some() || !matches!(self.active_popup, ActivePopup::None)
|
||||
}
|
||||
|
||||
/// Handle key event when the slash-command popup is visible.
|
||||
@@ -2900,6 +2982,10 @@ impl ChatComposer {
|
||||
/// modes (Esc hint, overlay, quit reminder) can override that base when
|
||||
/// their conditions are active.
|
||||
fn footer_mode(&self) -> FooterMode {
|
||||
if self.history_search.is_some() {
|
||||
return FooterMode::HistorySearch;
|
||||
}
|
||||
|
||||
let base_mode = if self.is_empty() {
|
||||
FooterMode::ComposerEmpty
|
||||
} else {
|
||||
@@ -2907,6 +2993,7 @@ impl ChatComposer {
|
||||
};
|
||||
|
||||
match self.footer_mode {
|
||||
FooterMode::HistorySearch => FooterMode::HistorySearch,
|
||||
FooterMode::EscHint => FooterMode::EscHint,
|
||||
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
|
||||
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
|
||||
@@ -2933,6 +3020,17 @@ impl ChatComposer {
|
||||
|
||||
pub(crate) fn sync_popups(&mut self) {
|
||||
self.sync_slash_command_elements();
|
||||
if self.history_search.is_some() {
|
||||
if self.current_file_query.is_some() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::StartFileSearch(String::new()));
|
||||
self.current_file_query = None;
|
||||
}
|
||||
self.active_popup = ActivePopup::None;
|
||||
self.dismissed_file_popup_token = None;
|
||||
self.dismissed_mention_popup_token = None;
|
||||
return;
|
||||
}
|
||||
if !self.popups_enabled() {
|
||||
self.active_popup = ActivePopup::None;
|
||||
return;
|
||||
@@ -3500,6 +3598,10 @@ impl Renderable for ChatComposer {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(pos) = self.history_search_cursor_pos(area) {
|
||||
return Some(pos);
|
||||
}
|
||||
|
||||
let [_, _, textarea_rect, _] = self.layout_areas(area);
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
@@ -3558,13 +3660,15 @@ impl ChatComposer {
|
||||
let show_shortcuts_hint = match footer_props.mode {
|
||||
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder
|
||||
FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let show_queue_hint = match footer_props.mode {
|
||||
FooterMode::ComposerHasDraft => footer_props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ComposerEmpty
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
@@ -3583,124 +3687,141 @@ impl ChatComposer {
|
||||
} else {
|
||||
popup_rect
|
||||
};
|
||||
let available_width =
|
||||
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
|
||||
let status_line_active = uses_passive_footer_status_layout(&footer_props);
|
||||
let combined_status_line = if status_line_active {
|
||||
passive_footer_status_line(&footer_props).map(ratatui::prelude::Stylize::dim)
|
||||
if let Some(line) = self.history_search_footer_line() {
|
||||
render_footer_line(hint_rect, buf, line);
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut truncated_status_line = if status_line_active {
|
||||
combined_status_line.as_ref().map(|line| {
|
||||
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let left_mode_indicator = if status_line_active {
|
||||
None
|
||||
} else {
|
||||
self.collaboration_mode_indicator
|
||||
};
|
||||
let mut left_width = if self.footer_flash_visible() {
|
||||
self.footer_flash
|
||||
.as_ref()
|
||||
.map(|flash| flash.line.width() as u16)
|
||||
.unwrap_or(0)
|
||||
} else if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
footer_hint_items_width(items)
|
||||
} else if status_line_active {
|
||||
truncated_status_line
|
||||
.as_ref()
|
||||
.map(|line| line.width() as u16)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
footer_line_width(
|
||||
&footer_props,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
)
|
||||
};
|
||||
let right_line = if status_line_active {
|
||||
let full =
|
||||
mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint);
|
||||
let compact = mode_indicator_line(
|
||||
self.collaboration_mode_indicator,
|
||||
/*show_cycle_hint*/ false,
|
||||
);
|
||||
let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0);
|
||||
if can_show_left_with_context(hint_rect, left_width, full_width) {
|
||||
full
|
||||
let available_width =
|
||||
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
|
||||
let status_line_active = uses_passive_footer_status_layout(&footer_props);
|
||||
let combined_status_line = if status_line_active {
|
||||
passive_footer_status_line(&footer_props)
|
||||
.map(ratatui::prelude::Stylize::dim)
|
||||
} else {
|
||||
compact
|
||||
}
|
||||
} else {
|
||||
Some(context_window_line(
|
||||
footer_props.context_window_percent,
|
||||
footer_props.context_window_used_tokens,
|
||||
))
|
||||
};
|
||||
let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0);
|
||||
if status_line_active
|
||||
&& let Some(max_left) = max_left_width_for_right(hint_rect, right_width)
|
||||
&& left_width > max_left
|
||||
&& let Some(line) = combined_status_line.as_ref().map(|line| {
|
||||
truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize)
|
||||
})
|
||||
{
|
||||
left_width = line.width() as u16;
|
||||
truncated_status_line = Some(line);
|
||||
}
|
||||
let can_show_left_and_context =
|
||||
can_show_left_with_context(hint_rect, left_width, right_width);
|
||||
let has_override =
|
||||
self.footer_flash_visible() || self.footer_hint_override.is_some();
|
||||
let single_line_layout = if has_override || status_line_active {
|
||||
None
|
||||
} else {
|
||||
match footer_props.mode {
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => {
|
||||
// Both of these modes render the single-line footer style (with
|
||||
// either the shortcuts hint or the optional queue hint). We still
|
||||
// want the single-line collapse rules so the mode label can win over
|
||||
// the context indicator on narrow widths.
|
||||
Some(single_line_footer_layout(
|
||||
hint_rect,
|
||||
right_width,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
))
|
||||
None
|
||||
};
|
||||
let mut truncated_status_line = if status_line_active {
|
||||
combined_status_line.as_ref().map(|line| {
|
||||
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let left_mode_indicator = if status_line_active {
|
||||
None
|
||||
} else {
|
||||
self.collaboration_mode_indicator
|
||||
};
|
||||
let mut left_width = if self.footer_flash_visible() {
|
||||
self.footer_flash
|
||||
.as_ref()
|
||||
.map(|flash| flash.line.width() as u16)
|
||||
.unwrap_or(0)
|
||||
} else if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
footer_hint_items_width(items)
|
||||
} else if status_line_active {
|
||||
truncated_status_line
|
||||
.as_ref()
|
||||
.map(|line| line.width() as u16)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
footer_line_width(
|
||||
&footer_props,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
)
|
||||
};
|
||||
let right_line = if status_line_active {
|
||||
let full =
|
||||
mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint);
|
||||
let compact = mode_indicator_line(
|
||||
self.collaboration_mode_indicator,
|
||||
/*show_cycle_hint*/ false,
|
||||
);
|
||||
let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0);
|
||||
if can_show_left_with_context(hint_rect, left_width, full_width) {
|
||||
full
|
||||
} else {
|
||||
compact
|
||||
}
|
||||
FooterMode::EscHint
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay => None,
|
||||
} else {
|
||||
Some(context_window_line(
|
||||
footer_props.context_window_percent,
|
||||
footer_props.context_window_used_tokens,
|
||||
))
|
||||
};
|
||||
let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0);
|
||||
if status_line_active
|
||||
&& let Some(max_left) = max_left_width_for_right(hint_rect, right_width)
|
||||
&& left_width > max_left
|
||||
&& let Some(line) = combined_status_line.as_ref().map(|line| {
|
||||
truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize)
|
||||
})
|
||||
{
|
||||
left_width = line.width() as u16;
|
||||
truncated_status_line = Some(line);
|
||||
}
|
||||
};
|
||||
let show_right = if matches!(
|
||||
footer_props.mode,
|
||||
FooterMode::EscHint
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
single_line_layout
|
||||
.as_ref()
|
||||
.map(|(_, show_context)| *show_context)
|
||||
.unwrap_or(can_show_left_and_context)
|
||||
};
|
||||
let can_show_left_and_context =
|
||||
can_show_left_with_context(hint_rect, left_width, right_width);
|
||||
let has_override =
|
||||
self.footer_flash_visible() || self.footer_hint_override.is_some();
|
||||
let single_line_layout = if has_override || status_line_active {
|
||||
None
|
||||
} else {
|
||||
match footer_props.mode {
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => {
|
||||
// Both of these modes render the single-line footer style (with
|
||||
// either the shortcuts hint or the optional queue hint). We still
|
||||
// want the single-line collapse rules so the mode label can win over
|
||||
// the context indicator on narrow widths.
|
||||
Some(single_line_footer_layout(
|
||||
hint_rect,
|
||||
right_width,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
))
|
||||
}
|
||||
FooterMode::EscHint
|
||||
| FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay => None,
|
||||
}
|
||||
};
|
||||
let show_right = if matches!(
|
||||
footer_props.mode,
|
||||
FooterMode::EscHint
|
||||
| FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
single_line_layout
|
||||
.as_ref()
|
||||
.map(|(_, show_context)| *show_context)
|
||||
.unwrap_or(can_show_left_and_context)
|
||||
};
|
||||
|
||||
if let Some((summary_left, _)) = single_line_layout {
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
if status_line_active {
|
||||
if let Some(line) = truncated_status_line.clone() {
|
||||
render_footer_line(hint_rect, buf, line);
|
||||
if let Some((summary_left, _)) = single_line_layout {
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
if status_line_active {
|
||||
if let Some(line) = truncated_status_line.clone() {
|
||||
render_footer_line(hint_rect, buf, line);
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
hint_rect,
|
||||
buf,
|
||||
&footer_props,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
hint_rect,
|
||||
@@ -3712,47 +3833,37 @@ impl ChatComposer {
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
hint_rect,
|
||||
buf,
|
||||
&footer_props,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
SummaryLeft::Custom(line) => {
|
||||
render_footer_line(hint_rect, buf, line);
|
||||
}
|
||||
SummaryLeft::None => {}
|
||||
}
|
||||
SummaryLeft::Custom(line) => {
|
||||
} else if self.footer_flash_visible() {
|
||||
if let Some(flash) = self.footer_flash.as_ref() {
|
||||
flash.line.render(inset_footer_hint_area(hint_rect), buf);
|
||||
}
|
||||
} else if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
render_footer_hint_items(hint_rect, buf, items);
|
||||
} else if status_line_active {
|
||||
if let Some(line) = truncated_status_line {
|
||||
render_footer_line(hint_rect, buf, line);
|
||||
}
|
||||
SummaryLeft::None => {}
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
hint_rect,
|
||||
buf,
|
||||
&footer_props,
|
||||
self.collaboration_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
} else if self.footer_flash_visible() {
|
||||
if let Some(flash) = self.footer_flash.as_ref() {
|
||||
flash.line.render(inset_footer_hint_area(hint_rect), buf);
|
||||
}
|
||||
} else if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
render_footer_hint_items(hint_rect, buf, items);
|
||||
} else if status_line_active {
|
||||
if let Some(line) = truncated_status_line {
|
||||
render_footer_line(hint_rect, buf, line);
|
||||
}
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
hint_rect,
|
||||
buf,
|
||||
&footer_props,
|
||||
self.collaboration_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
|
||||
if show_right && let Some(line) = &right_line {
|
||||
render_context_right(hint_rect, buf, line);
|
||||
if show_right && let Some(line) = &right_line {
|
||||
render_context_right(hint_rect, buf, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3828,10 +3939,44 @@ impl ChatComposer {
|
||||
} else if is_zellij && textarea_is_empty {
|
||||
buf.set_style(textarea_rect, textarea_style);
|
||||
} else if is_zellij {
|
||||
self.textarea
|
||||
.render_ref_styled(textarea_rect, buf, &mut state, textarea_style);
|
||||
let highlight_ranges = self.history_search_highlight_ranges();
|
||||
if highlight_ranges.is_empty() {
|
||||
self.textarea
|
||||
.render_ref_styled(textarea_rect, buf, &mut state, textarea_style);
|
||||
} else {
|
||||
let highlight_style =
|
||||
textarea_style.add_modifier(Modifier::REVERSED | Modifier::BOLD);
|
||||
let highlights = highlight_ranges
|
||||
.into_iter()
|
||||
.map(|range| (range, highlight_style))
|
||||
.collect::<Vec<_>>();
|
||||
self.textarea.render_ref_styled_with_highlights(
|
||||
textarea_rect,
|
||||
buf,
|
||||
&mut state,
|
||||
textarea_style,
|
||||
&highlights,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||
let highlight_ranges = self.history_search_highlight_ranges();
|
||||
if highlight_ranges.is_empty() {
|
||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||
} else {
|
||||
let highlight_style =
|
||||
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
|
||||
let highlights = highlight_ranges
|
||||
.into_iter()
|
||||
.map(|range| (range, highlight_style))
|
||||
.collect::<Vec<_>>();
|
||||
self.textarea.render_ref_styled_with_highlights(
|
||||
textarea_rect,
|
||||
buf,
|
||||
&mut state,
|
||||
Style::default(),
|
||||
&highlights,
|
||||
);
|
||||
}
|
||||
}
|
||||
if textarea_is_empty {
|
||||
let text = if self.input_enabled {
|
||||
@@ -4191,6 +4336,20 @@ mod tests {
|
||||
type_chars_humanlike(composer, &['h']);
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_composer_state(
|
||||
"footer_mode_history_search",
|
||||
/*enhanced_keys_supported*/ true,
|
||||
|composer| {
|
||||
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));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
//! 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::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) -> bool {
|
||||
matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'r')
|
||||
) || matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0012}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn is_history_search_forward_key(key_event: &KeyEvent) -> bool {
|
||||
matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'s')
|
||||
) || matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0013}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.paste_burst.flush_before_modified_input() {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
self.paste_burst.clear_window_after_non_char();
|
||||
|
||||
if self.current_file_query.is_some() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::StartFileSearch(String::new()));
|
||||
self.current_file_query = None;
|
||||
}
|
||||
self.active_popup = ActivePopup::None;
|
||||
self.selected_remote_image_index = None;
|
||||
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) || 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)
|
||||
|| 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.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.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.textarea.text(), "draft");
|
||||
|
||||
for ch in ['g', 'i', 't'] {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
assert_eq!(composer.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.textarea.text(), "git status");
|
||||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||||
}
|
||||
|
||||
#[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.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.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.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.textarea.set_cursor(/*pos*/ 2);
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
assert_eq!(composer.textarea.text(), "draft");
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
|
||||
assert_eq!(composer.textarea.text(), "remembered command");
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(!composer.history_search_active());
|
||||
assert_eq!(composer.textarea.text(), "draft");
|
||||
assert_eq!(composer.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.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.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.textarea.text(), "draft");
|
||||
assert_eq!(composer.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.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.textarea.text(), "h");
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(!composer.history_search_active());
|
||||
assert_eq!(composer.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.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.textarea.text(), "paste");
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(!composer.history_search_active());
|
||||
assert_eq!(composer.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.textarea.text(), "oldest matching entry");
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(!composer.history_search_active());
|
||||
assert!(composer.textarea.is_empty());
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(composer.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.textarea.text(), "draft");
|
||||
assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,8 @@ impl CollaborationModeIndicator {
|
||||
/// (for example, showing `QuitShortcutReminder` only while its timer is active).
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum FooterMode {
|
||||
/// Single-line incremental history search prompt shown while Ctrl+R search is active.
|
||||
HistorySearch,
|
||||
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
|
||||
QuitShortcutReminder,
|
||||
/// Multi-line shortcut overlay shown after pressing `?`.
|
||||
@@ -179,6 +181,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
|
||||
FooterMode::EscHint
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::HistorySearch
|
||||
| FooterMode::ComposerHasDraft => FooterMode::ComposerEmpty,
|
||||
other => other,
|
||||
}
|
||||
@@ -188,13 +191,15 @@ pub(crate) fn footer_height(props: &FooterProps) -> u16 {
|
||||
let show_shortcuts_hint = match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => {
|
||||
false
|
||||
}
|
||||
FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let show_queue_hint = match props.mode {
|
||||
FooterMode::ComposerHasDraft => props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::HistorySearch
|
||||
| FooterMode::ComposerEmpty
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
@@ -593,6 +598,7 @@ fn footer_from_props_lines(
|
||||
FooterMode::QuitShortcutReminder => {
|
||||
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
|
||||
}
|
||||
FooterMode::HistorySearch => vec![Line::from("reverse-i-search: ").dim()],
|
||||
FooterMode::ComposerEmpty => {
|
||||
let state = LeftSideState {
|
||||
hint: if show_shortcuts_hint {
|
||||
@@ -666,9 +672,10 @@ pub(crate) fn shows_passive_footer_line(props: &FooterProps) -> bool {
|
||||
match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => !props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => {
|
||||
false
|
||||
}
|
||||
FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,6 +763,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
let mut paste_image = Line::from("");
|
||||
let mut external_editor = Line::from("");
|
||||
let mut edit_previous = Line::from("");
|
||||
let mut history_search = Line::from("");
|
||||
let mut quit = Line::from("");
|
||||
let mut show_transcript = Line::from("");
|
||||
let mut change_mode = Line::from("");
|
||||
@@ -771,6 +779,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
ShortcutId::PasteImage => paste_image = text,
|
||||
ShortcutId::ExternalEditor => external_editor = text,
|
||||
ShortcutId::EditPrevious => edit_previous = text,
|
||||
ShortcutId::HistorySearch => history_search = text,
|
||||
ShortcutId::Quit => quit = text,
|
||||
ShortcutId::ShowTranscript => show_transcript = text,
|
||||
ShortcutId::ChangeMode => change_mode = text,
|
||||
@@ -787,6 +796,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
paste_image,
|
||||
external_editor,
|
||||
edit_previous,
|
||||
history_search,
|
||||
quit,
|
||||
];
|
||||
if change_mode.width() > 0 {
|
||||
@@ -869,6 +879,7 @@ enum ShortcutId {
|
||||
PasteImage,
|
||||
ExternalEditor,
|
||||
EditPrevious,
|
||||
HistorySearch,
|
||||
Quit,
|
||||
ShowTranscript,
|
||||
ChangeMode,
|
||||
@@ -1027,6 +1038,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
prefix: "",
|
||||
label: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::HistorySearch,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::ctrl(KeyCode::Char('r')),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " search history",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::Quit,
|
||||
bindings: &[ShortcutBinding {
|
||||
@@ -1086,13 +1106,15 @@ mod tests {
|
||||
let show_shortcuts_hint = match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder
|
||||
FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let show_queue_hint = match props.mode {
|
||||
FooterMode::ComposerHasDraft => props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ComposerEmpty
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
@@ -1227,6 +1249,7 @@ mod tests {
|
||||
&& !matches!(
|
||||
props.mode,
|
||||
FooterMode::EscHint
|
||||
| FooterMode::HistorySearch
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
);
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs
|
||||
//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent
|
||||
//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active
|
||||
//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may
|
||||
//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit
|
||||
//! shortcut.
|
||||
//! view the first chance to consume Ctrl+C (typically to dismiss itself), then lets an active
|
||||
//! composer history search consume Ctrl+C as cancellation, and `ChatWidget` may treat an unhandled
|
||||
//! Ctrl+C as an interrupt or as the first press of a double-press quit shortcut.
|
||||
//!
|
||||
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
|
||||
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
|
||||
@@ -458,7 +458,8 @@ impl BottomPane {
|
||||
/// Handles a Ctrl+C press within the bottom pane.
|
||||
///
|
||||
/// An active modal view is given the first chance to consume the key (typically to dismiss
|
||||
/// itself). If no view is active, Ctrl+C clears draft composer input.
|
||||
/// itself). If no view is active, Ctrl+C cancels active history search before falling back to
|
||||
/// clearing draft composer input.
|
||||
///
|
||||
/// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C
|
||||
/// was received, but it does not decide whether the process should exit; `ChatWidget` owns the
|
||||
@@ -475,6 +476,9 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
event
|
||||
} else if self.composer.cancel_history_search() {
|
||||
self.request_redraw();
|
||||
CancellationEvent::Handled
|
||||
} else if self.composer_is_empty() {
|
||||
CancellationEvent::NotHandled
|
||||
} else {
|
||||
@@ -1305,6 +1309,31 @@ mod tests {
|
||||
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_cancels_history_search_without_clearing_draft_or_showing_quit_hint() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: true,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
pane.insert_str("draft");
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
assert!(pane.composer.popup_active());
|
||||
|
||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
assert_eq!(pane.composer_text(), "draft");
|
||||
assert!(!pane.composer.popup_active());
|
||||
assert!(!pane.quit_shortcut_hint_visible());
|
||||
}
|
||||
|
||||
// live ring removed; related tests deleted.
|
||||
|
||||
#[test]
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› cargo test "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" reverse-i-search: c enter accept · esc cancel "
|
||||
+2
-2
@@ -14,5 +14,5 @@ expression: terminal.backend()
|
||||
" shift + enter for newline tab to queue message "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
|
||||
+3
-2
@@ -6,5 +6,6 @@ expression: terminal.backend()
|
||||
" ctrl + j for newline tab to queue message "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc esc to edit previous message "
|
||||
" ctrl + c to exit shift + tab to change mode "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" shift + tab to change mode "
|
||||
" ctrl + t to view transcript "
|
||||
|
||||
+2
-2
@@ -6,5 +6,5 @@ expression: terminal.backend()
|
||||
" shift + enter for newline tab to queue message "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
|
||||
@@ -1366,7 +1366,7 @@ impl TextArea {
|
||||
impl WidgetRef for &TextArea {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
self.render_lines(area, buf, &lines, 0..lines.len(), Style::default());
|
||||
self.render_lines(area, buf, &lines, 0..lines.len(), Style::default(), &[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1380,7 +1380,7 @@ impl StatefulWidgetRef for &TextArea {
|
||||
|
||||
let start = scroll as usize;
|
||||
let end = (scroll + area.height).min(lines.len() as u16) as usize;
|
||||
self.render_lines(area, buf, &lines, start..end, Style::default());
|
||||
self.render_lines(area, buf, &lines, start..end, Style::default(), &[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,7 +1417,28 @@ impl TextArea {
|
||||
|
||||
let start = scroll as usize;
|
||||
let end = (scroll + area.height).min(lines.len() as u16) as usize;
|
||||
self.render_lines(area, buf, &lines, start..end, base_style);
|
||||
self.render_lines(area, buf, &lines, start..end, base_style, &[]);
|
||||
}
|
||||
|
||||
/// Render the textarea with `base_style` plus additional render-only highlight ranges.
|
||||
///
|
||||
/// Highlight ranges are byte ranges in `self.text`. They affect only the buffer rendering and
|
||||
/// do not mutate the editable text, cursor, element metadata, or wrapping cache.
|
||||
pub(crate) fn render_ref_styled_with_highlights(
|
||||
&self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
state: &mut TextAreaState,
|
||||
base_style: Style,
|
||||
highlights: &[(Range<usize>, Style)],
|
||||
) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||||
state.scroll = scroll;
|
||||
|
||||
let start = scroll as usize;
|
||||
let end = (scroll + area.height).min(lines.len() as u16) as usize;
|
||||
self.render_lines(area, buf, &lines, start..end, base_style, highlights);
|
||||
}
|
||||
|
||||
fn render_lines(
|
||||
@@ -1427,6 +1448,7 @@ impl TextArea {
|
||||
lines: &[Range<usize>],
|
||||
range: std::ops::Range<usize>,
|
||||
base_style: Style,
|
||||
highlights: &[(Range<usize>, Style)],
|
||||
) {
|
||||
for (row, idx) in range.enumerate() {
|
||||
let r = &lines[idx];
|
||||
@@ -1449,6 +1471,19 @@ impl TextArea {
|
||||
let style = base_style.fg(ratatui::style::Color::Cyan);
|
||||
buf.set_string(area.x + x_off, y, styled, style);
|
||||
}
|
||||
|
||||
// Overlay render-only highlight ranges last so transient search highlighting remains
|
||||
// visible even when it intersects attachment placeholders or other styled elements.
|
||||
for (highlight_range, style) in highlights {
|
||||
let overlap_start = highlight_range.start.max(line_range.start);
|
||||
let overlap_end = highlight_range.end.min(line_range.end);
|
||||
if overlap_start >= overlap_end {
|
||||
continue;
|
||||
}
|
||||
let highlighted = &self.text[overlap_start..overlap_end];
|
||||
let x_off = self.text[line_range.start..overlap_start].width() as u16;
|
||||
buf.set_string(area.x + x_off, y, highlighted, *style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2187,6 +2222,43 @@ mod tests {
|
||||
assert!(state.scroll < effective_lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_highlights_apply_style_without_mutating_text() {
|
||||
let t = ta_with("hello world");
|
||||
let area = Rect::new(0, 0, 20, 1);
|
||||
let mut state = TextAreaState::default();
|
||||
let mut buf = Buffer::empty(area);
|
||||
let highlight_style = Style::default().add_modifier(ratatui::style::Modifier::REVERSED);
|
||||
|
||||
t.render_ref_styled_with_highlights(
|
||||
area,
|
||||
&mut buf,
|
||||
&mut state,
|
||||
Style::default(),
|
||||
&[(6..11, highlight_style)],
|
||||
);
|
||||
|
||||
assert_eq!(t.text(), "hello world");
|
||||
assert!(
|
||||
!buf[(0, 0)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::REVERSED)
|
||||
);
|
||||
assert!(
|
||||
buf[(6, 0)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::REVERSED)
|
||||
);
|
||||
assert!(
|
||||
buf[(10, 0)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::REVERSED)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
|
||||
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
|
||||
|
||||
@@ -65,6 +65,14 @@ Up/Down recall is handled by `ChatComposerHistory` and merges two sources:
|
||||
This distinction keeps the on-disk history backward compatible and avoids persisting attachments,
|
||||
while still providing a richer recall experience for in-session edits.
|
||||
|
||||
### Reverse history search (Ctrl+R)
|
||||
|
||||
Ctrl+R enters an incremental reverse search mode without immediately previewing the latest history entry. While search is active, the footer line becomes the editable query field and the composer body is only a preview of the currently matched entry. `Enter` accepts the preview as a normal editable draft, and `Esc` or Ctrl+C restores the exact draft that existed before search started.
|
||||
|
||||
The composer owns the search session because it controls draft snapshots, footer rendering, cursor placement, and preview highlighting. `ChatComposerHistory` owns traversal: it scans persistent and local entries in one offset space, skips duplicate prompt text within a search session, keeps boundary hits on the current match, and resumes scans after asynchronous persistent history responses.
|
||||
|
||||
The search query and composer text intentionally remain separate. A no-match result restores the original draft while leaving the footer query open for more typing, and accepting a match clears the search session so highlight styling disappears from the now-editable composer text.
|
||||
|
||||
## Config gating for reuse
|
||||
|
||||
`ChatComposer` now supports feature gating via `ChatComposerConfig`
|
||||
|
||||
Reference in New Issue
Block a user