Compare commits

...

4 Commits

Author SHA1 Message Date
pap
6dee56f261 Merge branch 'main' into ctrl-r-history 2025-08-03 00:20:43 +01:00
pap
eea749a154 remove fuzzy match, dead code and move cursor when doing ctrl+r 2025-08-03 00:12:11 +01:00
pap
3784aab510 fix overflow of characters 2025-08-02 22:20:14 +01:00
pap
87d33a8fdb initial work on ctrl+r for history search 2025-08-01 01:44:34 +01:00
2 changed files with 698 additions and 22 deletions

View File

@@ -3,6 +3,7 @@ use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
@@ -12,6 +13,7 @@ use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use tui_textarea::Input;
use tui_textarea::Key;
use tui_textarea::TextArea;
@@ -414,6 +416,106 @@ 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 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);
if !self.history.search_has_matches() {
// Pull in more older entries to widen the search scope.
self.history.fetch_more_for_search(&self.app_event_tx, 100);
}
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);
if !self.history.search_has_matches() {
self.history.fetch_more_for_search(&self.app_event_tx, 100);
}
return (InputResult::None, true);
}
Input {
key: Key::Char(c),
ctrl: false,
alt: false,
..
} => {
self.history.search_append_char(c, &mut self.textarea);
if !self.history.search_has_matches() {
self.history.fetch_more_for_search(&self.app_event_tx, 100);
}
return (InputResult::None, true);
}
_ => { /* fall-through for Enter and other controls */ }
}
}
match input {
// -------------------------------------------------------------
// History navigation (Up / Down) only when the composer is not
@@ -448,6 +550,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();
@@ -493,6 +598,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),
}
}
@@ -579,6 +701,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.
@@ -611,6 +739,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,
@@ -709,35 +844,114 @@ 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 cleaning previous search results
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)) {
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() {
let mut positions: Vec<(u16, u16)> = Vec::with_capacity(
(textarea_rect.width as usize) * (textarea_rect.height as usize),
);
let mut visible_chars: Vec<char> = Vec::with_capacity(
(textarea_rect.width as usize) * (textarea_rect.height as usize),
);
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_chars.push(ch);
} else {
visible_chars.push(' ');
}
positions.push((x, y));
}
}
// Exact, case-insensitive substring match of the query within visible text
let vis_lower: Vec<char> = visible_chars
.iter()
.map(|c| c.to_lowercase().next().unwrap_or(*c))
.collect();
let q_lower_chars: Vec<char> = q.to_lowercase().chars().collect();
if vis_lower.len() >= q_lower_chars.len() {
let mut i = 0;
while i + q_lower_chars.len() <= vis_lower.len() {
if vis_lower[i..i + q_lower_chars.len()] == q_lower_chars[..] {
for j in i..i + q_lower_chars.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_chars.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 {
let newline_hint_key = if self.use_shift_enter_hint {
"Shift+⏎"
let hint = if self.ctrl_c_quit_hint {
vec![
Span::from(" "),
"Ctrl+C again".set_style(key_hint_style),
Span::from(" to quit"),
]
} else {
"Ctrl+J"
let newline_hint_key = if self.use_shift_enter_hint {
"Shift+⏎"
} else {
"Ctrl+J"
};
vec![
Span::from(" "),
"".set_style(key_hint_style),
Span::from(" send "),
newline_hint_key.set_style(key_hint_style),
Span::from(" newline "),
"Ctrl+C".set_style(key_hint_style),
Span::from(" quit"),
]
};
vec![
Span::from(" "),
"".set_style(key_hint_style),
Span::from(" send "),
newline_hint_key.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);
Line::from(hint)
.style(Style::default().dim())
.render_ref(bottom_line_rect, buf);
}
}
}
}
@@ -918,6 +1132,36 @@ mod tests {
}
}
// #[test]
// fn desired_height_accounts_for_wrapping_long_lines() {
// use crossterm::event::KeyCode;
// use crossterm::event::KeyEvent;
// use crossterm::event::KeyModifiers;
// let (tx, _rx) = std::sync::mpsc::channel();
// let sender = AppEventSender::new(tx);
// let mut composer = ChatComposer::new(true, sender);
// // Long single-line history entry, typical of a pasted prompt.
// let long_line = "a".repeat(100);
// // Simulate submitting once so it exists in local history and Ctrl+R can preview it.
// composer.textarea.insert_str(&long_line);
// let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// match result {
// InputResult::Submitted(text) => assert_eq!(text, long_line),
// _ => panic!("expected Submitted"),
// }
// // Activate search and type a minimal query to select it.
// let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
// let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
// // With a small width, desired height should wrap the single logical line into multiple rows.
// let width: u16 = 20; // leaves ~19 chars of content width due to left border
// let h = composer.desired_height(width);
// assert!(h > 2, "expected more than one content row plus status line, got {h}");
// }
#[test]
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
use crossterm::event::KeyCode;

View File

@@ -31,6 +31,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+R history search).
search: Option<SearchState>,
}
impl ChatComposerHistory {
@@ -42,6 +45,7 @@ impl ChatComposerHistory {
fetched_history: HashMap::new(),
history_cursor: None,
last_history_text: None,
search: None,
}
}
@@ -53,6 +57,75 @@ impl ChatComposerHistory {
self.local_history.clear();
self.history_cursor = None;
self.last_history_text = None;
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;
}
}
/// When search is active but there are no matches (or we want deeper coverage),
/// fetch additional older persistent entries beyond those already cached.
pub fn fetch_more_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;
}
// Determine the next range of older offsets to fetch. Start from one before the
// oldest cached offset; if nothing is cached, start from newest.
let start_offset = match self.fetched_history.keys().min().copied() {
Some(min_cached) if min_cached > 0 => min_cached - 1,
Some(_) => return, // already at oldest
None => self.history_entry_count.saturating_sub(1),
};
let mut remaining = max_count;
let mut offset = start_offset;
loop {
if !self.fetched_history.contains_key(&offset) {
let op = Op::GetHistoryEntryRequest { offset, log_id };
app_event_tx.send(AppEvent::CodexOp(op));
}
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 +135,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 +243,106 @@ 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
}
/// Toggle or begin history search mode (Ctrl+R)
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;
}
}
pub fn is_search_active(&self) -> bool {
self.search.is_some()
}
pub fn exit_search(&mut self) {
self.search = None;
}
/// Append a character to the search query and update the preview.
/// used when the user types and we update the search query
pub fn search_append_char(&mut self, ch: char, textarea: &mut TextArea) {
self.update_search_query(textarea, |query| query.push(ch));
}
/// Remove the last character from the search query and update the preview.
pub fn search_backspace(&mut self, textarea: &mut TextArea) {
self.update_search_query(textarea, |query| {
query.pop();
});
}
/// 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,145 @@ 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).
/// Uses exact, case-insensitive substring matching; newer entries are preferred.
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);
}
let q_lower = query.to_lowercase();
// Local entries (newest at end), compute global index then push if contains
for (i, t) in self.local_history.iter().enumerate() {
if t.to_lowercase().contains(&q_lower) {
let global_idx = self.history_entry_count + i;
matches.push(SearchMatch { idx: global_idx });
}
}
// Fetched persistent entries
for (idx, t) in self.fetched_history.iter() {
if t.to_lowercase().contains(&q_lower) {
matches.push(SearchMatch { idx: *idx });
}
}
// Sort by recency (newer global index first)
matches.sort_by(|a, b| 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));
}
}
// Extract common logic for updating the search query, recomputing matches, and applying selection.
fn update_search_query<F>(&mut self, textarea: &mut TextArea, modify: F)
where
F: FnOnce(&mut String),
{
// Move out the current search state or exit if inactive
let mut state = match self.search.take() {
Some(s) => s,
None => return,
};
// Clone and modify the query
let mut query = state.query.clone();
modify(&mut query);
// Recompute matches based on modified query
let (matches, selected) = self.recompute_matches_for_query(&query);
// Update the moved-out state
state.query = query;
state.matches = matches;
state.selected = selected;
// Restore the state and apply selection update
self.search = Some(state);
self.apply_selected_to_textarea(textarea);
}
}
#[derive(Debug, Clone)]
struct SearchMatch {
idx: usize, // global history index
}
#[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)]
@@ -260,4 +582,114 @@ mod tests {
history.on_entry_response(1, 1, Some("older".into()), &mut textarea);
assert_eq!(textarea.lines().join("\n"), "older");
}
#[test]
fn search_moves_cursor_to_first_match_ascii() {
let mut history = ChatComposerHistory::new();
let mut textarea = TextArea::default();
// Record a local entry that will be matched.
history.record_local_submission("hello world");
// Begin search and type a query that has an exact substring match.
history.search();
for ch in ['w', 'o'] {
history.search_append_char(ch, &mut textarea);
}
// Expect the textarea to preview the matched entry and the cursor to
// be positioned at the first character of the first match (the 'w').
assert_eq!(textarea.lines().join("\n"), "hello world");
let (row, col) = textarea.cursor();
assert_eq!((row, col), (0, 6));
}
#[test]
fn search_moves_cursor_to_first_match_multiline_case_insensitive() {
let mut history = ChatComposerHistory::new();
let mut textarea = TextArea::default();
history.record_local_submission("foo\nBARbaz");
history.search();
for ch in ['b', 'a', 'r'] {
history.search_append_char(ch, &mut textarea);
}
// Cursor should point to the 'B' on the second line.
assert_eq!(textarea.lines().join("\n"), "foo\nBARbaz");
let (row, col) = textarea.cursor();
assert_eq!((row, col), (1, 0));
}
#[test]
fn search_moves_cursor_correctly_with_multibyte_chars() {
let mut history = ChatComposerHistory::new();
let mut textarea = TextArea::default();
history.record_local_submission("héllo world");
history.search();
for ch in ['w', 'o', 'r', 'l', 'd'] {
history.search_append_char(ch, &mut textarea);
}
// The cursor should be after 6 visible characters: "héllo ".
assert_eq!(textarea.lines().join("\n"), "héllo world");
let (row, col) = textarea.cursor();
assert_eq!((row, col), (0, 6));
}
#[test]
fn search_uses_exact() {
let mut history = ChatComposerHistory::new();
let mut textarea = TextArea::default();
history.record_local_submission("hello world");
history.search();
for ch in ['h', 'l', 'd'] {
// non-contiguous; would be fuzzy, not substring
history.search_append_char(ch, &mut textarea);
}
// No exact substring match for the final query; keep the previous preview
// (from the intermediate "h" match) instead of clearing.
assert_eq!(textarea.lines().join("\n"), "hello world");
}
#[test]
fn search_prefers_newer_match_by_recency() {
let mut history = ChatComposerHistory::new();
let mut textarea = TextArea::default();
history.record_local_submission("foo one");
history.record_local_submission("second foo");
history.search();
for ch in ['f', 'o', 'o'] {
history.search_append_char(ch, &mut textarea);
}
// Newer entry containing "foo" should be selected first.
assert_eq!(textarea.lines().join("\n"), "second foo");
}
#[test]
fn search_is_case_insensitive_and_moves_cursor_to_match_start() {
let mut history = ChatComposerHistory::new();
let mut textarea = TextArea::default();
history.record_local_submission("alpha COUNTRY beta");
history.search();
for ch in ['c', 'o', 'u', 'n', 't', 'r', 'y'] {
history.search_append_char(ch, &mut textarea);
}
assert_eq!(textarea.lines().join("\n"), "alpha COUNTRY beta");
// Cursor should be at start of COUNTRY, which begins at col 6 (after "alpha ")
let (row, col) = textarea.cursor();
assert_eq!((row, col), (0, 6));
}
}