Files
codex/codex-rs/tui/src/transcript_view/follow_control.rs
T
Eric Traut df7f717c85 Add transcript search and per-activity detail controls (#46734)
## What changed

- Add incremental, case-insensitive literal search across transcript details, loading older history as needed. Use `F3` to start Find, or `/` in the transcript pager, and navigate matches in either direction. Closing Find restores the previous presentation and reading position.
- Add clickable activity detail controls and `F4` keyboard focus for inspecting individual activity groups in the owned transcript. Preserve expansion state as live activity becomes history.
- Expose configurable `global.find_transcript`, `global.focus_activity`, and `pager.find` bindings under `tui.keymap`. Let new defaults yield to existing custom bindings and show available completions for pending chords.

## Testing

Add regression coverage for Unicode matching, bounded scanning, paginated and changing history, selection during search, composer input isolation, activity controls, and custom shortcut conflicts.

GitOrigin-RevId: 5bcbcdfbca7c5c296c635ec9b079ccd7a908e336
2026-09-20 00:06:25 +00:00

107 lines
3.5 KiB
Rust

//! Return-to-latest affordance in the owned transcript's existing composer gap.
//! Rendering owns the hit rectangle; hidden controls never intercept pointer input.
use super::*;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use ratatui::layout::Position as ScreenPosition;
use ratatui::widgets::Paragraph;
use unicode_width::UnicodeWidthStr;
#[derive(Default)]
pub(super) struct FollowControl {
area: Option<Rect>,
pointer: Option<ScreenPosition>,
pressed: bool,
}
impl TranscriptView {
pub(crate) fn render_follow_control(&mut self, area: Option<Rect>, buf: &mut Buffer) {
let area = area.filter(|area| {
!area.is_empty()
&& self.can_return_to_latest()
&& self.highlight.is_none()
&& self.selection.is_none()
&& !self.is_search_active()
});
let Some(area) = area else {
self.follow_control = FollowControl::default();
return;
};
let labels = if self.unseen_activity {
[
" New activity · ↓ Back to bottom · esc ",
" New activity · ↓ Bottom ",
" New · ↓ Bottom ",
" ↓ Bottom ",
" ↓ ",
]
} else {
[
" ↓ Back to bottom · esc ",
" ↓ Back to bottom ",
" ↓ Bottom · esc ",
" ↓ Bottom ",
" ↓ ",
]
};
let Some(label) = labels
.into_iter()
.find(|label| label.width() <= usize::from(area.width))
else {
self.follow_control = FollowControl::default();
return;
};
let width = label.width() as u16;
let target = Rect::new(
area.x + (area.width - width) / 2,
area.y,
width,
/*height*/ 1,
);
let hovered = self
.follow_control
.pointer
.is_some_and(|point| target.contains(point));
self.follow_control.area = Some(target);
let style =
crate::style::user_message_style().fg(crate::style::user_message_accent_color());
let style = if hovered {
style.reversed().bold()
} else {
style
};
Paragraph::new(label).style(style).render(target, buf);
}
pub(super) fn handle_follow_control_mouse(&mut self, event: MouseEvent) -> Option<ViewAction> {
let control = &mut self.follow_control;
let area = control.area?;
let point = ScreenPosition::new(event.column, event.row);
let was_hovered = control.pointer.is_some_and(|point| area.contains(point));
let inside = area.contains(point);
control.pointer = Some(point);
match event.kind {
MouseEventKind::Down(MouseButton::Left) if inside => {
control.pressed = true;
Some(ViewAction::Changed)
}
MouseEventKind::Up(MouseButton::Left) if control.pressed => {
control.pressed = false;
if inside {
self.jump_to_latest();
}
Some(ViewAction::Changed)
}
MouseEventKind::Drag(MouseButton::Left) if control.pressed => Some(ViewAction::Changed),
MouseEventKind::Moved if inside != was_hovered => Some(ViewAction::Changed),
_ => None,
}
}
}
#[cfg(test)]
#[path = "follow_control_tests.rs"]
mod tests;