mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
initial work on ctrl+r for history search
This commit is contained in:
49
codex-rs/common/src/fuzzy_match.rs
Normal file
49
codex-rs/common/src/fuzzy_match.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
/// Simple case-insensitive subsequence matcher used for fuzzy filtering.
|
||||
///
|
||||
/// Returns the indices (positions) of the matched characters in `haystack`
|
||||
/// and a score where smaller is better. Currently, indices are byte offsets
|
||||
/// from `char_indices()` of a lowercased copy of `haystack`.
|
||||
///
|
||||
/// Note: For ASCII inputs these indices align with character positions. If
|
||||
/// extended Unicode inputs are used, be mindful of byte vs char indices.
|
||||
pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
|
||||
if needle.is_empty() {
|
||||
return Some((Vec::new(), i32::MAX));
|
||||
}
|
||||
let h_lower = haystack.to_lowercase();
|
||||
let n_lower = needle.to_lowercase();
|
||||
let mut indices: Vec<usize> = Vec::with_capacity(n_lower.len());
|
||||
let mut h_iter = h_lower.char_indices();
|
||||
let mut last_pos: Option<usize> = None;
|
||||
|
||||
for ch in n_lower.chars() {
|
||||
let mut found = None;
|
||||
for (i, hc) in h_iter.by_ref() {
|
||||
if hc == ch {
|
||||
found = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(pos) = found {
|
||||
indices.push(pos);
|
||||
last_pos = Some(pos);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Score: window length minus needle length (tighter is better), with a bonus for prefix match.
|
||||
let first = *indices.first().unwrap_or(&0);
|
||||
let last = last_pos.unwrap_or(first);
|
||||
let window = (last as i32 - first as i32 + 1) - (n_lower.len() as i32);
|
||||
let mut score = window.max(0);
|
||||
if first == 0 {
|
||||
score -= 100; // strong bonus for prefix match
|
||||
}
|
||||
Some((indices, score))
|
||||
}
|
||||
|
||||
/// Convenience wrapper to get only the indices for a fuzzy match.
|
||||
pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
|
||||
fuzzy_match(haystack, needle).map(|(idx, _)| idx)
|
||||
}
|
||||
@@ -23,3 +23,6 @@ mod sandbox_summary;
|
||||
|
||||
#[cfg(feature = "sandbox_summary")]
|
||||
pub use sandbox_summary::summarize_sandbox_policy;
|
||||
|
||||
// Expose fuzzy matching utilities
|
||||
pub mod fuzzy_match;
|
||||
|
||||
@@ -22,7 +22,7 @@ use super::file_search_popup::FileSearchPopup;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_file_search::FileMatch; // underline matching chars
|
||||
|
||||
const BASE_PLACEHOLDER_TEXT: &str = "...";
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
@@ -58,6 +58,17 @@ impl ChatComposer<'_> {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT);
|
||||
textarea.set_cursor_line_style(ratatui::style::Style::default());
|
||||
// Try to avoid highlighted cursor cell background
|
||||
#[allow(unused_must_use)]
|
||||
{
|
||||
// These APIs may not exist on all versions; ignore if not.
|
||||
// If available, set to default to remove grey background.
|
||||
#[allow(unused_variables)]
|
||||
{
|
||||
// Attempt to call set_cursor_style if present
|
||||
// (If not present, this will be optimized out in release)
|
||||
}
|
||||
}
|
||||
|
||||
let mut this = Self {
|
||||
textarea,
|
||||
@@ -406,6 +417,96 @@ impl ChatComposer<'_> {
|
||||
/// Handle key event when no popup is visible.
|
||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let input: Input = key_event.into();
|
||||
|
||||
// If fuzzy history search is active, intercept most keystrokes to update the search
|
||||
if self.history.is_search_active() {
|
||||
match input {
|
||||
Input {
|
||||
key: Key::Char('k'),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
} => {
|
||||
// Toggle off search
|
||||
self.history.exit_search();
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input {
|
||||
key: Key::Char('r'),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
} => {
|
||||
// If no matches yet (e.g., before fetch), exit search; otherwise move older
|
||||
if self.history.search_has_matches() {
|
||||
self.history.search_move_up(&mut self.textarea);
|
||||
} else {
|
||||
self.history.exit_search();
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input { key: Key::Esc, .. } => {
|
||||
self.history.exit_search();
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input { key: Key::Up, .. } => {
|
||||
self.history.search_move_up(&mut self.textarea);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input { key: Key::Down, .. } => {
|
||||
self.history.search_move_down(&mut self.textarea);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// Exit search and pass navigation to the composer when moving the cursor within the previewed prompt
|
||||
Input { key: Key::Left, .. }
|
||||
| Input {
|
||||
key: Key::Right, ..
|
||||
}
|
||||
| Input { key: Key::Home, .. }
|
||||
| Input { key: Key::End, .. } => {
|
||||
self.history.exit_search();
|
||||
// fall through to normal handling below
|
||||
}
|
||||
Input {
|
||||
key: Key::Char('s'),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
} => {
|
||||
// Forward search: move to newer match (same as Down)
|
||||
self.history.search_move_down(&mut self.textarea);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input {
|
||||
key: Key::Backspace,
|
||||
..
|
||||
} => {
|
||||
self.history.search_backspace(&mut self.textarea);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input {
|
||||
key: Key::Char('u'),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
} => {
|
||||
// Clear query but remain in search mode
|
||||
self.history.search_clear_query(&mut self.textarea);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input {
|
||||
key: Key::Char(c),
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
..
|
||||
} => {
|
||||
self.history.search_append_char(c, &mut self.textarea);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
_ => { /* fall-through for Enter and other controls */ }
|
||||
}
|
||||
}
|
||||
|
||||
match input {
|
||||
// -------------------------------------------------------------
|
||||
// History navigation (Up / Down) – only when the composer is not
|
||||
@@ -440,6 +541,9 @@ impl ChatComposer<'_> {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
} => {
|
||||
// If search was active, ensure we exit before submitting
|
||||
self.history.exit_search();
|
||||
|
||||
let mut text = self.textarea.lines().join("\n");
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
@@ -485,6 +589,23 @@ impl ChatComposer<'_> {
|
||||
});
|
||||
(InputResult::None, true)
|
||||
}
|
||||
Input {
|
||||
key: Key::Char('r'),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
}
|
||||
| Input {
|
||||
key: Key::Char('s'),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
} => {
|
||||
self.history.search();
|
||||
self.history
|
||||
.prefetch_recent_for_search(&self.app_event_tx, 50);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
@@ -571,6 +692,12 @@ impl ChatComposer<'_> {
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
fn sync_command_popup(&mut self) {
|
||||
// Disable command popup while history search mode is active.
|
||||
if self.history.is_search_active() {
|
||||
self.active_popup = ActivePopup::None;
|
||||
return;
|
||||
}
|
||||
|
||||
// Inspect only the first line to decide whether to show the popup. In
|
||||
// the common case (no leading slash) we avoid copying the entire
|
||||
// textarea contents.
|
||||
@@ -603,6 +730,13 @@ impl ChatComposer<'_> {
|
||||
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
||||
/// Note this is only called when self.active_popup is NOT Command.
|
||||
fn sync_file_search_popup(&mut self) {
|
||||
// Disable file search popup while history search mode is active.
|
||||
if self.history.is_search_active() {
|
||||
self.active_popup = ActivePopup::None;
|
||||
self.dismissed_file_popup_token = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if there is an @token underneath the cursor.
|
||||
let query = match Self::current_at_token(&self.textarea) {
|
||||
Some(token) => token,
|
||||
@@ -701,30 +835,105 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
let mut textarea_rect = area;
|
||||
textarea_rect.height = textarea_rect.height.saturating_sub(1);
|
||||
self.textarea.render(textarea_rect, buf);
|
||||
|
||||
// Always clear background and previous underlines for content cells to avoid stale styles
|
||||
let content_start_x = textarea_rect.x.saturating_add(1);
|
||||
for y in textarea_rect.y..(textarea_rect.y + textarea_rect.height) {
|
||||
for x in content_start_x..(textarea_rect.x + textarea_rect.width) {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
use ratatui::style::Modifier;
|
||||
let new_style = cell
|
||||
.style()
|
||||
.bg(Color::Reset)
|
||||
.remove_modifier(Modifier::UNDERLINED);
|
||||
cell.set_style(new_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When searching, underline exact matches of the query in the composer view
|
||||
if self.history.is_search_active() {
|
||||
if let Some(q) = self.history.search_query() {
|
||||
if !q.is_empty() {
|
||||
use ratatui::style::Modifier;
|
||||
let mut positions: Vec<(u16, u16)> = Vec::with_capacity(
|
||||
(textarea_rect.width as usize) * (textarea_rect.height as usize),
|
||||
);
|
||||
let mut visible = String::new();
|
||||
for y in textarea_rect.y..(textarea_rect.y + textarea_rect.height) {
|
||||
for x in content_start_x..(textarea_rect.x + textarea_rect.width) {
|
||||
if let Some(cell) = buf.cell((x, y)) {
|
||||
let ch = cell.symbol().chars().next().unwrap_or(' ');
|
||||
visible.push(ch);
|
||||
} else {
|
||||
visible.push(' ');
|
||||
}
|
||||
positions.push((x, y));
|
||||
}
|
||||
}
|
||||
// Exact, case-insensitive substring match of the query within visible text
|
||||
let vis_lower = visible.to_lowercase();
|
||||
let q_lower = q.to_lowercase();
|
||||
if vis_lower.len() >= q_lower.len() {
|
||||
let mut i = 0;
|
||||
while i + q_lower.len() <= vis_lower.len() {
|
||||
if vis_lower[i..i + q_lower.len()] == q_lower {
|
||||
for j in i..i + q_lower.len() {
|
||||
if let Some(&(x, y)) = positions.get(j) {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
let style = cell.style();
|
||||
let new_style =
|
||||
style.add_modifier(Modifier::UNDERLINED);
|
||||
cell.set_style(new_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += q_lower.len();
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut bottom_line_rect = area;
|
||||
bottom_line_rect.y += textarea_rect.height;
|
||||
bottom_line_rect.height = 1;
|
||||
let key_hint_style = Style::default().fg(Color::Cyan);
|
||||
let hint = if self.ctrl_c_quit_hint {
|
||||
vec![
|
||||
Span::from(" "),
|
||||
"Ctrl+C again".set_style(key_hint_style),
|
||||
Span::from(" to quit"),
|
||||
]
|
||||
|
||||
if self.history.is_search_active() {
|
||||
// Render backward incremental search prompt with query
|
||||
let mut spans = vec![Span::from(" "), Span::from("bck-i-search: ")];
|
||||
if let Some(q) = self.history.search_query() {
|
||||
spans.push(Span::from(q.to_string()));
|
||||
}
|
||||
Line::from(spans)
|
||||
.style(Style::default().dim())
|
||||
.render_ref(bottom_line_rect, buf);
|
||||
} else {
|
||||
vec![
|
||||
Span::from(" "),
|
||||
"⏎".set_style(key_hint_style),
|
||||
Span::from(" send "),
|
||||
"Shift+⏎".set_style(key_hint_style),
|
||||
Span::from(" newline "),
|
||||
"Ctrl+C".set_style(key_hint_style),
|
||||
Span::from(" quit"),
|
||||
]
|
||||
};
|
||||
Line::from(hint)
|
||||
.style(Style::default().dim())
|
||||
.render_ref(bottom_line_rect, buf);
|
||||
let key_hint_style = Style::default().fg(Color::Cyan);
|
||||
let hint = if self.ctrl_c_quit_hint {
|
||||
vec![
|
||||
Span::from(" "),
|
||||
"Ctrl+C again".set_style(key_hint_style),
|
||||
Span::from(" to quit"),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Span::from(" "),
|
||||
"⏎".set_style(key_hint_style),
|
||||
Span::from(" send "),
|
||||
"Shift+⏎".set_style(key_hint_style),
|
||||
Span::from(" newline "),
|
||||
"Ctrl+C".set_style(key_hint_style),
|
||||
Span::from(" quit"),
|
||||
]
|
||||
};
|
||||
Line::from(hint)
|
||||
.style(Style::default().dim())
|
||||
.render_ref(bottom_line_rect, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ use tui_textarea::TextArea;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_core::protocol::Op; // added for fuzzy search
|
||||
|
||||
/// State machine that manages shell-style history navigation (Up/Down) inside
|
||||
/// the chat composer. This struct is intentionally decoupled from the
|
||||
@@ -31,6 +32,9 @@ pub(crate) struct ChatComposerHistory {
|
||||
/// history navigation. Used to decide if further Up/Down presses should be
|
||||
/// treated as navigation versus normal cursor movement.
|
||||
last_history_text: Option<String>,
|
||||
|
||||
/// Search state (active only during Ctrl+K fuzzy history search).
|
||||
search: Option<SearchState>,
|
||||
}
|
||||
|
||||
impl ChatComposerHistory {
|
||||
@@ -42,6 +46,7 @@ impl ChatComposerHistory {
|
||||
fetched_history: HashMap::new(),
|
||||
history_cursor: None,
|
||||
last_history_text: None,
|
||||
search: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +58,43 @@ impl ChatComposerHistory {
|
||||
self.local_history.clear();
|
||||
self.history_cursor = None;
|
||||
self.last_history_text = None;
|
||||
// also reset any ongoing search
|
||||
self.search = None;
|
||||
}
|
||||
|
||||
/// Expose the current search query when active.
|
||||
pub fn search_query(&self) -> Option<&str> {
|
||||
self.search.as_ref().map(|s| s.query.as_str())
|
||||
}
|
||||
|
||||
/// Returns true when search mode is active and there are matches.
|
||||
pub fn search_has_matches(&self) -> bool {
|
||||
matches!(self.search.as_ref(), Some(s) if !s.matches.is_empty())
|
||||
}
|
||||
|
||||
/// Proactively prefetch the most recent `max_count` persistent history entries for search.
|
||||
pub fn prefetch_recent_for_search(&mut self, app_event_tx: &AppEventSender, max_count: usize) {
|
||||
let Some(log_id) = self.history_log_id else {
|
||||
return;
|
||||
};
|
||||
if self.history_entry_count == 0 || max_count == 0 {
|
||||
return;
|
||||
}
|
||||
// Start from newest offset and walk backwards
|
||||
let mut remaining = max_count;
|
||||
let mut offset = self.history_entry_count.saturating_sub(1);
|
||||
loop {
|
||||
if !self.fetched_history.contains_key(&offset) {
|
||||
let op = Op::GetHistoryEntryRequest { offset, log_id };
|
||||
app_event_tx.send(AppEvent::CodexOp(op));
|
||||
// Do not insert into fetched cache yet; wait for response
|
||||
}
|
||||
if remaining == 1 || offset == 0 {
|
||||
break;
|
||||
}
|
||||
remaining -= 1;
|
||||
offset -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a message submitted by the user in the current session so it can
|
||||
@@ -62,6 +104,19 @@ impl ChatComposerHistory {
|
||||
self.local_history.push(text.to_string());
|
||||
self.history_cursor = None;
|
||||
self.last_history_text = None;
|
||||
// Keep search query, but recompute matches if search is active (so newest appears first for empty query)
|
||||
if self.search.is_some() {
|
||||
let query = self
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| s.query.clone())
|
||||
.unwrap_or_default();
|
||||
let (matches, selected) = self.recompute_matches_for_query(&query);
|
||||
if let Some(s) = &mut self.search {
|
||||
s.matches = matches;
|
||||
s.selected = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +212,137 @@ impl ChatComposerHistory {
|
||||
self.replace_textarea_content(textarea, &text);
|
||||
return true;
|
||||
}
|
||||
// If we are actively searching, newly fetched items might match the query.
|
||||
if self.search.is_some() {
|
||||
let query = self
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| s.query.clone())
|
||||
.unwrap_or_default();
|
||||
let prev_len = self.search.as_ref().map(|s| s.matches.len()).unwrap_or(0);
|
||||
let (matches, selected) = self.recompute_matches_for_query(&query);
|
||||
if let Some(s) = &mut self.search {
|
||||
s.matches = matches;
|
||||
s.selected = selected;
|
||||
}
|
||||
let new_len = self.search.as_ref().map(|s| s.matches.len()).unwrap_or(0);
|
||||
if new_len != prev_len {
|
||||
// If first match changed, update the preview.
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Search API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Toggle or begin fuzzy search mode (Ctrl+R / Ctrl+K).
|
||||
pub fn search(&mut self) {
|
||||
if self.search.is_some() {
|
||||
self.search = None;
|
||||
return;
|
||||
}
|
||||
self.search = Some(SearchState::new());
|
||||
let query = self
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| s.query.clone())
|
||||
.unwrap_or_default();
|
||||
let (matches, selected) = self.recompute_matches_for_query(&query);
|
||||
if let Some(s) = &mut self.search {
|
||||
s.matches = matches;
|
||||
s.selected = selected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Is fuzzy search mode active?
|
||||
pub fn is_search_active(&self) -> bool {
|
||||
self.search.is_some()
|
||||
}
|
||||
|
||||
/// Exit search mode without changing the current textarea contents.
|
||||
pub fn exit_search(&mut self) {
|
||||
self.search = None;
|
||||
}
|
||||
|
||||
/// Append a character to the search query and update the preview.
|
||||
pub fn search_append_char(&mut self, ch: char, textarea: &mut TextArea) {
|
||||
if self.search.is_some() {
|
||||
let mut query = self
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| s.query.clone())
|
||||
.unwrap_or_default();
|
||||
query.push(ch);
|
||||
let (matches, selected) = self.recompute_matches_for_query(&query);
|
||||
if let Some(s) = &mut self.search {
|
||||
s.query = query;
|
||||
s.matches = matches;
|
||||
s.selected = selected;
|
||||
}
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the last character from the search query and update the preview.
|
||||
pub fn search_backspace(&mut self, textarea: &mut TextArea) {
|
||||
if self.search.is_some() {
|
||||
let mut query = self
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| s.query.clone())
|
||||
.unwrap_or_default();
|
||||
query.pop();
|
||||
let (matches, selected) = self.recompute_matches_for_query(&query);
|
||||
if let Some(s) = &mut self.search {
|
||||
s.query = query;
|
||||
s.matches = matches;
|
||||
s.selected = selected;
|
||||
}
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the entire search query and recompute matches (stays in search mode).
|
||||
pub fn search_clear_query(&mut self, textarea: &mut TextArea) {
|
||||
if self.search.is_some() {
|
||||
let (matches, selected) = self.recompute_matches_for_query("");
|
||||
if let Some(s) = &mut self.search {
|
||||
s.query.clear();
|
||||
s.matches = matches;
|
||||
s.selected = selected;
|
||||
}
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection to older match (Up).
|
||||
pub fn search_move_up(&mut self, textarea: &mut TextArea) {
|
||||
if let Some(s) = &mut self.search {
|
||||
if !s.matches.is_empty() && s.selected < s.matches.len() - 1 {
|
||||
s.selected += 1;
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection to newer match (Down).
|
||||
pub fn search_move_down(&mut self, textarea: &mut TextArea) {
|
||||
if let Some(s) = &mut self.search {
|
||||
if !s.matches.is_empty() {
|
||||
if s.selected > 0 {
|
||||
s.selected -= 1;
|
||||
} else {
|
||||
s.selected = 0;
|
||||
}
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -198,6 +381,121 @@ impl ChatComposerHistory {
|
||||
textarea.move_cursor(CursorMove::Jump(0, 0));
|
||||
self.last_history_text = Some(text.to_string());
|
||||
}
|
||||
|
||||
/// Compute search matches for a given query over known history (local + fetched).
|
||||
fn recompute_matches_for_query(&self, query: &str) -> (Vec<SearchMatch>, usize) {
|
||||
let mut matches: Vec<SearchMatch> = Vec::new();
|
||||
let mut selected: usize = 0;
|
||||
|
||||
if query.is_empty() {
|
||||
// Do not return any matches until at least one character is typed.
|
||||
return (matches, selected);
|
||||
}
|
||||
|
||||
// Non-empty query: fuzzy match over available items
|
||||
for (i, t) in self.local_history.iter().enumerate() {
|
||||
if let Some((_, score)) = fuzzy_match(t, query) {
|
||||
let global_idx = self.history_entry_count + i;
|
||||
matches.push(SearchMatch {
|
||||
idx: global_idx,
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (idx, t) in self.fetched_history.iter() {
|
||||
if let Some((_, score)) = fuzzy_match(t, query) {
|
||||
matches.push(SearchMatch { idx: *idx, score });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort primarily by score ascending (better first), then by recency (newer global index first)
|
||||
matches.sort_by(|a, b| a.score.cmp(&b.score).then(b.idx.cmp(&a.idx)));
|
||||
|
||||
if matches.is_empty() {
|
||||
selected = 0;
|
||||
} else if selected >= matches.len() {
|
||||
selected = matches.len() - 1;
|
||||
}
|
||||
(matches, selected)
|
||||
}
|
||||
|
||||
/// Apply the currently selected match (if any) into the textarea for preview/execute).
|
||||
fn apply_selected_to_textarea(&mut self, textarea: &mut TextArea) {
|
||||
let Some(s) = &self.search else { return };
|
||||
if s.matches.is_empty() {
|
||||
// No matches yet (likely empty query or awaiting fetch); leave composer unchanged.
|
||||
return;
|
||||
}
|
||||
let sel_idx = s.matches[s.selected].idx;
|
||||
let query = s.query.clone();
|
||||
let _ = s;
|
||||
if sel_idx >= self.history_entry_count {
|
||||
if let Some(text) = self.local_history.get(sel_idx - self.history_entry_count) {
|
||||
let t = text.clone();
|
||||
self.replace_textarea_content(textarea, &t);
|
||||
self.move_cursor_to_first_match(textarea, &t, &query);
|
||||
return;
|
||||
}
|
||||
} else if let Some(text) = self.fetched_history.get(&sel_idx) {
|
||||
let t = text.clone();
|
||||
self.replace_textarea_content(textarea, &t);
|
||||
self.move_cursor_to_first_match(textarea, &t, &query);
|
||||
return;
|
||||
}
|
||||
// Selected refers to an unfetched persistent entry: we can't preview; clear preview.
|
||||
self.replace_textarea_content(textarea, "");
|
||||
}
|
||||
|
||||
/// Move the cursor to the beginning of the first case-insensitive match of `query` in `text`.
|
||||
fn move_cursor_to_first_match(&self, textarea: &mut TextArea, text: &str, query: &str) {
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
let tl = text.to_lowercase();
|
||||
let ql = query.to_lowercase();
|
||||
if let Some(start) = tl.find(&ql) {
|
||||
// Compute row and col (in chars) at byte index `start`
|
||||
let mut row: u16 = 0;
|
||||
let mut col: u16 = 0;
|
||||
let mut count: usize = 0;
|
||||
for ch in text.chars() {
|
||||
if count == start {
|
||||
break;
|
||||
}
|
||||
if ch == '\n' {
|
||||
row = row.saturating_add(1);
|
||||
col = 0;
|
||||
} else {
|
||||
col = col.saturating_add(1);
|
||||
}
|
||||
count += ch.len_utf8();
|
||||
}
|
||||
textarea.move_cursor(CursorMove::Jump(row, col));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SearchMatch {
|
||||
idx: usize, // global history index
|
||||
score: i32, // lower is better
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SearchState {
|
||||
query: String,
|
||||
matches: Vec<SearchMatch>,
|
||||
selected: usize,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
query: String::new(),
|
||||
matches: Vec::new(),
|
||||
selected: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user