From afa78c925839b1b9b3c5c71c67abcd2a56b61bdb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 14:18:59 -0300 Subject: [PATCH] feat(tui): refine transcript overlay navigation --- codex-rs/tui/src/app_backtrack.rs | 44 ++- codex-rs/tui/src/footer_hints.rs | 235 ++++++++++++ codex-rs/tui/src/keymap.rs | 33 +- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/pager_overlay.rs | 354 +++++++++++------- codex-rs/tui/src/resume_picker.rs | 254 ++----------- ..._tests__static_overlay_snapshot_basic.snap | 8 +- ...ests__static_overlay_wraps_long_lines.snap | 8 +- ...ript_overlay_apply_patch_scroll_vt100.snap | 8 +- ..._transcript_overlay_renders_live_tail.snap | 8 +- ...ts__transcript_overlay_snapshot_basic.snap | 8 +- 11 files changed, 549 insertions(+), 412 deletions(-) create mode 100644 codex-rs/tui/src/footer_hints.rs diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index d6f5f3d5ce..c0520ad81e 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -35,6 +35,7 @@ use crate::app_event::AppEvent; use crate::history_cell::AgentMessageCell; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; +use crate::key_hint::KeyBindingListExt; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; @@ -104,9 +105,9 @@ pub(crate) struct PendingBacktrackRollback { impl App { /// Route overlay events while the transcript overlay is active. /// - /// 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. + /// If backtrack preview is active, Esc / previous-prompt steps selection, next-prompt steps + /// forward, Enter confirms. Otherwise, Esc or a prompt-selection key 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, @@ -122,19 +123,15 @@ impl App { self.overlay_step_backtrack(tui, event)?; Ok(true) } - TuiEvent::Key(KeyEvent { - code: KeyCode::Left, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { + TuiEvent::Key(key_event) + if self.keymap.pager.previous_user_prompt.is_pressed(key_event) => + { self.overlay_step_backtrack(tui, event)?; Ok(true) } - TuiEvent::Key(KeyEvent { - code: KeyCode::Right, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { + TuiEvent::Key(key_event) + if self.keymap.pager.next_user_prompt.is_pressed(key_event) => + { self.overlay_step_backtrack_forward(tui, event)?; Ok(true) } @@ -151,13 +148,19 @@ impl App { Ok(true) } } - } else if let TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) = event + } else if let TuiEvent::Key(key_event) = event + && (matches!( + key_event, + KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) || self.keymap.pager.previous_user_prompt.is_pressed(key_event) + || self.keymap.pager.next_user_prompt.is_pressed(key_event)) { - // First Esc in transcript overlay: begin backtrack preview at latest user message. + // First Esc / prompt-selection key in transcript overlay: begin backtrack preview at + // latest user message. self.begin_overlay_backtrack_preview(tui); Ok(true) } else { @@ -490,7 +493,8 @@ impl App { Ok(()) } - /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. + /// Handle next-prompt in overlay backtrack preview: step selection forward if armed, else + /// forward. fn overlay_step_backtrack_forward( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/footer_hints.rs b/codex-rs/tui/src/footer_hints.rs new file mode 100644 index 0000000000..e2ea93c56c --- /dev/null +++ b/codex-rs/tui/src/footer_hints.rs @@ -0,0 +1,235 @@ +use crate::color::is_light; +use crate::terminal_palette::default_bg; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Styled as _; +use ratatui::style::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::WidgetRef; +use unicode_width::UnicodeWidthStr; + +const FOOTER_COMPACT_BREAKPOINT: u16 = 120; +const FOOTER_HINT_LEFT_PADDING: usize = 1; +const FOOTER_HINT_GAP: usize = 3; + +#[derive(Clone, Debug)] +pub(crate) struct FooterHint { + key: String, + wide_label: String, + compact_label: String, + priority: u8, +} + +impl FooterHint { + pub(crate) fn new( + key: impl Into, + wide_label: impl Into, + compact_label: impl Into, + priority: u8, + ) -> Self { + Self { + key: key.into(), + wide_label: wide_label.into(), + compact_label: compact_label.into(), + priority, + } + } +} + +#[derive(Clone, Copy)] +enum FooterHintLabelMode { + Wide, + Compact, + KeyOnly, +} + +pub(crate) fn footer_hint_line_for_row(hints: &[FooterHint], width: u16) -> Line<'static> { + if width >= FOOTER_COMPACT_BREAKPOINT + && let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width) + { + return line; + } + if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) { + return line; + } + if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) { + return line; + } + + let mut retained = (0..hints.len()).collect::>(); + retained.sort_by_key(|idx| hints[*idx].priority); + for retain_count in (1..=retained.len()).rev() { + let mut candidate_indices = retained[..retain_count].to_vec(); + candidate_indices.sort_unstable(); + let candidate = candidate_indices + .iter() + .map(|idx| &hints[*idx]) + .collect::>(); + if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) { + return line; + } + } + Line::default() +} + +pub(crate) fn render_footer_separator(area: Rect, buf: &mut Buffer, label: String) { + if area.width == 0 { + return; + } + + Line::from("─".repeat(area.width as usize).dim()).render_ref(area, buf); + if label.is_empty() { + return; + } + + let label_width = UnicodeWidthStr::width(label.as_str()) as u16; + if label_width < area.width { + let label_area = Rect::new( + area.x + area.width - label_width - 1, + area.y, + label_width, + 1, + ); + Line::from(label.dim()).render_ref(label_area, buf); + } +} + +fn fit_footer_hints( + hints: &[FooterHint], + mode: FooterHintLabelMode, + width: u16, +) -> Option> { + let hint_refs = hints.iter().collect::>(); + fit_footer_hint_refs(&hint_refs, mode, width) +} + +fn fit_footer_hint_refs( + hints: &[&FooterHint], + mode: FooterHintLabelMode, + width: u16, +) -> Option> { + let gap_width = FOOTER_HINT_GAP; + if footer_hints_width(hints, mode, gap_width) > width as usize { + return None; + } + + let mut spans = vec![ + " ".repeat(FOOTER_HINT_LEFT_PADDING) + .set_style(footer_hint_label_style()), + ]; + for (idx, hint) in hints.iter().enumerate() { + if idx > 0 { + spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style())); + } + spans.push(hint.key.clone().set_style(footer_hint_key_style())); + let label = match mode { + FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()), + FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()), + FooterHintLabelMode::KeyOnly => None, + }; + if let Some(label) = label { + spans.push(" ".set_style(footer_hint_label_style())); + spans.push(label.to_string().set_style(footer_hint_label_style())); + } + } + Some(spans.into()) +} + +fn footer_hint_key_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Black) + } else { + Style::default() + } +} + +fn footer_hint_label_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::DarkGray) + } else { + Style::default().dim() + } +} + +fn footer_hints_width(hints: &[&FooterHint], mode: FooterHintLabelMode, gap_width: usize) -> usize { + FOOTER_HINT_LEFT_PADDING + + hints + .iter() + .enumerate() + .map(|(idx, hint)| { + let label_width = match mode { + FooterHintLabelMode::Wide => { + 1 + UnicodeWidthStr::width(hint.wide_label.as_str()) + } + FooterHintLabelMode::Compact => { + 1 + UnicodeWidthStr::width(hint.compact_label.as_str()) + } + FooterHintLabelMode::KeyOnly => 0, + }; + let hint_width = UnicodeWidthStr::width(hint.key.as_str()) + label_width; + if idx == 0 { + hint_width + } else { + hint_width + gap_width + } + }) + .sum::() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line_text(line: Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn footer_hint_line_uses_wide_labels_when_width_allows() { + let hints = [FooterHint::new( + "enter", + "resume session", + "resume", + /*priority*/ 0, + )]; + + let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 140)); + + assert!(rendered.contains("enter resume session")); + } + + #[test] + fn footer_hint_line_compacts_below_breakpoint() { + let hints = [FooterHint::new( + "enter", + "resume session", + "resume", + /*priority*/ 0, + )]; + + let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 80)); + + assert!(rendered.contains("enter resume")); + assert!(!rendered.contains("resume session")); + } + + #[test] + fn footer_hint_line_drops_low_priority_hints_when_narrow() { + let hints = [ + FooterHint::new("a", "alpha", "alpha", /*priority*/ 0), + FooterHint::new("b", "bravo", "bravo", /*priority*/ 9), + FooterHint::new("c", "charlie", "charlie", /*priority*/ 1), + ]; + + let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 6)); + + assert!(rendered.contains('a')); + assert!(rendered.contains('c')); + assert!(!rendered.contains('b')); + } +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index fa9f056f4c..3f11ea8728 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -832,8 +832,8 @@ impl RuntimeKeymap { 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![alt(KeyCode::Up)], - next_user_prompt: default_bindings![alt(KeyCode::Down)], + previous_user_prompt: default_bindings![plain(KeyCode::Left), alt(KeyCode::Up)], + next_user_prompt: default_bindings![plain(KeyCode::Right), alt(KeyCode::Down)], }, list: ListKeymap { move_up: default_bindings![ @@ -1478,14 +1478,6 @@ const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ "fixed.transcript_edit_previous", key_hint::plain(KeyCode::Esc), ), - ( - "fixed.transcript_edit_previous", - key_hint::plain(KeyCode::Left), - ), - ( - "fixed.transcript_edit_next", - key_hint::plain(KeyCode::Right), - ), ( "fixed.transcript_confirm_edit", key_hint::plain(KeyCode::Enter), @@ -2183,9 +2175,26 @@ mod tests { #[test] fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() { let mut keymap = TuiKeymap::default(); - keymap.pager.close = Some(one("left")); + keymap.pager.close = Some(one("enter")); - expect_conflict(&keymap, "close", "fixed.transcript_edit_previous"); + expect_conflict(&keymap, "close", "fixed.transcript_confirm_edit"); + } + + #[test] + fn pager_prompt_selection_defaults_to_left_and_right_arrows() { + let runtime = RuntimeKeymap::defaults(); + + assert_eq!( + runtime.pager.previous_user_prompt, + vec![key_hint::plain(KeyCode::Left), key_hint::alt(KeyCode::Up)] + ); + assert_eq!( + runtime.pager.next_user_prompt, + vec![ + key_hint::plain(KeyCode::Right), + key_hint::alt(KeyCode::Down) + ] + ); } #[test] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 69b6bec0f3..c588230a4f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -126,6 +126,7 @@ mod external_agent_config_migration; mod external_agent_config_migration_startup; mod external_editor; mod file_search; +mod footer_hints; mod frames; mod get_git_diff; mod git_action_directives; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index a6813614bf..44f623daad 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -19,6 +19,9 @@ use std::io::Result; use std::sync::Arc; use crate::chatwidget::ActiveCellTranscriptKey; +use crate::footer_hints::FooterHint; +use crate::footer_hints::footer_hint_line_for_row; +use crate::footer_hints::render_footer_separator; use crate::history_cell::HistoryCell; use crate::history_cell::HistoryRenderMode; use crate::history_cell::UserHistoryCell; @@ -40,7 +43,6 @@ use ratatui::layout::Rect; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::text::Text; use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; @@ -122,25 +124,12 @@ fn first_or_empty(bindings: &[KeyBinding]) -> Vec { bindings.first().copied().into_iter().collect() } -// Render a single line of key hints from (key(s), description) pairs. -fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &str)]) { - let mut spans: Vec> = vec![" ".into()]; - let mut first = true; - for (keys, desc) in pairs { - if !first { - spans.push(" ".into()); - } - for (i, key) in keys.iter().enumerate() { - if i > 0 { - spans.push("/".into()); - } - spans.push(Span::from(key)); - } - spans.push(" ".into()); - spans.push(Span::from(desc.to_string())); - first = false; - } - Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf); +fn key_label(bindings: &[KeyBinding]) -> String { + bindings + .iter() + .map(KeyBinding::display_label) + .collect::>() + .join("/") } /// Generic widget for rendering a pager view. @@ -220,11 +209,11 @@ impl PagerView { fn render(&mut self, area: Rect, buf: &mut Buffer) { Clear.render(area, buf); - self.render_header(area, buf); let content_area = self.content_area(area); self.update_last_content_height(content_area.height); let content_height = self.content_height(content_area.width); self.last_rendered_height = Some(content_height); + self.render_header(area, content_area, buf, content_height); // If there is a pending request to scroll a specific chunk into view, // satisfy it now that wrapping is up to date for this width. if let Some(idx) = self.pending_scroll_chunk.take() { @@ -239,12 +228,13 @@ impl PagerView { self.render_bottom_bar(area, content_area, buf, content_height); } - fn render_header(&self, area: Rect, buf: &mut Buffer) { - Span::from("/ ".repeat(area.width as usize / 2)) + fn render_header(&self, area: Rect, content_area: Rect, buf: &mut Buffer, total_len: usize) { + let header = Rect::new(area.x, area.y, area.width, 1); + render_footer_separator(header, buf, String::new()); + let percent = self.scroll_percent(content_area.height, total_len); + format!(" {} · {percent}% ", self.title) .dim() - .render_ref(area, buf); - let header = format!("/ {}", self.title); - header.dim().render_ref(area, buf); + .render_ref(header, buf); } fn render_content(&mut self, area: Rect, buf: &mut Buffer) { @@ -307,31 +297,23 @@ impl PagerView { full_area: Rect, content_area: Rect, buf: &mut Buffer, - total_len: usize, + _total_len: usize, ) { let sep_y = content_area.bottom(); let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1); - Span::from("─".repeat(sep_rect.width as usize)) - .dim() - .render_ref(sep_rect, buf); - let percent = if total_len == 0 { - 100 - } else { - let max_scroll = total_len.saturating_sub(content_area.height as usize); - if max_scroll == 0 { - 100 - } else { - (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() - as u8 - } - }; - let pct_text = format!(" {percent}% "); - let pct_w = pct_text.chars().count() as u16; - let pct_x = sep_rect.x + sep_rect.width - pct_w - 1; - Span::from(pct_text) - .dim() - .render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf); + render_footer_separator(sep_rect, buf, String::new()); + } + + fn scroll_percent(&self, content_height: u16, total_len: usize) -> u8 { + if total_len == 0 { + return 100; + } + let max_scroll = total_len.saturating_sub(content_height as usize); + if max_scroll == 0 { + return 100; + } + (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() as u8 } fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { @@ -542,7 +524,7 @@ impl TranscriptOverlay { Self { view: PagerView::new( Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode), - "T R A N S C R I P T".to_string(), + "Transcript".to_string(), state.scroll_offset, keymap, ), @@ -567,7 +549,7 @@ impl TranscriptOverlay { .enumerate() .flat_map(|(i, c)| { let mut v: Vec> = Vec::new(); - let mut cell_renderable = if c.as_any().is::() { + let base_renderable = if c.as_any().is::() { Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), style: if highlight_cell == Some(i) { @@ -584,6 +566,7 @@ impl TranscriptOverlay { render_mode, })) as Box }; + let mut cell_renderable = base_renderable; if !c.is_stream_continuation() && i > 0 { cell_renderable = Box::new(InsetRenderable::new( cell_renderable, @@ -828,6 +811,29 @@ impl TranscriptOverlay { self.set_highlight_cell(Some(next_prompt)); } + fn header_title(&self) -> String { + let total = self + .cells + .iter() + .filter(|cell| cell.is_user_prompt()) + .count(); + let Some(highlight_cell) = self.highlight_cell else { + let noun = if total == 1 { "prompt" } else { "prompts" }; + return format!("Transcript · {total} {noun}"); + }; + let selected = self + .cells + .iter() + .take(highlight_cell.saturating_add(1)) + .filter(|cell| cell.is_user_prompt()) + .count(); + if selected == 0 || total == 0 { + let noun = if total == 1 { "prompt" } else { "prompts" }; + return format!("Transcript · {total} {noun}"); + } + format!("Transcript · {selected}/{total}") + } + fn rebuild_renderables(&mut self) { let tail_renderable = self.take_live_tail_renderable(); self.view.renderables = @@ -873,69 +879,108 @@ impl TranscriptOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints( - line1, - buf, - &[ - ( - first_or_empty(&self.view.keymap.scroll_up) - .into_iter() - .chain(first_or_empty(&self.view.keymap.scroll_down)) - .collect(), - "to scroll", - ), - ( - first_or_empty(&self.view.keymap.page_up) - .into_iter() - .chain(first_or_empty(&self.view.keymap.page_down)) - .collect(), - "to page", - ), - ( - first_or_empty(&self.view.keymap.jump_top) - .into_iter() - .chain(first_or_empty(&self.view.keymap.jump_bottom)) - .collect(), - "to jump", - ), - ( - first_or_empty(&self.view.keymap.previous_user_prompt) - .into_iter() - .chain(first_or_empty(&self.view.keymap.next_user_prompt)) - .collect(), - "to prompts", - ), - ], - ); + let scroll_keys = first_or_empty(&self.view.keymap.scroll_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.scroll_down)) + .collect::>(); + let page_keys = first_or_empty(&self.view.keymap.page_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.page_down)) + .collect::>(); + let jump_keys = first_or_empty(&self.view.keymap.jump_top) + .into_iter() + .chain(first_or_empty(&self.view.keymap.jump_bottom)) + .collect::>(); + let prompt_keys = first_or_empty(&self.view.keymap.previous_user_prompt) + .into_iter() + .chain(first_or_empty(&self.view.keymap.next_user_prompt)) + .collect::>(); + let navigation_hints = vec![ + FooterHint::new( + key_label(&scroll_keys), + "scroll", + "scroll", + /*priority*/ 1, + ), + FooterHint::new( + key_label(&prompt_keys), + "prompts", + "prompts", + /*priority*/ 2, + ), + FooterHint::new(key_label(&page_keys), "page", "page", /*priority*/ 6), + FooterHint::new(key_label(&jump_keys), "jump", "jump", /*priority*/ 7), + ]; + footer_hint_line_for_row(&navigation_hints, area.width).render_ref(line1, buf); - let mut pairs: Vec<(Vec, &str)> = Vec::new(); + let mut action_hints = Vec::new(); + action_hints.push(FooterHint::new( + key_label(&first_or_empty(&self.view.keymap.close)), + "quit", + "quit", + /*priority*/ 0, + )); if !self.copy_keymap.is_empty() { - pairs.push((first_or_empty(&self.copy_keymap), "to copy")); + action_hints.push(FooterHint::new( + key_label(&first_or_empty(&self.copy_keymap)), + "copy", + "copy", + /*priority*/ 3, + )); } if !self.toggle_raw_output_keymap.is_empty() { - pairs.push((first_or_empty(&self.toggle_raw_output_keymap), "raw")); - } - pairs.push((first_or_empty(&self.view.keymap.close), "to quit")); - if self.highlight_cell.is_some() { - pairs.push(( - vec![ - key_hint::plain(KeyCode::Esc), - key_hint::plain(KeyCode::Left), - ], - "to edit prev", + let mode_label = match self.render_mode { + HistoryRenderMode::Rich => "raw", + HistoryRenderMode::Raw => "rich", + }; + action_hints.push(FooterHint::new( + key_label(&first_or_empty(&self.toggle_raw_output_keymap)), + mode_label, + mode_label, + /*priority*/ 4, )); - pairs.push((vec![key_hint::plain(KeyCode::Right)], "to edit next")); - pairs.push((vec![key_hint::plain(KeyCode::Enter)], "to edit message")); - } else { - pairs.push((vec![key_hint::plain(KeyCode::Esc)], "to edit prev")); } - render_key_hints(line2, buf, &pairs); + if self.highlight_cell.is_some() { + let previous_edit_keys = std::iter::once(key_hint::plain(KeyCode::Esc)) + .chain(first_or_empty(&self.view.keymap.previous_user_prompt)) + .collect::>(); + action_hints.push(FooterHint::new( + key_label(&previous_edit_keys), + "edit prev", + "prev", + /*priority*/ 8, + )); + action_hints.push(FooterHint::new( + key_label(&first_or_empty(&self.view.keymap.next_user_prompt)), + "edit next", + "next", + /*priority*/ 9, + )); + action_hints.push(FooterHint::new( + key_label(&[key_hint::plain(KeyCode::Enter)]), + "edit message", + "edit", + /*priority*/ 10, + )); + } else { + let previous_edit_keys = std::iter::once(key_hint::plain(KeyCode::Esc)) + .chain(first_or_empty(&self.view.keymap.previous_user_prompt)) + .collect::>(); + action_hints.push(FooterHint::new( + key_label(&previous_edit_keys), + "edit prev", + "prev", + /*priority*/ 8, + )); + } + footer_hint_line_for_row(&action_hints, area.width).render_ref(line2, buf); } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { let top_h = area.height.saturating_sub(3); let top = Rect::new(area.x, area.y, area.width, top_h); let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); + self.view.title = self.header_title(); self.view.render(top, buf); self.render_hints(bottom, buf); } @@ -1032,36 +1077,37 @@ impl StaticOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints( - line1, - buf, - &[ - ( - first_or_empty(&self.view.keymap.scroll_up) - .into_iter() - .chain(first_or_empty(&self.view.keymap.scroll_down)) - .collect(), - "to scroll", - ), - ( - first_or_empty(&self.view.keymap.page_up) - .into_iter() - .chain(first_or_empty(&self.view.keymap.page_down)) - .collect(), - "to page", - ), - ( - first_or_empty(&self.view.keymap.jump_top) - .into_iter() - .chain(first_or_empty(&self.view.keymap.jump_bottom)) - .collect(), - "to jump", - ), - ], - ); - let pairs: Vec<(Vec, &str)> = - vec![(first_or_empty(&self.view.keymap.close), "to quit")]; - render_key_hints(line2, buf, &pairs); + let scroll_keys = first_or_empty(&self.view.keymap.scroll_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.scroll_down)) + .collect::>(); + let page_keys = first_or_empty(&self.view.keymap.page_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.page_down)) + .collect::>(); + let jump_keys = first_or_empty(&self.view.keymap.jump_top) + .into_iter() + .chain(first_or_empty(&self.view.keymap.jump_bottom)) + .collect::>(); + let navigation_hints = vec![ + FooterHint::new( + key_label(&scroll_keys), + "scroll", + "scroll", + /*priority*/ 1, + ), + FooterHint::new(key_label(&page_keys), "page", "page", /*priority*/ 6), + FooterHint::new(key_label(&jump_keys), "jump", "jump", /*priority*/ 7), + ]; + footer_hint_line_for_row(&navigation_hints, area.width).render_ref(line1, buf); + + let action_hints = vec![FooterHint::new( + key_label(&first_or_empty(&self.view.keymap.close)), + "quit", + "quit", + /*priority*/ 0, + )]; + footer_hint_line_for_row(&action_hints, area.width).render_ref(line2, buf); } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { @@ -1139,11 +1185,13 @@ mod tests { use crate::diff_model::FileChange; use crate::exec_cell::CommandOutput; use crate::history_cell; + use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; use codex_protocol::parse_command::ParsedCommand; use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::style::Modifier; use ratatui::text::Text; #[derive(Debug)] @@ -1332,6 +1380,52 @@ mod tests { assert_eq!(overlay.selected_user_cell(), Some(2)); } + #[test] + fn transcript_header_counts_selected_user_prompt() { + let mut overlay = transcript_overlay(vec![ + user_cell("first"), + Arc::new(AgentMessageCell::new( + vec![Line::from("assistant")], + /*is_first_line*/ true, + )), + user_cell("second"), + ]); + + assert_eq!(overlay.header_title(), "Transcript · 2 prompts"); + + overlay.move_prompt_selection(PromptSelectionDirection::Previous); + assert_eq!(overlay.header_title(), "Transcript · 2/2"); + + overlay.move_prompt_selection(PromptSelectionDirection::Previous); + assert_eq!(overlay.header_title(), "Transcript · 1/2"); + } + + #[test] + fn selected_user_prompt_keeps_reversed_style_without_role_gutter() { + let mut overlay = transcript_overlay(vec![user_cell("selected prompt")]); + overlay.set_highlight_cell(Some(0)); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 8, + ); + let mut buf = Buffer::empty(area); + + overlay.render(area, &mut buf); + let rendered = buffer_to_text(&buf, area); + + assert!(!rendered.contains('▌')); + assert!(!rendered.contains('│')); + let prompt_marker = (area.y..area.bottom()) + .flat_map(|y| (area.x..area.right()).map(move |x| (x, y))) + .find(|(x, y)| buf[(*x, *y)].symbol() == "›") + .expect("expected selected prompt marker"); + assert!( + buf[prompt_marker] + .style() + .add_modifier + .contains(Modifier::REVERSED) + ); + } + #[test] fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { let mut overlay = transcript_overlay(vec![Arc::new(TestCell { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1de8fae81a..0ad976c189 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -9,6 +9,9 @@ mod transcript; use crate::app_server_session::AppServerSession; use crate::color::blend; use crate::color::is_light; +use crate::footer_hints::FooterHint; +use crate::footer_hints::footer_hint_line_for_row; +use crate::footer_hints::render_footer_separator; use crate::git_action_directives::parse_assistant_markdown; use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_plain_text_key_event; @@ -74,9 +77,6 @@ const SESSION_META_MIN_CWD_WIDTH: usize = 30; const SESSION_META_MAX_CWD_WIDTH: usize = 72; const SESSION_META_BRANCH_ICON: &str = ""; const SESSION_META_CWD_ICON: &str = "⌁"; -const FOOTER_COMPACT_BREAKPOINT: u16 = 120; -const FOOTER_HINT_LEFT_PADDING: usize = 1; -const FOOTER_HINT_GAP: usize = 3; const PICKER_CHROME_HEIGHT: u16 = 8; const PICKER_LIST_HORIZONTAL_INSET: u16 = 4; @@ -2004,13 +2004,6 @@ fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str { } } -struct PickerFooterHint { - key: &'static str, - wide_label: String, - compact_label: String, - priority: u8, -} - fn render_picker_footer( frame: &mut crate::custom_terminal::Frame, area: Rect, @@ -2022,9 +2015,9 @@ fn render_picker_footer( } let separator = Rect::new(area.x, area.y, area.width, 1); - render_picker_footer_separator( - frame, + render_footer_separator( separator, + frame.buffer, picker_footer_progress_label(state, list_height, area.width), ); @@ -2038,30 +2031,6 @@ fn render_picker_footer( } } -fn render_picker_footer_separator( - frame: &mut crate::custom_terminal::Frame, - area: Rect, - progress_label: String, -) { - if area.width == 0 { - return; - } - - let separator = "─".repeat(area.width as usize); - frame.render_widget_ref(Line::from(separator.dim()), area); - - let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16; - if progress_width < area.width { - let percent_area = Rect::new( - area.x + area.width - progress_width - 1, - area.y, - progress_width, - 1, - ); - frame.render_widget_ref(Line::from(progress_label.dim()), percent_area); - } -} - fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u16) -> String { let position = if state.filtered_rows.is_empty() { 0 @@ -2128,23 +2097,10 @@ fn picker_footer_scroll_percent(state: &PickerState, list_height: u16) -> u8 { fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { if state.is_transcript_loading() { let hints = [ - PickerFooterHint { - key: "loading", - wide_label: String::from("transcript"), - compact_label: String::from("transcript"), - priority: 0, - }, - PickerFooterHint { - key: "ctrl+c", - wide_label: String::from("quit"), - compact_label: String::from("quit"), - priority: 1, - }, + FooterHint::new("loading", "transcript", "transcript", /*priority*/ 0), + FooterHint::new("ctrl+c", "quit", "quit", /*priority*/ 1), ]; - let line = fit_footer_hints(&hints, FooterHintLabelMode::Wide, width) - .or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::Compact, width)) - .or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::KeyOnly, width)) - .unwrap_or_default(); + let line = footer_hint_line_for_row(&hints, width); return vec![line, Line::default()]; } @@ -2170,99 +2126,30 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { SessionListDensity::Dense => "comfy", }; let first_row_hints = vec![ - PickerFooterHint { - key: "enter", - wide_label: action_label.to_string(), - compact_label: action_label.to_string(), - priority: 0, - }, - PickerFooterHint { - key: "esc", - wide_label: esc_label.to_string(), - compact_label: esc_compact_label.to_string(), - priority: 1, - }, - PickerFooterHint { - key: "ctrl+c", - wide_label: ctrl_c_label.to_string(), - compact_label: ctrl_c_label.to_string(), - priority: 2, - }, - PickerFooterHint { - key: "tab", - wide_label: String::from("focus sort/filter"), - compact_label: String::from("focus"), - priority: 7, - }, - PickerFooterHint { - key: "←/→", - wide_label: String::from("change option"), - compact_label: String::from("option"), - priority: 8, - }, + FooterHint::new("enter", action_label, action_label, /*priority*/ 0), + FooterHint::new("esc", esc_label, esc_compact_label, /*priority*/ 1), + FooterHint::new("ctrl+c", ctrl_c_label, ctrl_c_label, /*priority*/ 2), + FooterHint::new("tab", "focus sort/filter", "focus", /*priority*/ 7), + FooterHint::new("←/→", "change option", "option", /*priority*/ 8), ]; let second_row_hints = vec![ - PickerFooterHint { - key: "ctrl+o", - wide_label: density_label.to_string(), - compact_label: density_compact_label.to_string(), - priority: 3, - }, - PickerFooterHint { - key: "ctrl+t", - wide_label: String::from("transcript"), - compact_label: String::from("preview"), - priority: 4, - }, - PickerFooterHint { - key: "ctrl+e", - wide_label: String::from("expand"), - compact_label: String::from("exp"), - priority: 6, - }, - PickerFooterHint { - key: "↑/↓", - wide_label: String::from("browse"), - compact_label: String::from("browse"), - priority: 5, - }, + FooterHint::new( + "ctrl+o", + density_label, + density_compact_label, + /*priority*/ 3, + ), + FooterHint::new("ctrl+t", "transcript", "preview", /*priority*/ 4), + FooterHint::new("ctrl+e", "expand", "exp", /*priority*/ 6), + FooterHint::new("↑/↓", "browse", "browse", /*priority*/ 5), ]; vec![ - hint_line_for_row(&first_row_hints, width), - hint_line_for_row(&second_row_hints, width), + footer_hint_line_for_row(&first_row_hints, width), + footer_hint_line_for_row(&second_row_hints, width), ] } -fn hint_line_for_row(hints: &[PickerFooterHint], width: u16) -> Line<'static> { - if width >= FOOTER_COMPACT_BREAKPOINT - && let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width) - { - return line; - } - if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) { - return line; - } - if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) { - return line; - } - - let mut retained = (0..hints.len()).collect::>(); - retained.sort_by_key(|idx| hints[*idx].priority); - for retain_count in (1..=retained.len()).rev() { - let mut candidate_indices = retained[..retain_count].to_vec(); - candidate_indices.sort_unstable(); - let candidate = candidate_indices - .iter() - .map(|idx| &hints[*idx]) - .collect::>(); - if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) { - return line; - } - } - Line::default() -} - fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame, area: Rect) { if area.width == 0 || area.height == 0 { return; @@ -2312,99 +2199,6 @@ fn transcript_loading_overlay_style() -> Style { Style::default().bg(best_color(blend(overlay, bg, alpha))) } -#[derive(Clone, Copy)] -enum FooterHintLabelMode { - Wide, - Compact, - KeyOnly, -} - -fn fit_footer_hints( - hints: &[PickerFooterHint], - mode: FooterHintLabelMode, - width: u16, -) -> Option> { - let hint_refs = hints.iter().collect::>(); - fit_footer_hint_refs(&hint_refs, mode, width) -} - -fn fit_footer_hint_refs( - hints: &[&PickerFooterHint], - mode: FooterHintLabelMode, - width: u16, -) -> Option> { - let gap_width = FOOTER_HINT_GAP; - if footer_hints_width(hints, mode, gap_width) > width as usize { - return None; - } - - let mut spans = vec![ - " ".repeat(FOOTER_HINT_LEFT_PADDING) - .set_style(footer_hint_label_style()), - ]; - for (idx, hint) in hints.iter().enumerate() { - if idx > 0 { - spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style())); - } - spans.push(hint.key.set_style(footer_hint_key_style())); - let label = match mode { - FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()), - FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()), - FooterHintLabelMode::KeyOnly => None, - }; - if let Some(label) = label { - spans.push(" ".set_style(footer_hint_label_style())); - spans.push(label.to_string().set_style(footer_hint_label_style())); - } - } - Some(spans.into()) -} - -fn footer_hint_key_style() -> Style { - if default_bg().is_some_and(is_light) { - Style::default().fg(Color::Black) - } else { - Style::default() - } -} - -fn footer_hint_label_style() -> Style { - if default_bg().is_some_and(is_light) { - Style::default().fg(Color::DarkGray) - } else { - Style::default().dim() - } -} - -fn footer_hints_width( - hints: &[&PickerFooterHint], - mode: FooterHintLabelMode, - gap_width: usize, -) -> usize { - FOOTER_HINT_LEFT_PADDING - + hints - .iter() - .enumerate() - .map(|(idx, hint)| { - let label_width = match mode { - FooterHintLabelMode::Wide => { - 1 + UnicodeWidthStr::width(hint.wide_label.as_str()) - } - FooterHintLabelMode::Compact => { - 1 + UnicodeWidthStr::width(hint.compact_label.as_str()) - } - FooterHintLabelMode::KeyOnly => 0, - }; - let hint_width = UnicodeWidthStr::width(hint.key) + label_width; - if idx == 0 { - hint_width - } else { - hint_width + gap_width - } - }) - .sum::() -} - fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) { if area.height == 0 { return; diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap index bc7818aff9..13c5d2e4b7 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap @@ -2,13 +2,13 @@ source: tui/src/pager_overlay.rs expression: term.backend() --- -"/ S T A T I C / / / / / / / / / / / / / " +" S T A T I C · 100% ────────────────────" "one " "two " "three " "~ " "~ " -"───────────────────────────────── 100% ─" -" ↑/↓ to scroll pgup/pgdn to page hom" -" q to quit " +"────────────────────────────────────────" +" ↑/↓ pgup/pgdn home/end " +" q quit " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap index 8bbcc488c2..5ae408ef02 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap @@ -2,11 +2,11 @@ source: tui/src/pager_overlay.rs expression: term.backend() --- -"/ S T A T I C / / / / / " +" S T A T I C · 0% ──────" "a very long line that " "should wrap when " "rendered within a narrow" -"─────────────────── 0% ─" -" ↑/↓ to scroll pgup/pg" -" q to quit " +"────────────────────────" +" ↑/↓ pgup/pgdn " +" q quit " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap index 1f086e81e4..9354651904 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap @@ -2,7 +2,7 @@ source: tui/src/pager_overlay.rs expression: snapshot --- -/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / + Transcript · 0 prompts · 0% ─────────────────────────────────────────────────── • Added foo.txt (+2 -0) 1 +hello 2 +world @@ -10,6 +10,6 @@ expression: snapshot • Added foo.txt (+2 -0) 1 +hello 2 +world -─────────────────────────────────────────────────────────────────────────── 0% ─ - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev +──────────────────────────────────────────────────────────────────────────────── + ↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump + q quit ctrl + o copy ⌥ + r raw esc/← prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap index 05ea902460..591e617f3b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap @@ -2,13 +2,13 @@ source: tui/src/pager_overlay.rs expression: term.backend() --- -"/ T R A N S C R I P T / / / / / / / / / " +" Transcript · 0 prompts · 100% ─────────" "alpha " " " "tail " "~ " "~ " -"───────────────────────────────── 100% ─" -" ↑/↓ to scroll pgup/pgdn to page hom" -" q to quit esc to edit prev " +"────────────────────────────────────────" +" ↑/↓ ←/→ pgup/pgdn home/end " +" q ctrl + o ⌥ + r esc/← " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap index 05ab469a1b..983c959968 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap @@ -2,13 +2,13 @@ source: tui/src/pager_overlay.rs expression: term.backend() --- -"/ T R A N S C R I P T / / / / / / / / / " +" Transcript · 0 prompts · 100% ─────────" "alpha " " " "beta " " " "gamma " -"───────────────────────────────── 100% ─" -" ↑/↓ to scroll pgup/pgdn to page hom" -" ctrl + o to copy ⌥ + r raw q to qui" +"────────────────────────────────────────" +" ↑/↓ ←/→ pgup/pgdn home/end " +" q ctrl + o ⌥ + r esc/← " " "