diff --git a/codex-rs/tui/src/footer_hints.rs b/codex-rs/tui/src/footer_hints.rs index 4bfdee3377..38a420f7e2 100644 --- a/codex-rs/tui/src/footer_hints.rs +++ b/codex-rs/tui/src/footer_hints.rs @@ -99,6 +99,14 @@ pub(crate) fn render_footer_separator(area: Rect, buf: &mut Buffer, label: Strin } } +pub(crate) fn first_fitting_right_label(width: u16, labels: &[String]) -> String { + labels + .iter() + .find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize) + .cloned() + .unwrap_or_default() +} + pub(crate) fn render_footer_line_with_optional_right( area: Rect, buf: &mut Buffer, @@ -372,4 +380,25 @@ mod tests { assert_eq!(buffer_text(&buf, area), "sta…"); } + + #[test] + fn first_fitting_right_label_picks_first_that_fits() { + let labels = vec![ + " 10 / 120 · 55% ".to_string(), + " 10/120 · 55% ".to_string(), + " 55% ".to_string(), + ]; + + assert_eq!( + first_fitting_right_label(/*width*/ 15, &labels), + " 10/120 · 55% " + ); + } + + #[test] + fn first_fitting_right_label_returns_empty_when_nothing_fits() { + let labels = vec![" 100% ".to_string()]; + + assert_eq!(first_fitting_right_label(/*width*/ 4, &labels), ""); + } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 4dd96d83f6..38059a1423 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -23,6 +23,7 @@ use std::time::Instant; use crate::chatwidget::ActiveCellTranscriptKey; use crate::chatwidget::CopyStatus; use crate::footer_hints::FooterHint; +use crate::footer_hints::first_fitting_right_label; use crate::footer_hints::footer_hint_line_for_row; use crate::footer_hints::render_footer_line_with_optional_right; use crate::footer_hints::render_footer_separator; @@ -141,6 +142,8 @@ struct PagerView { renderables: Vec>, scroll_offset: usize, title: String, + footer_separator_label: String, + show_header_progress: bool, keymap: PagerKeymap, last_content_height: Option, last_rendered_height: Option, @@ -168,6 +171,8 @@ impl PagerView { renderables, scroll_offset, title, + footer_separator_label: String::new(), + show_header_progress: true, keymap, last_content_height: None, last_rendered_height: None, @@ -235,10 +240,13 @@ impl PagerView { 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(header, buf); + let title = if self.show_header_progress { + let percent = self.scroll_percent(content_area.height, total_len); + format!(" {} · {percent}% ", self.title) + } else { + format!(" {} ", self.title) + }; + title.dim().render_ref(header, buf); } fn render_content(&mut self, area: Rect, buf: &mut Buffer) { @@ -306,7 +314,7 @@ impl PagerView { let sep_y = content_area.bottom(); let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1); - render_footer_separator(sep_rect, buf, String::new()); + render_footer_separator(sep_rect, buf, self.footer_separator_label.clone()); } fn scroll_percent(&self, content_height: u16, total_len: usize) -> u8 { @@ -535,12 +543,16 @@ impl TranscriptOverlay { state: TranscriptOverlayState, ) -> Self { Self { - view: PagerView::new( - Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode), - "Transcript".to_string(), - state.scroll_offset, - keymap, - ), + view: { + let mut view = PagerView::new( + Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode), + "Transcript".to_string(), + state.scroll_offset, + keymap, + ); + view.show_header_progress = false; + view + }, cells: transcript_cells, highlight_cell: state.highlight_cell, render_mode: state.render_mode, @@ -832,26 +844,34 @@ impl TranscriptOverlay { } fn header_title(&self) -> String { + "Transcript".to_string() + } + + fn footer_progress_label(&self, content_height: u16, total_len: usize, width: u16) -> 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}") + .highlight_cell + .and_then(|highlight_cell| { + let selected = self + .cells + .iter() + .take(highlight_cell.saturating_add(1)) + .filter(|cell| cell.is_user_prompt()) + .count(); + (selected > 0).then_some(selected) + }) + .unwrap_or(total); + let percent = self.view.scroll_percent(content_height, total_len); + let labels = [ + format!(" {selected} / {total} · {percent}% "), + format!(" {selected}/{total} · {percent}% "), + format!(" {percent}% "), + ]; + first_fitting_right_label(width, &labels) } fn rebuild_renderables(&mut self) { @@ -1036,6 +1056,10 @@ impl TranscriptOverlay { 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(); + let content_area = self.view.content_area(top); + let total_len = self.view.content_height(content_area.width); + self.view.footer_separator_label = + self.footer_progress_label(content_area.height, total_len, top.width); self.view.render(top, buf); self.render_hints(bottom, buf); } @@ -1440,7 +1464,7 @@ mod tests { } #[test] - fn transcript_header_counts_selected_user_prompt() { + fn transcript_header_title_is_stable() { let mut overlay = transcript_overlay(vec![ user_cell("first"), Arc::new(AgentMessageCell::new( @@ -1450,13 +1474,48 @@ mod tests { user_cell("second"), ]); - assert_eq!(overlay.header_title(), "Transcript · 2 prompts"); + assert_eq!(overlay.header_title(), "Transcript"); overlay.move_prompt_selection(PromptSelectionDirection::Previous); - assert_eq!(overlay.header_title(), "Transcript · 2/2"); + assert_eq!(overlay.header_title(), "Transcript"); overlay.move_prompt_selection(PromptSelectionDirection::Previous); - assert_eq!(overlay.header_title(), "Transcript · 1/2"); + assert_eq!(overlay.header_title(), "Transcript"); + } + + #[test] + fn transcript_footer_progress_label_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.footer_progress_label( + /*content_height*/ 5, /*total_len*/ 12, /*width*/ 80 + ), + " 2 / 2 · 100% " + ); + + overlay.move_prompt_selection(PromptSelectionDirection::Previous); + assert_eq!( + overlay.footer_progress_label( + /*content_height*/ 5, /*total_len*/ 12, /*width*/ 80 + ), + " 2 / 2 · 100% " + ); + + overlay.move_prompt_selection(PromptSelectionDirection::Previous); + assert_eq!( + overlay.footer_progress_label( + /*content_height*/ 5, /*total_len*/ 12, /*width*/ 80 + ), + " 1 / 2 · 100% " + ); } #[test] diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 0ad976c189..7e7103e8aa 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -10,6 +10,7 @@ use crate::app_server_session::AppServerSession; use crate::color::blend; use crate::color::is_light; use crate::footer_hints::FooterHint; +use crate::footer_hints::first_fitting_right_label; use crate::footer_hints::footer_hint_line_for_row; use crate::footer_hints::render_footer_separator; use crate::git_action_directives::parse_assistant_markdown; @@ -2048,10 +2049,7 @@ fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u1 format!(" {position}/{total} · {percent}% "), format!(" {percent}% "), ]; - labels - .into_iter() - .find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize) - .unwrap_or_default() + first_fitting_right_label(width, &labels) } fn picker_footer_percent(state: &PickerState, list_height: u16) -> u8 { 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 9354651904..dcea917ef8 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 --- - Transcript · 0 prompts · 0% ─────────────────────────────────────────────────── + Transcript ──────────────────────────────────────────────────────────────────── • 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 / 0 · 0% ─ ↑/↓ 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_footer_status.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status.snap index 119c75508d..0383b7051e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status.snap @@ -2,10 +2,10 @@ source: tui/src/pager_overlay.rs expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 80, 8),)" --- - Transcript · 1 prompt · 100% ────────────────────────────────────────────────── + Transcript ──────────────────────────────────────────────────────────────────── › prompt -──────────────────────────────────────────────────────────────────────────────── +───────────────────────────────────────────────────────────────── 1 / 1 · 100% ─ ↑/↓ scroll ←/→ prompts pgup/pgdn page … Copied selected turn to clipboard q quit ctrl + o copy ⌥ + r raw esc/← prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status_narrow.snap index 3554c6ae0a..89a8f012bb 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status_narrow.snap @@ -2,10 +2,10 @@ source: tui/src/pager_overlay.rs expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 28, 8),)" --- - Transcript · 1 prompt · 100 + Transcript ──────────────── › prompt -──────────────────────────── +───────────── 1 / 1 · 100% ─ No agent response to copy f… q ctrl + o ⌥ + r 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 591e617f3b..db153c0197 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() --- -" Transcript · 0 prompts · 100% ─────────" +" Transcript ────────────────────────────" "alpha " " " "tail " "~ " "~ " -"────────────────────────────────────────" +"───────────────────────── 0 / 0 · 100% ─" " ↑/↓ ←/→ 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 983c959968..7cc32c2047 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() --- -" Transcript · 0 prompts · 100% ─────────" +" Transcript ────────────────────────────" "alpha " " " "beta " " " "gamma " -"────────────────────────────────────────" +"───────────────────────── 0 / 0 · 100% ─" " ↑/↓ ←/→ pgup/pgdn home/end " " q ctrl + o ⌥ + r esc/← " " "