diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 73d238f9a..80fc65ca7 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -93,8 +93,9 @@ pub(crate) struct PendingBacktrackRollback { impl App { /// Route overlay events while the transcript overlay is active. /// - /// If backtrack preview is active, Esc steps the selection and Enter confirms it. - /// Otherwise, Esc begins preview mode and all other events are forwarded to the overlay. + /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter + /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the + /// overlay. pub(crate) async fn handle_backtrack_overlay_event( &mut self, tui: &mut tui::Tui, @@ -110,6 +111,22 @@ impl App { self.overlay_step_backtrack(tui, event)?; Ok(true) } + TuiEvent::Key(KeyEvent { + code: KeyCode::Left, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack(tui, event)?; + Ok(true) + } + TuiEvent::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack_forward(tui, event)?; + Ok(true) + } TuiEvent::Key(KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, @@ -277,6 +294,27 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Step selection to the next newer user message and update overlay. + fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { + let count = user_count(&self.transcript_cells); + if count == 0 { + return; + } + + let last_index = count.saturating_sub(1); + let next_selection = if self.backtrack.nth_user_message == usize::MAX { + last_index + } else { + self.backtrack + .nth_user_message + .saturating_add(1) + .min(last_index) + }; + + self.apply_backtrack_selection_internal(next_selection); + tui.frame_requester().schedule_frame(); + } + /// Apply a computed backtrack selection to the overlay and internal counter. fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { @@ -364,6 +402,20 @@ impl App { Ok(()) } + /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. + fn overlay_step_backtrack_forward( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result<()> { + if self.backtrack.base_id.is_some() { + self.step_forward_backtrack_and_highlight(tui); + } else { + self.overlay_forward_event(tui, event)?; + } + Ok(()) + } + /// Confirm a primed backtrack from the main view (no overlay visible). /// Computes the prefill from the selected user message for rollback. pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index ae71a58c6..5f3f79e32 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -92,6 +92,8 @@ const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left); +const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right); const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); @@ -637,10 +639,13 @@ impl TranscriptOverlay { let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); - let mut pairs: Vec<(&[KeyBinding], &str)> = - vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; + let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; if self.highlight_cell.is_some() { + pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev")); + pairs.push((&[KEY_RIGHT], "to edit next")); pairs.push((&[KEY_ENTER], "to edit message")); + } else { + pairs.push((&[KEY_ESC], "to edit prev")); } render_key_hints(line2, buf, &pairs); } @@ -816,25 +821,37 @@ mod tests { lines: vec![Line::from("hello")], })]); - // Render into a small buffer and assert the backtrack hint is present - let area = Rect::new(0, 0, 40, 10); + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); - // Flatten buffer to a string and check for the hint text - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } + let s = buffer_to_text(&buf, area); assert!( s.contains("edit prev"), "expected 'edit prev' hint in overlay footer, got: {s:?}" ); } + #[test] + fn edit_next_hint_is_visible_when_highlighted() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })]); + overlay.set_highlight_cell(Some(0)); + + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let s = buffer_to_text(&buf, area); + assert!( + s.contains("edit next"), + "expected 'edit next' hint in overlay footer, got: {s:?}" + ); + } + #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index 195dc79b3..a86ed6dea 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -94,8 +94,9 @@ pub(crate) struct PendingBacktrackRollback { impl App { /// Route overlay events while the transcript overlay is active. /// - /// If backtrack preview is active, Esc steps the selection and Enter confirms it. - /// Otherwise, Esc begins preview mode and all other events are forwarded to the overlay. + /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter + /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the + /// overlay. pub(crate) async fn handle_backtrack_overlay_event( &mut self, tui: &mut tui::Tui, @@ -111,6 +112,22 @@ impl App { self.overlay_step_backtrack(tui, event)?; Ok(true) } + TuiEvent::Key(KeyEvent { + code: KeyCode::Left, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack(tui, event)?; + Ok(true) + } + TuiEvent::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack_forward(tui, event)?; + Ok(true) + } TuiEvent::Key(KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, @@ -308,6 +325,27 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Step selection to the next newer user message and update overlay. + fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { + let count = user_count(&self.transcript_cells); + if count == 0 { + return; + } + + let last_index = count.saturating_sub(1); + let next_selection = if self.backtrack.nth_user_message == usize::MAX { + last_index + } else { + self.backtrack + .nth_user_message + .saturating_add(1) + .min(last_index) + }; + + self.apply_backtrack_selection_internal(next_selection); + tui.frame_requester().schedule_frame(); + } + /// Apply a computed backtrack selection to the overlay and internal counter. fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { @@ -387,6 +425,20 @@ impl App { Ok(()) } + /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. + fn overlay_step_backtrack_forward( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result<()> { + if self.backtrack.base_id.is_some() { + self.step_forward_backtrack_and_highlight(tui); + } else { + self.overlay_forward_event(tui, event)?; + } + Ok(()) + } + /// Confirm a primed backtrack from the main view (no overlay visible). /// Computes the prefill from the selected user message for rollback. pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { diff --git a/codex-rs/tui2/src/pager_overlay.rs b/codex-rs/tui2/src/pager_overlay.rs index 6fb6d123c..c981ae951 100644 --- a/codex-rs/tui2/src/pager_overlay.rs +++ b/codex-rs/tui2/src/pager_overlay.rs @@ -93,6 +93,8 @@ const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left); +const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right); const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); @@ -656,10 +658,13 @@ impl TranscriptOverlay { let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); - let mut pairs: Vec<(&[KeyBinding], &str)> = - vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; + let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; if self.highlight_cell.is_some() { + pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev")); + pairs.push((&[KEY_RIGHT], "to edit next")); pairs.push((&[KEY_ENTER], "to edit message")); + } else { + pairs.push((&[KEY_ESC], "to edit prev")); } render_key_hints(line2, buf, &pairs); } @@ -837,25 +842,37 @@ mod tests { lines: vec![Line::from("hello")], })]); - // Render into a small buffer and assert the backtrack hint is present - let area = Rect::new(0, 0, 40, 10); + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); - // Flatten buffer to a string and check for the hint text - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } + let s = buffer_to_text(&buf, area); assert!( s.contains("edit prev"), "expected 'edit prev' hint in overlay footer, got: {s:?}" ); } + #[test] + fn edit_next_hint_is_visible_when_highlighted() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })]); + overlay.set_highlight_cell(Some(0)); + + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let s = buffer_to_text(&buf, area); + assert!( + s.contains("edit next"), + "expected 'edit next' hint in overlay footer, got: {s:?}" + ); + } + #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines