mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
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:
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+1
@@ -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 "
|
||||
|
||||
+1
@@ -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 "
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user