tui: allow forward navigation in backtrack preview (#9059)

Fixes #9058

## Summary
When the transcript backtrack preview is armed (press `Esc`), allow
navigating to newer user messages with the `→` arrow, in addition to
navigating backwards with `Esc`/`←`, before confirming with `Enter`.

## Changes
- Backtrack preview navigation: `Esc`/`←` steps to older user messages,
`→` steps to newer ones, `Enter` edits the selected message (clamped at
bounds, no wrap-around).
- Transcript overlay footer hints updated to advertise `esc/←`, `→`, and
`enter` when a message is highlighted.

## Related
- WSL shortcut-overlay snapshot determinism: #9359

## Testing
- `just fmt`
- `just fix -p codex-tui`
- `just fix -p codex-tui2`
- `cargo test -p codex-tui app_backtrack::`
- `cargo test -p codex-tui pager_overlay::`
- `cargo test -p codex-tui2 app_backtrack::`
- `cargo test -p codex-tui2 pager_overlay::`

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
SlKzᵍᵐ
2026-01-18 05:10:24 +01:00
committed by GitHub
parent aeaff26451
commit 0a568a47fd
4 changed files with 166 additions and 28 deletions

View File

@@ -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<BacktrackSelection> {

View File

@@ -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

View File

@@ -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<BacktrackSelection> {

View File

@@ -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