feat(tui2): add inline transcript find

Add an in-viewport find experience for the TUI2 transcript (the
scrollable region above the composer), without introducing a persistent
search bar or scroll indicator.

UX:
- Ctrl-F opens a 1-row "/ " prompt above the composer and updates
  highlights live as you type
- Ctrl-G jumps to the next match without closing the prompt, and keeps
  working after the prompt closes while the query is still active
- Esc closes the prompt but keeps the active query/highlights; Esc again
  clears the search
- Enter closes the prompt and jumps to the selected match

Implementation:
- Add a dedicated transcript_find module to own query/edit state,
  smart-case matching over flattened transcript lines, stable jump
  anchoring (cell + line-in-cell), and per-line highlight rendering
- Keep app.rs integration additive via small delegation calls from the
  key handler and render loop
- Plumb find visibility to the footer so shortcuts show Ctrl-G next match
  only while the find prompt is visible

Docs/tests:
- Add tui2/docs/transcript_find.md documenting current behavior vs the
  ideal/perfect end state and explicitly calling out deferred work
- Stabilize VT100-based rendering tests by forcing color output and
  emitting crossterm fg/bg colors directly in insert-history output
This commit is contained in:
Josh McKinney
2025-12-23 13:47:45 -08:00
parent 060c44ff78
commit 97c54634f7
10 changed files with 1236 additions and 12 deletions
+248
View File
@@ -0,0 +1,248 @@
# Transcript Find (Inline Viewport)
This document describes the design for “find in transcript” in the **TUI2 inline viewport** (the
main transcript region above the composer), not the full-screen transcript overlay.
The goal is to provide fast, low-friction navigation through the in-memory transcript while keeping
the UI predictable and the implementation easy to review/maintain.
---
## Goals
- **Search the inline viewport content**, derived from the same flattened transcript lines used for
scrolling/selection, so search results track what the user sees.
- **Ephemeral UI**: no always-on search bar and no scroll bar in this iteration.
- **Fast navigation**:
- highlight all matches
- jump to the next match repeatedly without reopening the prompt
- **Stable anchoring**: jumping should land on stable content anchors (cell + line), not raw screen
rows.
- **Reviewable architecture**: keep `app.rs` changes small by placing feature logic in a dedicated
module and calling it from the render loop and key handler.
---
## Current Implementation (What We Have Today)
This section documents the current state so it’s easy to compare against the “ideal/perfect” end
state discussed in review.
For implementation details, see the rustdoc comments and unit tests in `tui2/src/transcript_find.rs`.
### UI
- When active, a single prompt row is rendered **above the composer**:
- `"/ query current/total"`
- Matches are highlighted in the transcript:
- all matches: underlined
- current match: reversed + bold + underlined
- The prompt is **not persistent**: it only appears while editing.
### Keys
- `Ctrl-F`: open the find prompt and start editing the query.
- While editing:
- type to edit the query (highlights update as you type)
- `Backspace`: delete one character
- `Ctrl-U`: clear the query
- `Enter`: close the prompt and jump to a match (if any)
- `Esc`: close the prompt without clearing the query (highlights remain)
- `Ctrl-G`: jump to next match.
- Works while editing (prompt stays open).
- Works even after the prompt is closed, as long as the query is still active.
- `Esc` (when not editing and a query is active): clears the search/highlights.
### Footer hints
- When the find prompt is visible, the footer shows `Ctrl-G next match`:
- in the shortcut summary line
- and in the `?` shortcut overlay
### Implementation layout
- Core logic lives in `tui2/src/transcript_find.rs`:
- key handling
- match computation/caching
- jump selection
- per-line rendering helper (`render_line`) and prompt rendering helper (`render_prompt_line`)
- `tui2/src/app.rs` is kept mostly additive by delegating:
- early key handling delegation in `App::handle_key_event`
- per-frame recompute/jump hook after transcript flattening
- per-row render hook for match highlighting
- prompt + cursor positioning while editing
- Footer hint integration is wired via `set_transcript_ui_state(..., find_visible)` through:
- `tui2/src/chatwidget.rs`
- `tui2/src/bottom_pane/mod.rs`
- `tui2/src/bottom_pane/chat_composer.rs`
- `tui2/src/bottom_pane/footer.rs`
---
## UX and Keybindings
### Entering search
- `Ctrl-F` opens the find prompt on the line immediately above the composer.
- While the prompt is open, typed characters update the query and immediately update highlights.
### Navigating results
- `Ctrl-G` jumps to the next match.
- Works while the prompt is open.
- Also works after the prompt is closed as long as a non-empty query is still active (so users can
“keep stepping” through matches).
### Exiting / clearing
- `Esc` closes the prompt without clearing the active query (and therefore keeps highlights).
- `Esc` again (when not editing and a query is active) clears the search/highlights.
### Footer hints
When the find prompt is visible, we surface the relevant navigation key (`Ctrl-G`) in:
- the shortcut summary line (the default footer mode)
- the “?” shortcut overlay
This keeps the prompt itself visually minimal.
---
## Data Model: Search Over Flattened Lines
Search operates over the same representation as scrolling and selection:
1. Cells are flattened into a list of `Line<'static>` plus parallel `TranscriptLineMeta` entries
(see `tui2/src/tui/scrolling.rs` and `tui2/docs/tui_viewport_and_history.md`).
2. The find module searches **plain text** extracted from each flattened line (by concatenating its
spans’ contents).
3. Each match stores:
- `line_index` (index into flattened lines)
- `range` (byte range within the flattened line’s plain text)
- `anchor` derived from `TranscriptLineMeta::CellLine { cell_index, line_in_cell }`
The anchor is used to update `TranscriptScroll` when jumping so the viewport lands on stable content
even if the transcript grows.
---
## Matching Semantics
### Smart-case
The search is “smart-case”:
- If the query contains any ASCII uppercase, the match is case-sensitive.
- Otherwise, both haystack and needle are matched in ASCII-lowercased form.
This avoids expensive Unicode case folding and keeps behavior predictable in terminals.
---
## Rendering
### Highlights
- All matches are highlighted (currently: underlined).
- The “current match” is emphasized more strongly (currently: reversed + bold + underlined).
Highlighting is applied at render time for each visible line by splitting spans into segments and
patching styles for the match ranges.
### Prompt line
While editing, the line directly above the composer shows:
`/ query current/total`
It is rendered inside the transcript viewport area (not as a persistent UI element), and the cursor
is moved into this line while editing.
---
## Performance / Caching
Recomputing matches happens only when needed. The search module caches based on:
- transcript width (wrapping changes can change the flattened line list)
- number of flattened lines (transcript growth)
This keeps the work proportional to actual content changes rather than every frame.
---
## Code Layout (Additive, Review-Friendly)
The implementation is structured so `app.rs` only delegates:
- `tui2/src/transcript_find.rs` owns:
- query/edit state
- match computation and caching
- key handling for find-related shortcuts
- rendering helpers for highlighted lines and the prompt line
- producing a scroll anchor when a jump is requested
`app.rs` integration points are intentionally small:
- **Key handling**: early delegation to `TranscriptFind::handle_key_event`.
- **Render**:
- call `TranscriptFind::on_render` after building flattened lines to apply pending jumps
- call `TranscriptFind::render_line` per visible row
- render `render_prompt_line` when active and set cursor with `cursor_position`
- **Footer**:
- `set_transcript_ui_state(..., find_visible)` so the footer can show find-related hints only when
the prompt is visible.
---
## Comparison to the “Ideal” End State
### Ideal UX (what “perfect” looks like)
- **Ephemeral, minimal UI**: no always-on search bar, and no scroll bar for this feature.
- **Fast entry**: `Ctrl-F` opens a single prompt row above the composer.
- **Live feedback**: highlights update as you type, and the prompt shows `current/total`.
- **Repeat navigation without closing**: `Ctrl-G` jumps to the next match while the prompt stays
open, and continues to work after the prompt closes as long as the query is active.
- **Predictable exit semantics**:
- `Enter`: accept query, close prompt, and jump (if any matches)
- `Esc`: close the prompt but keep the query/highlights
- `Esc` again (with an active query): clear the query/highlights
- **Stable jumping**: navigation targets stable transcript anchors (cell + line-in-cell), so jumping
behaves well as the transcript grows.
- **Discoverability without clutter**: when the prompt is visible, the footer/shortcuts surface the
navigation key (`Ctrl-G`) so the prompt itself stays tight.
- **Future marker integration**: if/when a scroll indicator is introduced, match markers integrate
with it (faint ticks for match lines, stronger marker for the current match).
### Already aligned with the ideal
- Ephemeral prompt (no always-on bar).
- Live highlighting while typing.
- `Ctrl-G` repeat navigation without reopening the prompt (including while editing).
- Stable jump anchoring via `(cell_index, line_in_cell)` metadata.
- Footer hints (`Ctrl-G next match`) shown only while the prompt is visible.
- Minimal, review-friendly integration points in `app.rs` via `tui2/src/transcript_find.rs`.
### Not implemented yet (intentional deferrals)
- Prev match (e.g. `Ctrl-Shift-G`).
- “Contextual landing” when jumping (e.g. padding/centering so the match isn’t pinned to the top).
- Match markers integrated with a future scroll indicator.
### Known limitations / trade-offs in the current version
- Matching is ASCII smart-case (no full Unicode case folding).
- Match ranges are byte ranges in the flattened plain text. This is fine for styling spans by byte
slicing, but any future “column-precise” behaviors should be careful with multi-byte characters.
---
## Future Work (Not Implemented Here)
- **Prev match**: add `Ctrl-Shift-G` for previous match if desired.
- **Marker integration**: if/when a scroll indicator is added, include match markers derived from
match line indices (faint ticks) and a stronger marker for the current match.
- **Contextual jump placement**: center the current match (or provide padding above) rather than
placing it at the exact top row when jumping.
+46 -2
View File
@@ -18,6 +18,7 @@ use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::ResumeSelection;
use crate::transcript_copy_ui::TranscriptCopyUi;
use crate::transcript_find::TranscriptFind;
use crate::transcript_multi_click::TranscriptMultiClick;
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
use crate::transcript_selection::TranscriptSelection;
@@ -334,6 +335,7 @@ pub(crate) struct App {
transcript_view_top: usize,
transcript_total_lines: usize,
transcript_copy_ui: TranscriptCopyUi,
transcript_find: TranscriptFind,
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
@@ -502,6 +504,7 @@ impl App {
transcript_view_top: 0,
transcript_total_lines: 0,
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
transcript_find: TranscriptFind::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -651,7 +654,11 @@ impl App {
frame.buffer,
);
}
if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) {
if let Some((x, y)) =
self.transcript_find.cursor_position(frame.area(), chat_top)
{
frame.set_cursor_position((x, y));
} else if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) {
frame.set_cursor_position((x, y));
}
})?;
@@ -674,6 +681,7 @@ impl App {
selection_active,
scroll_position,
self.copy_selection_key(),
self.transcript_find.is_visible(),
);
}
}
@@ -722,6 +730,17 @@ impl App {
return area.y;
}
if let Some((cell_index, line_in_cell)) = self.transcript_find.on_render(
&lines,
&line_meta,
transcript_area.width,
self.transcript_view_top,
) {
self.transcript_scroll = TranscriptScroll::Scrolled {
cell_index,
line_in_cell,
};
}
let is_user_cell: Vec<bool> = cells
.iter()
.map(|c| c.as_any().is::<UserHistoryCell>())
@@ -793,7 +812,10 @@ impl App {
}
}
lines[line_index].render_ref(row_area, frame.buffer);
let line = self
.transcript_find
.render_line(line_index, &lines[line_index]);
line.render_ref(row_area, frame.buffer);
}
self.apply_transcript_selection(transcript_area, frame.buffer);
@@ -813,6 +835,19 @@ impl App {
} else {
self.transcript_copy_ui.clear_affordance();
}
if let Some(prompt_line) = self.transcript_find.render_prompt_line()
&& chat_top > area.y
{
let prompt_area = Rect {
x: area.x,
y: chat_top.saturating_sub(1),
width: area.width,
height: 1,
};
Clear.render_ref(prompt_area, frame.buffer);
Paragraph::new(prompt_line).render_ref(prompt_area, frame.buffer);
}
chat_top
}
@@ -1356,6 +1391,7 @@ impl App {
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.current_model = model_family.get_model_slug().to_string();
self.transcript_find.clear();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
@@ -1444,6 +1480,7 @@ impl App {
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
self.transcript_find.note_lines_changed();
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
if !display.is_empty() {
// Only insert a separating blank line for new cells that are not
@@ -1891,6 +1928,11 @@ impl App {
}
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
if self.transcript_find.handle_key_event(&key_event) {
tui.frame_requester().schedule_frame();
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('t'),
@@ -2115,6 +2157,7 @@ mod tests {
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
CopySelectionShortcut::CtrlShiftC,
),
transcript_find: TranscriptFind::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -2165,6 +2208,7 @@ mod tests {
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
CopySelectionShortcut::CtrlShiftC,
),
transcript_find: TranscriptFind::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -124,6 +124,7 @@ pub(crate) struct ChatComposer {
transcript_selection_active: bool,
transcript_scroll_position: Option<(usize, usize)>,
transcript_copy_selection_key: KeyBinding,
transcript_find_visible: bool,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
}
@@ -176,6 +177,7 @@ impl ChatComposer {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
skills: None,
dismissed_skill_popup_token: None,
};
@@ -1545,6 +1547,7 @@ impl ChatComposer {
transcript_selection_active: self.transcript_selection_active,
transcript_scroll_position: self.transcript_scroll_position,
transcript_copy_selection_key: self.transcript_copy_selection_key,
transcript_find_visible: self.transcript_find_visible,
}
}
@@ -1577,11 +1580,13 @@ impl ChatComposer {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: KeyBinding,
find_visible: bool,
) {
self.transcript_scrolled = scrolled;
self.transcript_selection_active = selection_active;
self.transcript_scroll_position = scroll_position;
self.transcript_copy_selection_key = copy_selection_key;
self.transcript_find_visible = find_visible;
}
fn sync_popups(&mut self) {
+41 -10
View File
@@ -26,6 +26,7 @@ pub(crate) struct FooterProps {
pub(crate) transcript_selection_active: bool,
pub(crate) transcript_scroll_position: Option<(usize, usize)>,
pub(crate) transcript_copy_selection_key: KeyBinding,
pub(crate) transcript_find_visible: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -119,6 +120,11 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
line.push_span(props.transcript_copy_selection_key);
line.push_span(" copy selection".dim());
}
if props.transcript_find_visible {
line.push_span(" · ".dim());
line.push_span(key_hint::ctrl(KeyCode::Char('g')));
line.push_span(" next match".dim());
}
vec![line]
}
FooterMode::ShortcutOverlay => {
@@ -187,6 +193,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut newline = Line::from("");
let mut file_paths = Line::from("");
let mut paste_image = Line::from("");
let mut find = Line::from("");
let mut find_next = Line::from("");
let mut edit_previous = Line::from("");
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
@@ -198,6 +206,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
ShortcutId::InsertNewline => newline = text,
ShortcutId::FilePaths => file_paths = text,
ShortcutId::PasteImage => paste_image = text,
ShortcutId::Find => find = text,
ShortcutId::FindNext => find_next = text,
ShortcutId::EditPrevious => edit_previous = text,
ShortcutId::Quit => quit = text,
ShortcutId::ShowTranscript => show_transcript = text,
@@ -205,16 +215,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
}
}
let ordered = vec![
commands,
newline,
file_paths,
paste_image,
edit_previous,
quit,
Line::from(""),
show_transcript,
];
let mut ordered = vec![commands, newline, file_paths, paste_image, find, find_next];
ordered.extend(vec![edit_previous, quit, Line::from(""), show_transcript]);
build_columns(ordered)
}
@@ -286,6 +288,8 @@ enum ShortcutId {
InsertNewline,
FilePaths,
PasteImage,
Find,
FindNext,
EditPrevious,
Quit,
ShowTranscript,
@@ -406,6 +410,24 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: " to paste images",
},
ShortcutDescriptor {
id: ShortcutId::Find,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('f')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to search transcript",
},
ShortcutDescriptor {
id: ShortcutId::FindNext,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('g')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " next match",
},
ShortcutDescriptor {
id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding {
@@ -469,6 +491,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -485,6 +508,7 @@ mod tests {
transcript_selection_active: true,
transcript_scroll_position: Some((3, 42)),
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -501,6 +525,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -517,6 +542,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -533,6 +559,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -549,6 +576,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -565,6 +593,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -581,6 +610,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -597,6 +627,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
}
+2
View File
@@ -387,12 +387,14 @@ impl BottomPane {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: crate::key_hint::KeyBinding,
find_visible: bool,
) {
self.composer.set_transcript_ui_state(
scrolled,
selection_active,
scroll_position,
copy_selection_key,
find_visible,
);
self.request_redraw();
}
@@ -12,5 +12,6 @@ expression: terminal.backend()
" "
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" ctrl + f to search transcript ctrl + g next match "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "
@@ -4,5 +4,6 @@ expression: terminal.backend()
---
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" ctrl + f to search transcript ctrl + g next match "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "
+2
View File
@@ -3097,12 +3097,14 @@ impl ChatWidget {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: crate::key_hint::KeyBinding,
find_visible: bool,
) {
self.bottom_pane.set_transcript_ui_state(
scrolled,
selection_active,
scroll_position,
copy_selection_key,
find_visible,
);
}
+1
View File
@@ -79,6 +79,7 @@ mod text_formatting;
mod tooltips;
mod transcript_copy;
mod transcript_copy_ui;
mod transcript_find;
mod transcript_multi_click;
mod transcript_render;
mod transcript_selection;
+889
View File
@@ -0,0 +1,889 @@
//! Inline "find in transcript" support for the TUI2 inline viewport.
//!
//! This module is intentionally UI-framework-light: it holds the find state and provides helpers
//! that `app.rs` calls from the normal render loop.
//!
//! **Integration points (in `tui2/src/app.rs`):**
//!
//! - Early key handling delegation via [`TranscriptFind::handle_key_event`]
//! - Per-frame update/jump hook via [`TranscriptFind::on_render`]
//! - Per-row highlight hook via [`TranscriptFind::render_line`]
//! - Prompt rendering + cursor positioning via [`TranscriptFind::render_prompt_line`] and
//! [`TranscriptFind::cursor_position`]
//!
//! The search operates on the flattened transcript lines (`Line<'static>`) that are already used
//! for scrolling and selection, and produces stable jump targets via
//! [`TranscriptLineMeta::cell_line`](TranscriptLineMeta::cell_line).
use crate::render::line_utils::line_to_static;
use crate::tui::scrolling::TranscriptLineMeta;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr as _;
use std::ops::Range;
#[derive(Debug, Default)]
/// Stateful "find in transcript" controller for the inline viewport.
///
/// The state is designed around the "ephemeral prompt" UX:
///
/// - `Ctrl-F` enters editing mode (prompt line becomes visible)
/// - `Esc` closes the prompt while keeping highlights (query remains)
/// - `Esc` again clears the query/highlights
/// - `Ctrl-G` advances to the next match while editing or while a query is still active
///
/// The caller is expected to:
///
/// - call [`TranscriptFind::on_render`] after flattening the transcript into `lines` + `line_meta`
/// - call [`TranscriptFind::render_line`] for visible rows
/// - use [`TranscriptFind::render_prompt_line`] + [`TranscriptFind::cursor_position`] when editing
pub(crate) struct TranscriptFind {
/// Current query text (plain UTF-8 string).
query: String,
/// Whether the prompt is currently visible and receiving input.
editing: bool,
/// Cached width of the flattened transcript viewport.
///
/// Width changes can change wrapping and therefore the flattened line list.
last_width: Option<u16>,
/// Cached flattened line count.
///
/// Note: callers should also invoke [`TranscriptFind::note_lines_changed`] when content changes
/// without changing the count (e.g., streaming updates).
last_lines_len: Option<usize>,
/// All matches across the flattened transcript, in display order (line-major, left-to-right).
matches: Vec<TranscriptFindMatch>,
/// Per-line mapping to indices into `matches`, used by `render_line`.
line_match_indices: Vec<Vec<usize>>,
/// Index into `matches` representing the current match (if any).
current_match: Option<usize>,
/// Stable identifier for the current match used to preserve selection across recompute.
///
/// `(line_index, match_range_start)`.
current_key: Option<(usize, usize)>,
/// A navigation action to apply on the next [`on_render`](TranscriptFind::on_render) call.
pending: Option<TranscriptFindPendingAction>,
}
#[derive(Debug, Clone, Copy)]
/// Deferred navigation requests applied at the next render.
///
/// This allows key events to be handled without directly mutating the scroll state; instead we
/// produce a stable anchor from [`TranscriptLineMeta`] during [`TranscriptFind::on_render`].
enum TranscriptFindPendingAction {
/// Jump to the "best" match for the current viewport (used after Enter).
Jump,
/// Advance the current match, wrapping around (used by Ctrl-G).
Next,
}
#[derive(Debug, Clone)]
/// One match within the flattened transcript.
///
/// Matches are stored in "scan order" (line-major, then left-to-right), and carry a stable anchor
/// for jumping the viewport.
struct TranscriptFindMatch {
line_index: usize,
range: Range<usize>,
/// Stable `(cell_index, line_in_cell)` anchor used to update scroll state.
anchor: Option<(usize, usize)>,
}
impl TranscriptFind {
/// Whether find is active (either editing or a non-empty query is present).
pub(crate) fn is_active(&self) -> bool {
self.editing || !self.query.is_empty()
}
/// Whether the find prompt should be visible.
pub(crate) fn is_visible(&self) -> bool {
self.editing
}
/// Force a recompute on the next render.
///
/// This is intended for "content changed but line count didn't" scenarios, such as streaming
/// assistant output updating in-place.
pub(crate) fn note_lines_changed(&mut self) {
if self.is_active() {
self.last_lines_len = None;
}
}
/// Handle find-related key events.
///
/// Returns `true` when the key was consumed by the find UI/state machine.
///
/// This method updates internal state only; it does not directly change the transcript scroll
/// position. Navigation keys (e.g. `Ctrl-G`, `Enter`) set a pending action that is applied on
/// the next [`on_render`](Self::on_render) call, where we can map the current match to a stable
/// `(cell_index, line_in_cell)` anchor.
pub(crate) fn handle_key_event(&mut self, key_event: &KeyEvent) -> bool {
match *key_event {
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.begin_edit();
true
}
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if self.editing => {
self.end_edit();
true
}
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if !self.query.is_empty() => {
self.clear();
true
}
KeyEvent {
code: KeyCode::Char('g'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if self.editing || !self.query.is_empty() => {
self.set_pending(TranscriptFindPendingAction::Next);
true
}
_ if self.editing => {
self.handle_edit_key(*key_event);
true
}
_ => false,
}
}
/// Cursor position for the prompt line (when editing).
///
/// The caller passes the full frame `area` and the computed top of the chat widget (`chat_top`)
/// so we can position the cursor on the line directly above the composer.
pub(crate) fn cursor_position(&self, area: Rect, chat_top: u16) -> Option<(u16, u16)> {
if !self.editing || chat_top <= area.y {
return None;
}
let prefix_w = "/ ".width() as u16;
let query_w = self.query.width() as u16;
let x = area
.x
.saturating_add(prefix_w)
.saturating_add(query_w)
.min(area.right().saturating_sub(1));
let y = chat_top.saturating_sub(1);
Some((x, y))
}
/// Update match state and apply any pending navigation request.
///
/// Returns a stable transcript anchor `(cell_index, line_in_cell)` to be consumed by the
/// transcript scroll state (e.g. to jump the viewport to the current match).
///
/// Returns `None` when:
/// - find is inactive, or
/// - no navigation action is pending, or
/// - the current match does not map to a stable anchor (e.g. no `CellLine` meta).
pub(crate) fn on_render(
&mut self,
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
width: u16,
preferred_line: usize,
) -> Option<(usize, usize)> {
if !self.is_active() {
return None;
}
self.ensure_up_to_date(lines, line_meta, width, preferred_line);
self.apply_pending(preferred_line)
}
/// Render a transcript line with match highlighting (if active).
///
/// Highlighting is computed on the flattened plain text of the line (see [`line_plain_text`])
/// and then applied back onto the styled spans by splitting and patching styles.
///
/// Styling conventions:
/// - All matches: underlined
/// - Current match: reversed + bold + underlined
pub(crate) fn render_line(&self, line_index: usize, line: &Line<'_>) -> Line<'static> {
if self.query.is_empty() {
return line_to_static(line);
}
let indices = self.match_indices_for_line(line_index);
if indices.is_empty() {
return line_to_static(line);
}
let mut ranges: Vec<(Range<usize>, Style)> = Vec::with_capacity(indices.len());
for idx in indices {
let m = &self.matches[*idx];
let style = if self.current_match == Some(*idx) {
Style::new().reversed().bold().underlined()
} else {
Style::new().underlined()
};
ranges.push((m.range.clone(), style));
}
highlight_line(line, &ranges)
}
/// Render the prompt row shown above the composer while editing.
pub(crate) fn render_prompt_line(&self) -> Option<Line<'static>> {
if !self.editing {
return None;
}
let (current, total) = self.match_summary();
let mut spans: Vec<Span<'static>> = vec!["/ ".dim()];
spans.push(self.query.clone().into());
if !self.query.is_empty() {
spans.push(format!(" {current}/{total}").dim());
}
Some(Line::from(spans))
}
/// Handle key events while editing the query.
///
/// This is only called when [`Self::editing`] is true and the event wasn't handled by the
/// top-level key bindings (e.g., `Ctrl-F`, `Ctrl-G`, `Esc`).
fn handle_edit_key(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
} => {
self.editing = false;
self.set_pending(TranscriptFindPendingAction::Jump);
}
KeyEvent {
code: KeyCode::Char('u'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.clear_query();
}
KeyEvent {
code: KeyCode::Backspace,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.backspace();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if !crate::key_hint::has_ctrl_or_alt(modifiers) => {
self.push_char(c);
}
_ => {}
}
}
/// Enter editing mode (idempotent).
fn begin_edit(&mut self) {
if self.editing {
return;
}
self.editing = true;
}
/// Exit editing mode without clearing the query/highlights.
fn end_edit(&mut self) {
self.editing = false;
}
/// Clear all state, including query/highlights and cached match results.
pub(crate) fn clear(&mut self) {
self.query.clear();
self.editing = false;
self.last_width = None;
self.last_lines_len = None;
self.matches.clear();
self.line_match_indices.clear();
self.current_match = None;
self.current_key = None;
self.pending = None;
}
/// Clear the query text while leaving editing mode unchanged.
///
/// This resets the cached width so matches will be recomputed when the user types again.
fn clear_query(&mut self) {
self.query.clear();
self.last_width = None;
}
/// Remove a single character from the end of the query.
fn backspace(&mut self) {
if self.query.pop().is_some() {
self.last_width = None;
}
}
/// Append one character to the query.
fn push_char(&mut self, ch: char) {
self.query.push(ch);
self.last_width = None;
}
/// Record a navigation request to be applied on the next render.
fn set_pending(&mut self, pending: TranscriptFindPendingAction) {
self.pending = Some(pending);
}
/// Recompute matches when the flattened transcript representation changes.
///
/// This function is keyed on `width` and `lines.len()` to avoid work on every frame; callers
/// should invoke [`TranscriptFind::note_lines_changed`] if content changes without affecting
/// those keys.
///
/// When recomputing:
/// - we preserve the selected match using [`Self::current_key`]
/// - we store a stable jump anchor (cell + line-in-cell) when available via
/// [`TranscriptLineMeta::cell_line`]
fn ensure_up_to_date(
&mut self,
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
width: u16,
preferred_line: usize,
) {
// Fast path: empty query means there are no matches, and we should treat find as inactive
// (even if `editing` is still true).
if self.query.is_empty() {
self.matches.clear();
self.line_match_indices.clear();
self.current_match = None;
self.current_key = None;
self.last_width = Some(width);
self.last_lines_len = Some(lines.len());
return;
}
// Cache key: if width and line count are unchanged, the flattened transcript representation
// is assumed stable enough to reuse the previous match computation. Callers should use
// `note_lines_changed()` when content changes without affecting these keys.
if self.last_width == Some(width) && self.last_lines_len == Some(lines.len()) {
return;
}
// Preserve selection across recompute by remembering the currently-selected match's
// identity (line index + start byte offset in the flattened plain text).
let current_key = self.current_key.take();
self.matches.clear();
self.line_match_indices = vec![Vec::new(); lines.len()];
// Scan each flattened line, extracting plain text and recording match ranges. We keep:
// - a global `matches` list in scan order for navigation (Ctrl-G)
// - a per-line index list for rendering highlights efficiently
for (line_index, line) in lines.iter().enumerate() {
let plain = line_plain_text(line);
let ranges = find_match_ranges(&plain, &self.query);
for range in ranges {
let idx = self.matches.len();
// Only `CellLine` entries have stable anchors suitable for scroll jumps.
let anchor = line_meta
.get(line_index)
.and_then(TranscriptLineMeta::cell_line);
self.matches.push(TranscriptFindMatch {
line_index,
range: range.clone(),
anchor,
});
self.line_match_indices[line_index].push(idx);
}
}
// Choose the current match:
// 1) Prefer to keep the previous selection if it still exists in the new match set.
// 2) Otherwise, pick the first match at/after the preferred line (top of the viewport).
// 3) Otherwise, wrap to the first match.
self.current_match = current_key
.and_then(|(line_index, start)| {
self.matches
.iter()
.position(|m| m.line_index == line_index && m.range.start == start)
})
.or_else(|| {
self.matches
.iter()
.position(|m| m.line_index >= preferred_line)
.or_else(|| (!self.matches.is_empty()).then_some(0))
});
self.current_key = self.current_match.map(|i| {
let m = &self.matches[i];
(m.line_index, m.range.start)
});
self.last_width = Some(width);
self.last_lines_len = Some(lines.len());
}
/// Apply a pending navigation action (if any) and return the anchor to scroll to.
///
/// The returned `(cell_index, line_in_cell)` is derived from [`TranscriptLineMeta`] and is used
/// by the caller to update the transcript scroll state.
///
/// Note: the internal "current match" is updated even when an anchor is unavailable; in that
/// case this returns `None` and the caller should not change scroll position.
fn apply_pending(&mut self, preferred_line: usize) -> Option<(usize, usize)> {
let pending = self.pending.take()?;
if self.matches.is_empty() {
self.current_match = None;
self.current_key = None;
return None;
}
match pending {
TranscriptFindPendingAction::Jump => {
if self.current_match.is_none() {
self.current_match = self
.matches
.iter()
.position(|m| m.line_index >= preferred_line)
.or_else(|| (!self.matches.is_empty()).then_some(0));
}
}
TranscriptFindPendingAction::Next => {
self.current_match = Some(match self.current_match {
Some(i) => (i + 1) % self.matches.len(),
None => 0,
});
}
}
self.current_key = self.current_match.map(|i| {
let m = &self.matches[i];
(m.line_index, m.range.start)
});
self.current_match.and_then(|i| self.matches[i].anchor)
}
/// Return indices into `matches` for the given flattened line index.
///
/// Out-of-range indices return an empty slice to simplify call sites.
fn match_indices_for_line(&self, line_index: usize) -> &[usize] {
self.line_match_indices
.get(line_index)
.map_or(&[], |v| v.as_slice())
}
/// Return `(current, total)` match counts for the prompt display.
///
/// `current` is 1-based (`0` when no match is selected), and `total` is the number of matches.
fn match_summary(&self) -> (usize, usize) {
let total = self.matches.len();
let current = self.current_match.map(|i| i + 1).unwrap_or(0);
(current, total)
}
}
/// Convert a styled [`Line`] into plain text by concatenating its spans.
///
/// Find operates on plain text for match computation. Styling is applied later during
/// [`highlight_line`].
fn line_plain_text(line: &Line<'_>) -> String {
let mut out = String::new();
for span in &line.spans {
out.push_str(span.content.as_ref());
}
out
}
/// Find all non-overlapping match ranges of `needle` within `haystack`.
///
/// Uses "smart-case" semantics:
/// - If `needle` contains any ASCII uppercase letters, matching is case-sensitive.
/// - Otherwise, both strings are compared in ASCII lowercase.
fn find_match_ranges(haystack: &str, needle: &str) -> Vec<Range<usize>> {
if needle.is_empty() {
return Vec::new();
}
let is_case_sensitive = needle.chars().any(|c| c.is_ascii_uppercase());
if is_case_sensitive {
find_match_ranges_exact(haystack, needle)
} else {
let haystack = haystack.to_ascii_lowercase();
let needle = needle.to_ascii_lowercase();
find_match_ranges_exact(&haystack, &needle)
}
}
/// Find all non-overlapping match ranges of `needle` within `haystack` (case-sensitive).
///
/// Ranges are byte indices into the provided `haystack` string.
fn find_match_ranges_exact(haystack: &str, needle: &str) -> Vec<Range<usize>> {
let mut out = Vec::new();
let mut start = 0usize;
while start <= haystack.len() {
let Some(rel) = haystack[start..].find(needle) else {
break;
};
let abs = start + rel;
let end = abs + needle.len();
out.push(abs..end);
start = end;
}
out
}
/// Apply highlighting styles to the provided `line`.
///
/// `ranges` is a list of `(byte_range, style)` pairs referring to byte offsets in the flattened
/// plain text of `line` (see [`line_plain_text`]). The implementation preserves existing span
/// styles by patching the requested highlight style onto each affected segment.
fn highlight_line(line: &Line<'_>, ranges: &[(Range<usize>, Style)]) -> Line<'static> {
if ranges.is_empty() {
return line_to_static(line);
}
// We treat `line` as a single flattened string (see `line_plain_text`) and map highlight byte
// ranges back onto the individual styled spans. As we walk spans left-to-right we keep a
// "global" byte cursor into the flattened text plus an index into `ranges`.
let mut out: Vec<Span<'static>> = Vec::new();
let mut global_pos = 0usize;
let mut range_idx = 0usize;
for span in &line.spans {
let text = span.content.as_ref();
let span_start = global_pos;
let span_end = span_start + text.len();
global_pos = span_end;
// Skip ranges that end before this span begins. This keeps `range_idx` pointing at the
// first range that could possibly overlap the current span.
while range_idx < ranges.len() && ranges[range_idx].0.end <= span_start {
range_idx += 1;
}
// `local_pos` tracks how much of the current span we've emitted. We may need to split the
// span into multiple segments as highlight ranges start/end within it.
let mut local_pos = 0usize;
while range_idx < ranges.len() {
let (range, extra_style) = &ranges[range_idx];
if range.start >= span_end {
break;
}
// Clamp the global match range to this span's global extent.
let start = range.start.max(span_start);
let end = range.end.min(span_end);
let start_local = start - span_start;
// Emit any unhighlighted segment that appears before the match starts.
if start_local > local_pos {
out.push(Span::styled(
text[local_pos..start_local].to_string(),
span.style,
));
}
let end_local = end - span_start;
// Emit the highlighted segment, preserving existing style by patching it with the
// requested highlight style.
out.push(Span::styled(
text[start_local..end_local].to_string(),
span.style.patch(*extra_style),
));
local_pos = end_local;
// Advance to the next range when we've consumed the entire range within this span.
// When the range extends past this span, we keep `range_idx` pinned and continue the
// highlighting into the next span.
if range.end <= span_end {
range_idx += 1;
} else {
break;
}
}
// Emit any remaining tail of the span that doesn't intersect a highlight range.
if local_pos < text.len() {
out.push(Span::styled(text[local_pos..].to_string(), span.style));
}
}
Line::from(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
/// Build `TranscriptLineMeta` for a single-cell transcript with `line_count` lines.
fn meta_cell_lines(line_count: usize) -> Vec<TranscriptLineMeta> {
(0..line_count)
.map(|line_in_cell| TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell,
})
.collect()
}
/// Convenience for a key press with no modifiers.
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
/// Convenience for a key press with the control modifier.
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
#[test]
fn smart_case_and_pending_jump() {
let lines: Vec<Line<'static>> = vec![Line::from("hello World"), Line::from("second world")];
let meta = vec![
TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 0,
},
TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 1,
},
];
let mut find = TranscriptFind {
query: "world".to_string(),
..Default::default()
};
assert_eq!(find.on_render(&lines, &meta, 80, 0), None);
assert_eq!(find.matches.len(), 2);
find.current_match = None;
find.current_key = None;
find.pending = Some(TranscriptFindPendingAction::Jump);
let anchor = find.on_render(&lines, &meta, 80, 1);
assert_eq!(anchor, Some((0, 1)));
find.clear();
find.query = "World".to_string();
let _ = find.on_render(&lines, &meta, 80, 0);
assert_eq!(
find.matches
.iter()
.map(|m| m.line_index)
.collect::<Vec<_>>(),
vec![0]
);
}
#[test]
fn ctrl_f_enters_editing_and_renders_prompt() {
let mut find = TranscriptFind::default();
assert!(!find.is_active());
assert!(find.handle_key_event(&ctrl(KeyCode::Char('f'))));
assert!(find.is_active());
assert!(find.is_visible());
let prompt = find.render_prompt_line().expect("prompt line");
assert_eq!(
prompt
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<Vec<_>>(),
vec!["/ ", ""]
);
}
#[test]
fn edit_keys_modify_query() {
let mut find = TranscriptFind::default();
let _ = find.handle_key_event(&ctrl(KeyCode::Char('f')));
let _ = find.handle_key_event(&key(KeyCode::Char('a')));
let _ = find.handle_key_event(&key(KeyCode::Char('b')));
assert_eq!(find.query, "ab");
let _ = find.handle_key_event(&key(KeyCode::Backspace));
assert_eq!(find.query, "a");
let _ = find.handle_key_event(&ctrl(KeyCode::Char('u')));
assert_eq!(find.query, "");
}
#[test]
fn enter_closes_prompt_and_requests_jump() {
let lines: Vec<Line<'static>> = vec![Line::from("one two"), Line::from("two three")];
let meta = meta_cell_lines(lines.len());
let mut find = TranscriptFind::default();
let _ = find.handle_key_event(&ctrl(KeyCode::Char('f')));
for ch in "two".chars() {
let _ = find.handle_key_event(&key(KeyCode::Char(ch)));
}
let _ = find.handle_key_event(&key(KeyCode::Enter));
assert!(!find.is_visible());
assert!(find.is_active());
assert_eq!(find.render_prompt_line(), None);
let anchor = find.on_render(&lines, &meta, 80, 0);
assert_eq!(anchor, Some((0, 0)));
}
#[test]
fn esc_closes_prompt_then_clears_query() {
let mut find = TranscriptFind::default();
let _ = find.handle_key_event(&ctrl(KeyCode::Char('f')));
for ch in "two".chars() {
let _ = find.handle_key_event(&key(KeyCode::Char(ch)));
}
let _ = find.handle_key_event(&key(KeyCode::Esc));
assert!(!find.is_visible());
assert!(find.is_active());
assert_eq!(find.query, "two");
let _ = find.handle_key_event(&key(KeyCode::Esc));
assert!(!find.is_active());
assert_eq!(find.query, "");
}
#[test]
fn ctrl_g_cycles_matches_after_prompt_closed() {
let lines: Vec<Line<'static>> =
vec![Line::from("world world"), Line::from("another world")];
let meta = meta_cell_lines(lines.len());
let mut find = TranscriptFind::default();
let _ = find.handle_key_event(&ctrl(KeyCode::Char('f')));
for ch in "world".chars() {
let _ = find.handle_key_event(&key(KeyCode::Char(ch)));
}
let _ = find.handle_key_event(&key(KeyCode::Enter));
let a0 = find.on_render(&lines, &meta, 80, 0);
assert_eq!(a0, Some((0, 0)));
assert!(find.handle_key_event(&ctrl(KeyCode::Char('g'))));
let a1 = find.on_render(&lines, &meta, 80, 0);
assert_eq!(a1, Some((0, 0)));
assert!(find.handle_key_event(&ctrl(KeyCode::Char('g'))));
let a2 = find.on_render(&lines, &meta, 80, 0);
assert_eq!(a2, Some((0, 1)));
assert!(find.handle_key_event(&ctrl(KeyCode::Char('g'))));
let a3 = find.on_render(&lines, &meta, 80, 0);
assert_eq!(a3, Some((0, 0)));
}
#[test]
fn note_lines_changed_forces_recompute() {
let mut find = TranscriptFind {
query: "hello".to_string(),
..Default::default()
};
let lines_v1 = vec![Line::from("hello")];
let meta = meta_cell_lines(lines_v1.len());
let _ = find.on_render(&lines_v1, &meta, 80, 0);
assert_eq!(find.matches.len(), 1);
find.note_lines_changed();
assert_eq!(find.last_lines_len, None);
let lines_v2 = vec![Line::from("world")];
let _ = find.on_render(&lines_v2, &meta, 80, 0);
assert_eq!(find.matches.len(), 0);
}
#[test]
fn render_line_highlights_current_match_more_strongly() {
let lines = vec![Line::from("aa")];
let meta = meta_cell_lines(lines.len());
let mut find = TranscriptFind {
query: "a".to_string(),
..Default::default()
};
let _ = find.on_render(&lines, &meta, 80, 0);
let rendered = find.render_line(0, &lines[0]);
assert_eq!(
rendered,
Line::from(vec![
Span::styled("a", Style::new().reversed().bold().underlined()),
Span::styled("a", Style::new().underlined()),
])
);
}
#[test]
fn highlight_line_supports_ranges_across_span_boundaries() {
let line = Line::from(vec!["hello ".into(), "world".into()]);
let query = "o w";
let ranges = find_match_ranges(&line_plain_text(&line), query);
assert_eq!(ranges, vec![4..7]);
let rendered = highlight_line(&line, &[(ranges[0].clone(), Style::new().underlined())]);
assert_eq!(
rendered,
Line::from(vec![
Span::styled("hell".to_string(), Style::default()),
Span::styled("o ".to_string(), Style::new().underlined()),
Span::styled("w".to_string(), Style::new().underlined()),
Span::styled("orld".to_string(), Style::default()),
])
);
}
#[test]
fn cursor_position_clamps_to_prompt_line_width() {
let mut find = TranscriptFind {
query: "abcdef".to_string(),
editing: true,
..Default::default()
};
let area = Rect::new(10, 5, 5, 5);
let (x, y) = find.cursor_position(area, 8).expect("cursor");
assert_eq!(y, 7);
assert_eq!(x, area.right().saturating_sub(1));
find.clear();
}
#[test]
fn ctrl_g_is_ignored_when_inactive() {
let mut find = TranscriptFind::default();
assert!(!find.handle_key_event(&ctrl(KeyCode::Char('g'))));
}
}