diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 6e4b28feb0..d2ed74a116 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -315,6 +315,8 @@ pub struct TuiVimTextObjectKeymap { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiPagerKeymap { + /// Start transcript search. + pub start_search: Option, /// Scroll up by one row. pub scroll_up: Option, /// Scroll down by one row. @@ -331,14 +333,18 @@ pub struct TuiPagerKeymap { pub jump_top: Option, /// Jump to the end. pub jump_bottom: Option, + /// Move to the previous user prompt in transcript search mode. + pub previous_user_prompt: Option, + /// Move to the next user prompt in transcript search mode. + pub next_user_prompt: Option, + /// Move to the next transcript search match. + pub next_search_match: Option, + /// Move to the previous transcript search match. + pub previous_search_match: Option, /// Close the pager overlay. pub close: Option, /// Close the transcript overlay via its dedicated toggle key. pub close_transcript: Option, - /// Jump to the previous user prompt in the transcript overlay. - pub previous_user_prompt: Option, - /// Jump to the next user prompt in the transcript overlay. - pub next_user_prompt: Option, } /// List selection context keybindings for popup-style selectable lists. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b2b161a9f2..81b7fe41a0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2842,12 +2842,15 @@ "half_page_up": null, "jump_bottom": null, "jump_top": null, + "next_search_match": null, "next_user_prompt": null, "page_down": null, "page_up": null, + "previous_search_match": null, "previous_user_prompt": null, "scroll_down": null, - "scroll_up": null + "scroll_up": null, + "start_search": null }, "vim_normal": { "append_after_cursor": null, @@ -3528,12 +3531,15 @@ "half_page_up": null, "jump_bottom": null, "jump_top": null, + "next_search_match": null, "next_user_prompt": null, "page_down": null, "page_up": null, + "previous_search_match": null, "previous_user_prompt": null, "scroll_down": null, - "scroll_up": null + "scroll_up": null, + "start_search": null } }, "vim_normal": { @@ -3753,13 +3759,21 @@ ], "description": "Jump to the beginning." }, + "next_search_match": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move to the next transcript search match." + }, "next_user_prompt": { "allOf": [ { "$ref": "#/definitions/KeybindingsSpec" } ], - "description": "Jump to the next user prompt in the transcript overlay." + "description": "Move to the next user prompt in transcript search mode." }, "page_down": { "allOf": [ @@ -3777,13 +3791,21 @@ ], "description": "Scroll up by one page." }, + "previous_search_match": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move to the previous transcript search match." + }, "previous_user_prompt": { "allOf": [ { "$ref": "#/definitions/KeybindingsSpec" } ], - "description": "Jump to the previous user prompt in the transcript overlay." + "description": "Move to the previous user prompt in transcript search mode." }, "scroll_down": { "allOf": [ @@ -3800,6 +3822,14 @@ } ], "description": "Scroll up by one row." + }, + "start_search": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Start transcript search." } }, "type": "object" diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 52ef930601..db580dbece 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -114,6 +114,24 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { + if let Some(Overlay::Transcript(transcript)) = &mut self.overlay { + let key_event = match event { + TuiEvent::Key(key_event) => Some(key_event), + _ => None, + }; + if transcript.search_is_active() { + self.overlay_forward_event(tui, event)?; + return Ok(true); + } + if let Some(key_event) = key_event + && transcript.is_start_search_key(key_event) + { + self.cancel_overlay_backtrack_preview(); + self.overlay_forward_event(tui, TuiEvent::Key(key_event))?; + return Ok(true); + } + } + if self.backtrack.overlay_preview_active { match event { TuiEvent::Key(KeyEvent { @@ -591,6 +609,17 @@ impl App { self.chat_widget.clear_esc_backtrack_hint(); } + fn cancel_overlay_backtrack_preview(&mut self) { + self.backtrack.overlay_preview_active = false; + self.backtrack.primed = false; + self.backtrack.base_id = None; + self.backtrack.nth_user_message = usize::MAX; + if let Some(Overlay::Transcript(transcript)) = &mut self.overlay { + transcript.set_highlight_cell(/*cell*/ None); + } + self.chat_widget.clear_esc_backtrack_hint(); + } + pub(crate) fn apply_backtrack_selection( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index c25f4490d6..e32cdff29c 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -211,6 +211,7 @@ pub(crate) struct VimTextObjectKeymap { /// Pager/overlay keybindings for transcript and static help views. #[derive(Clone, Debug)] pub(crate) struct PagerKeymap { + pub(crate) start_search: Vec, pub(crate) scroll_up: Vec, pub(crate) scroll_down: Vec, pub(crate) page_up: Vec, @@ -219,10 +220,12 @@ pub(crate) struct PagerKeymap { pub(crate) half_page_down: Vec, pub(crate) jump_top: Vec, pub(crate) jump_bottom: Vec, - pub(crate) close: Vec, - pub(crate) close_transcript: Vec, pub(crate) previous_user_prompt: Vec, pub(crate) next_user_prompt: Vec, + pub(crate) next_search_match: Vec, + pub(crate) previous_search_match: Vec, + pub(crate) close: Vec, + pub(crate) close_transcript: Vec, } /// Generic list picker keybindings shared across popup list views. @@ -741,6 +744,7 @@ impl RuntimeKeymap { }; let pager = PagerKeymap { + start_search: resolve_local!(keymap, defaults, pager, start_search), scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), page_up: resolve_local!(keymap, defaults, pager, page_up), @@ -749,10 +753,12 @@ impl RuntimeKeymap { half_page_down: resolve_local!(keymap, defaults, pager, half_page_down), jump_top: resolve_local!(keymap, defaults, pager, jump_top), jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom), - close: resolve_local!(keymap, defaults, pager, close), - close_transcript: resolve_local!(keymap, defaults, pager, close_transcript), previous_user_prompt: resolve_local!(keymap, defaults, pager, previous_user_prompt), next_user_prompt: resolve_local!(keymap, defaults, pager, next_user_prompt), + next_search_match: resolve_local!(keymap, defaults, pager, next_search_match), + previous_search_match: resolve_local!(keymap, defaults, pager, previous_search_match), + close: resolve_local!(keymap, defaults, pager, close), + close_transcript: resolve_local!(keymap, defaults, pager, close_transcript), }; let approval = ApprovalKeymap { @@ -1073,6 +1079,7 @@ impl RuntimeKeymap { cancel: default_bindings![plain(KeyCode::Esc)], }, pager: PagerKeymap { + start_search: default_bindings![plain(KeyCode::Char('/'))], scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))], scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))], page_up: default_bindings![ @@ -1089,10 +1096,18 @@ impl RuntimeKeymap { half_page_down: default_bindings![ctrl(KeyCode::Char('d'))], jump_top: default_bindings![plain(KeyCode::Home)], jump_bottom: default_bindings![plain(KeyCode::End)], - close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))], - close_transcript: default_bindings![ctrl(KeyCode::Char('t'))], previous_user_prompt: default_bindings![plain(KeyCode::Left), alt(KeyCode::Up)], next_user_prompt: default_bindings![plain(KeyCode::Right), alt(KeyCode::Down)], + next_search_match: default_bindings![ + plain(KeyCode::Enter), + plain(KeyCode::Char('n')) + ], + previous_search_match: default_bindings![ + shift(KeyCode::Char('n')), + plain(KeyCode::Char('N')) + ], + close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))], + close_transcript: default_bindings![ctrl(KeyCode::Char('t'))], }, list: ListKeymap { move_up: default_bindings![ @@ -1567,9 +1582,10 @@ impl RuntimeKeymap { ], )?; - validate_no_reserved( + validate_unique( "pager", [ + ("start_search", self.pager.start_search.as_slice()), ("scroll_up", self.pager.scroll_up.as_slice()), ("scroll_down", self.pager.scroll_down.as_slice()), ("page_up", self.pager.page_up.as_slice()), @@ -1585,6 +1601,38 @@ impl RuntimeKeymap { self.pager.previous_user_prompt.as_slice(), ), ("next_user_prompt", self.pager.next_user_prompt.as_slice()), + ("next_search_match", self.pager.next_search_match.as_slice()), + ( + "previous_search_match", + self.pager.previous_search_match.as_slice(), + ), + ], + )?; + + validate_no_reserved( + "pager", + [ + ("start_search", self.pager.start_search.as_slice()), + ("scroll_up", self.pager.scroll_up.as_slice()), + ("scroll_down", self.pager.scroll_down.as_slice()), + ("page_up", self.pager.page_up.as_slice()), + ("page_down", self.pager.page_down.as_slice()), + ("half_page_up", self.pager.half_page_up.as_slice()), + ("half_page_down", self.pager.half_page_down.as_slice()), + ("jump_top", self.pager.jump_top.as_slice()), + ("jump_bottom", self.pager.jump_bottom.as_slice()), + ("close", self.pager.close.as_slice()), + ("close_transcript", self.pager.close_transcript.as_slice()), + ( + "previous_user_prompt", + self.pager.previous_user_prompt.as_slice(), + ), + ("next_user_prompt", self.pager.next_user_prompt.as_slice()), + ("next_search_match", self.pager.next_search_match.as_slice()), + ( + "previous_search_match", + self.pager.previous_search_match.as_slice(), + ), ], TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, [], @@ -1798,16 +1846,10 @@ const MAIN_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ ), ]; -const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ - ( - "fixed.transcript_edit_previous", - key_hint::plain(KeyCode::Esc), - ), - ( - "fixed.transcript_confirm_edit", - key_hint::plain(KeyCode::Enter), - ), -]; +const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[( + "fixed.transcript_edit_previous", + key_hint::plain(KeyCode::Esc), +)]; /// Resolve one action with context -> global -> default precedence. /// diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index b355cda69f..aca38a5506 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -167,6 +167,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."), action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."), action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."), + action("pager", "Pager", "start_search", "Start transcript search."), action("pager", "Pager", "scroll_up", "Scroll up by one row."), action("pager", "Pager", "scroll_down", "Scroll down by one row."), action("pager", "Pager", "page_up", "Scroll up by one page."), @@ -175,6 +176,10 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("pager", "Pager", "half_page_down", "Scroll down by half a page."), action("pager", "Pager", "jump_top", "Jump to the beginning."), action("pager", "Pager", "jump_bottom", "Jump to the end."), + action("pager", "Pager", "previous_user_prompt", "Move to the previous user prompt in transcript search mode."), + action("pager", "Pager", "next_user_prompt", "Move to the next user prompt in transcript search mode."), + action("pager", "Pager", "next_search_match", "Move to the next transcript search match."), + action("pager", "Pager", "previous_search_match", "Move to the previous transcript search match."), action("pager", "Pager", "close", "Close the pager overlay."), action("pager", "Pager", "close_transcript", "Close the transcript overlay."), action("list", "List", "move_up", "Move list selection up."), @@ -310,6 +315,7 @@ pub(super) fn binding_slot<'a>( ("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote), ("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick), ("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel), + ("pager", "start_search") => Some(&mut keymap.pager.start_search), ("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up), ("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down), ("pager", "page_up") => Some(&mut keymap.pager.page_up), @@ -318,6 +324,10 @@ pub(super) fn binding_slot<'a>( ("pager", "half_page_down") => Some(&mut keymap.pager.half_page_down), ("pager", "jump_top") => Some(&mut keymap.pager.jump_top), ("pager", "jump_bottom") => Some(&mut keymap.pager.jump_bottom), + ("pager", "previous_user_prompt") => Some(&mut keymap.pager.previous_user_prompt), + ("pager", "next_user_prompt") => Some(&mut keymap.pager.next_user_prompt), + ("pager", "next_search_match") => Some(&mut keymap.pager.next_search_match), + ("pager", "previous_search_match") => Some(&mut keymap.pager.previous_search_match), ("pager", "close") => Some(&mut keymap.pager.close), ("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript), ("list", "move_up") => Some(&mut keymap.list.move_up), @@ -435,6 +445,7 @@ pub(super) fn bindings_for_action<'a>( ("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()), ("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()), ("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()), + ("pager", "start_search") => Some(runtime_keymap.pager.start_search.as_slice()), ("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()), ("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()), ("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()), @@ -443,6 +454,10 @@ pub(super) fn bindings_for_action<'a>( ("pager", "half_page_down") => Some(runtime_keymap.pager.half_page_down.as_slice()), ("pager", "jump_top") => Some(runtime_keymap.pager.jump_top.as_slice()), ("pager", "jump_bottom") => Some(runtime_keymap.pager.jump_bottom.as_slice()), + ("pager", "previous_user_prompt") => Some(runtime_keymap.pager.previous_user_prompt.as_slice()), + ("pager", "next_user_prompt") => Some(runtime_keymap.pager.next_user_prompt.as_slice()), + ("pager", "next_search_match") => Some(runtime_keymap.pager.next_search_match.as_slice()), + ("pager", "previous_search_match") => Some(runtime_keymap.pager.previous_search_match.as_slice()), ("pager", "close") => Some(runtime_keymap.pager.close.as_slice()), ("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()), ("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index edf2f22ad1..3820ddf641 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -17,6 +17,7 @@ use std::any::TypeId; use std::cell::Cell as StdCell; +use std::cell::RefCell; use std::io::Result; use std::rc::Rc; use std::sync::Arc; @@ -38,10 +39,12 @@ use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; +use crate::key_hint::is_plain_text_key_event; use crate::keymap::PagerKeymap; use crate::render::Insets; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; +use crate::style::accent_style; use crate::style::user_message_style; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::mark_buffer_hyperlinks; @@ -53,9 +56,11 @@ use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; +use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; @@ -65,6 +70,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use unicode_width::UnicodeWidthChar; pub(crate) enum Overlay { Transcript(Box), @@ -165,6 +171,8 @@ struct PagerView { layout: Option, /// If set, on next render position this row of the chunk near the upper third. pending_anchor_chunk: Option<(usize, usize)>, + /// If set, on next render ensure this search-match line is visible. + pending_search_line: Option<(usize, usize)>, } #[derive(Debug)] @@ -193,6 +201,7 @@ impl PagerView { last_rendered_height: None, layout: None, pending_anchor_chunk: None, + pending_search_line: None, } } @@ -254,7 +263,6 @@ impl PagerView { self.last_rendered_height = Some(content_height); self.resolve_pending_scroll(content_area, content_height); self.render_header(area, content_area, buf, content_height); - self.render_content(content_area, buf); self.render_bottom_bar(area, content_area, buf, content_height); @@ -407,9 +415,15 @@ impl PagerView { } fn content_area(&self, area: Rect) -> Rect { + self.content_area_with_inset(area, /*top_inset*/ 0) + } + + fn content_area_with_inset(&self, area: Rect, top_inset: u16) -> Rect { let mut area = area; area.y = area.y.saturating_add(1); area.height = area.height.saturating_sub(2); + area.y = area.y.saturating_add(top_inset); + area.height = area.height.saturating_sub(top_inset); area } } @@ -438,12 +452,19 @@ impl PagerView { fn resolve_pending_scroll(&mut self, area: Rect, content_height: usize) { if let Some((idx, row_offset)) = self.pending_anchor_chunk.take() { self.position_chunk_at_upper_third(idx, row_offset, area); + self.pending_search_line.take(); + } else if let Some((chunk_index, line_index)) = self.pending_search_line.take() { + self.ensure_line_visible(chunk_index, line_index, area); } self.scroll_offset = self .scroll_offset .min(content_height.saturating_sub(area.height as usize)); } + fn scroll_line_into_view(&mut self, chunk_index: usize, line_index: usize) { + self.pending_search_line = Some((chunk_index, line_index)); + } + fn clamped_scroll_offset(&mut self, area: Rect) -> usize { self.scroll_offset.min( self.content_height(area.width) @@ -465,6 +486,21 @@ impl PagerView { let anchor = (area.height as usize) / 3; self.scroll_offset = row.saturating_sub(anchor); } + + fn ensure_line_visible(&mut self, chunk_index: usize, line_index: usize, area: Rect) { + if area.height == 0 || chunk_index >= self.renderables.len() { + return; + } + let layout = self.layout(area.width); + let line = layout.offsets[chunk_index].saturating_add(line_index); + let current_top = self.scroll_offset; + let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize); + if line < current_top { + self.scroll_offset = line; + } else if line > current_bottom { + self.scroll_offset = line.saturating_sub(area.height.saturating_sub(1) as usize); + } + } } /// A renderable that caches its desired height. @@ -498,18 +534,27 @@ impl Renderable for CachedRenderable { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct RenderableMatch { + renderable_index: usize, + line_index: usize, + start_col: u16, + end_col: u16, +} + struct CellRenderable { cell: Arc, - cell_index: usize, + renderable_index: usize, style: Style, selected_style: Option