From 0a73d55b80afd2aa88051848bd28524d132fd01e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 22 Sep 2026 17:56:10 +0000 Subject: [PATCH] Avoid redundant spacing above the transcript composer (#47331) ## Why An empty composer hint row already separates activity from the input. Adding another separator wastes transcript space, especially in short terminals. ## What changed Reserve the extra separator only when copy status text, a follow control, or a fitting hint occupies the shared row and the viewport has room. Recalculate the transcript layout in the same frame when adding that separator, and use a shared timestamp for copy status visibility and expiry. ## Testing Extend snapshots for running turns with no hint, follow controls in short terminals, and copy status text at narrow widths and limited heights. Update resize assertions to check that the draft and caret remain stable relative to the terminal bottom. GitOrigin-RevId: a73894063b759ec6b9a25f720e0e8007516109c8 --- codex-rs/tui/src/app/composer_hints_tests.rs | 2 +- codex-rs/tui/src/app/owned_transcript.rs | 28 ++++++++- .../src/app/owned_transcript_follow_tests.rs | 52 +++++++++++++-- ..._tests__running_copy_feedback_spacing.snap | 61 ++++++++++++++++++ ..._follow_tests__running_follow_control.snap | 2 +- ...running_follow_control_short_terminal.snap | 13 ++++ ..._tests__running_without_composer_hint.snap | 16 +++++ codex-rs/tui/src/bottom_pane/composer_gap.rs | 2 + codex-rs/tui/src/bottom_pane/mod.rs | 2 + .../tui/src/transcript_view/composer_gap.rs | 42 ++++++++++--- .../src/transcript_view/composer_gap_tests.rs | 41 ++++++++---- .../tui/src/transcript_view/follow_control.rs | 63 ++++++++++--------- .../transcript_view/right_click_copy_tests.rs | 2 + 13 files changed, 265 insertions(+), 61 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_follow_control_short_terminal.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_without_composer_hint.snap diff --git a/codex-rs/tui/src/app/composer_hints_tests.rs b/codex-rs/tui/src/app/composer_hints_tests.rs index bbbccf2674..f0331cebf4 100644 --- a/codex-rs/tui/src/app/composer_hints_tests.rs +++ b/codex-rs/tui/src/app/composer_hints_tests.rs @@ -99,7 +99,7 @@ async fn usage_notice_preserves_composer_geometry_and_restores_tip_on_recovery() app.insert_history_cell(&mut tui, cell); } } - assert_eq!(app.render_owned_transcript(&mut tui, size)?, before); + app.render_owned_transcript(&mut tui, size)?; assert_eq!(tui.terminal.last_known_cursor_pos, cursor); let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); let screen = buffer diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 515310a309..18ba695600 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -83,7 +83,7 @@ impl App { self.sync_owned_transcript(screen_size.width); let transcript_width = self.chat_widget.history_wrap_width(screen_size.width); let composer_hint = self.composer_hint(transcript_width); - let composer_gap = (!self.chat_widget.has_active_view() + let mut composer_gap = (!self.chat_widget.has_active_view() && !self.chat_widget.is_external_writer_view()) .then(crate::bottom_pane::ComposerGap::default); let mut prompt_footer = @@ -124,7 +124,7 @@ impl App { }; drop(bottom); let available = screen_size.height.saturating_sub(bottom_height); - let bottom_area = Rect::new( + let mut bottom_area = Rect::new( /*x*/ 0, screen_size.height.saturating_sub(bottom_height), screen_size.width, @@ -133,6 +133,7 @@ impl App { let mut rendered_cursor = None; let mut footer_height_changed = false; let mut feedback_tick = None; + let now = Instant::now(); tui.draw(screen_size.height, |frame| { ratatui::widgets::Clear.render( Rect::new(/*x*/ 0, /*y*/ 0, screen_size.width, available), @@ -148,6 +149,11 @@ impl App { frame.buffer, &self.transcript_cells, ); + if let Some(gap) = composer_gap.as_mut() { + gap.needs_separator = available > 1 + && chat_widget.no_modal_or_popup_active() + && view.composer_gap_has_content(transcript_width, composer_hint.as_ref(), now); + } // Rendering resolves whether new activity is still hidden. Paint that result in // this frame so a revision change cannot flash a stale activity hint. let mut footer = @@ -177,6 +183,22 @@ impl App { .desired_height(screen_size.width) .min(screen_size.height) != bottom_height; + if footer_height_changed && composer_gap.as_ref().is_some_and(|gap| gap.needs_separator) + { + bottom_area.height = bottom + .desired_height(screen_size.width) + .min(screen_size.height); + bottom_area.y = screen_size.height.saturating_sub(bottom_area.height); + // Resolve controls with the compact viewport first, then make room for + // their separator. Resizing must not preserve a stale return control. + ratatui::widgets::Clear.render(bottom_area, frame.buffer); + view.render( + Rect::new(/*x*/ 0, /*y*/ 0, transcript_width, bottom_area.y), + frame.buffer, + &self.transcript_cells, + ); + footer_height_changed = false; + } bottom.render(bottom_area, frame.buffer); let follow_area = if let Some(gap) = composer_gap.as_ref() { Some(Rect { @@ -195,7 +217,7 @@ impl App { } .filter(|_| chat_widget.no_modal_or_popup_active()); feedback_tick = - view.render_composer_gap(follow_area, composer_hint.as_ref(), frame.buffer); + view.render_composer_gap(follow_area, composer_hint.as_ref(), frame.buffer, now); chat_widget.note_rendered_width(screen_size.width); rendered_cursor = bottom.cursor_pos(bottom_area); if let Some(position) = rendered_cursor { diff --git a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs index e4a364a9bd..6f05c021e0 100644 --- a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs @@ -47,29 +47,49 @@ async fn follow_control_click_preserves_draft_caret_and_composer_geometry() -> R app.transcript_view.jump_to_latest(); let bottom = app.render_owned_transcript(&mut tui, size)?; let cursor = tui.terminal.last_known_cursor_pos; + if running { + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + insta::assert_snapshot!( + "running_without_composer_hint", + crate::chatwidget::tests::helpers::normalize_snapshot_paths( + super::tests::buffer_text(buffer) + ), + ); + } app.transcript_view .scroll(&app.transcript_cells, /*rows*/ -10); tui.screen_size_for_event(&TuiEvent::Resize(size))?; - assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom); - for height in [24, 12] { + app.render_owned_transcript(&mut tui, size)?; + assert_eq!(tui.terminal.last_known_cursor_pos, cursor); + for height in [24, 9, 8, 12] { let resized = Size::new(width, height); tui.screen_size_for_event(&TuiEvent::Resize(resized))?; - let resized_bottom = app.render_owned_transcript(&mut tui, resized)?; + app.render_owned_transcript(&mut tui, resized)?; let resized_cursor = tui.terminal.last_known_cursor_pos; assert_eq!( ( app.chat_widget.composer_text_with_pending(), resized_cursor.x, - resized_cursor.y - resized_bottom.y, + resized.height - resized_cursor.y, app.transcript_view.is_following(), ), ( "draft stays here".to_string(), cursor.x, - cursor.y - bottom.y, + size.height - cursor.y, false ) ); + if running && height == 9 { + let buffer = + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + insta::assert_snapshot!( + "running_follow_control_short_terminal", + crate::chatwidget::tests::helpers::normalize_snapshot_paths( + super::tests::buffer_text(buffer) + ), + ); + } } let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); if running { @@ -121,6 +141,28 @@ async fn follow_control_click_preserves_draft_caret_and_composer_geometry() -> R ("draft stays here".to_string(), cursor) ); assert!(app.chat_widget.queued_user_message_texts().is_empty()); + if running { + app.transcript_view.show_copy_feedback( + &Ok(crate::clipboard_copy::CopyStatus::Confirmed), + /*characters*/ 24, + ); + let mut snapshots = Vec::new(); + for (width, height) in [(80, 12), (18, 14), (6, 18), (80, 6)] { + let size = Size::new(width, height); + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + app.render_owned_transcript(&mut tui, size)?; + let buffer = + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + snapshots.push(format!( + "{width} columns, {height} rows\n{}", + super::tests::buffer_text(buffer) + )); + } + insta::assert_snapshot!( + "running_copy_feedback_spacing", + crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapshots.join("\n\n")), + ); + } } tui.set_owned_screen(/*owned*/ false)?; assert!(!app.handle_owned_transcript_event( diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap new file mode 100644 index 0000000000..41dac123fd --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap @@ -0,0 +1,61 @@ +--- +source: tui/src/app/owned_transcript_follow_tests.rs +expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapshots.join(\"\\n\\n\"))" +--- +80 columns, 12 rows +transcript row 37 +transcript row 38 +transcript row 39 + +• Working (0s • esc to interrupt) + + Copied 24 chars to host clipboard + +› draft stays here + + GPT-5.6-Sol default · /tmp/project + tab to queue message + +18 columns, 14 rows +transcript row 36 +transcript row 37 +transcript row 38 +transcript row 39 + +• Working (0s • e… + + Copied 24 chars + +› draft stays + here + + GPT-5.6-Sol def… + tab to queue mes + +6 columns, 18 rows +ript +row 38 +transc +ript +row 39 + +• Wor… + + +› dra + ft + sta + ys + her + e + + GPT… + tab + +80 columns, 6 rows +• Working (0s • esc to interrupt) + +› draft stays here + + GPT-5.6-Sol default · /tmp/project + tab to queue message diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_follow_control.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_follow_control.snap index 76db613360..37eeb84503 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_follow_control.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_follow_control.snap @@ -2,9 +2,9 @@ source: tui/src/app/owned_transcript_follow_tests.rs expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(super::tests::buffer_text(buffer))" --- +transcript row 26 transcript row 27 transcript row 28 -transcript row 29 • Working (0s • esc to interrupt) diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_follow_control_short_terminal.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_follow_control_short_terminal.snap new file mode 100644 index 0000000000..fada310fd9 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_follow_control_short_terminal.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/owned_transcript_follow_tests.rs +expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(super::tests::buffer_text(buffer))" +--- +transcript row 26 + +• Working (0s • esc to interrupt) + ↓ Back to bottom · esc + +› draft stays here + + GPT-5.6-Sol default · /tmp/project + tab to queue message diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_without_composer_hint.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_without_composer_hint.snap new file mode 100644 index 0000000000..4087c94dbd --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_without_composer_hint.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/owned_transcript_follow_tests.rs +expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(super::tests::buffer_text(buffer))" +--- +transcript row 36 +transcript row 37 +transcript row 38 +transcript row 39 + +• Working (0s • esc to interrupt) + + +› draft stays here + + GPT-5.6-Sol default · /tmp/project + tab to queue message diff --git a/codex-rs/tui/src/bottom_pane/composer_gap.rs b/codex-rs/tui/src/bottom_pane/composer_gap.rs index 1e1e962b1c..3e87c356cc 100644 --- a/codex-rs/tui/src/bottom_pane/composer_gap.rs +++ b/codex-rs/tui/src/bottom_pane/composer_gap.rs @@ -10,6 +10,8 @@ use crate::render::renderable::Renderable; #[derive(Default)] pub(crate) struct ComposerGap { + /// Visible text needs a separator after activity when the viewport has room for it. + pub(crate) needs_separator: bool, /// Filled during rendering; a fresh instance prevents reusing a hidden or clipped row. pub(crate) area: Cell, } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2f70829f88..7ecd259a85 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -2193,8 +2193,10 @@ impl BottomPane { }, ); let question_editor = self.questions.as_ref().filter(|q| q.expanded); + // An empty shared gap already separates activity from the composer. if !has_inline_previews && has_status_or_footer + && options.composer_gap.is_none_or(|gap| gap.needs_separator) && question_editor.is_none_or(|q| q.unanswered_count() > 1) { flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); diff --git a/codex-rs/tui/src/transcript_view/composer_gap.rs b/codex-rs/tui/src/transcript_view/composer_gap.rs index fec537370b..db5fb43680 100644 --- a/codex-rs/tui/src/transcript_view/composer_gap.rs +++ b/codex-rs/tui/src/transcript_view/composer_gap.rs @@ -16,6 +16,36 @@ pub(super) struct CopyFeedback { } impl TranscriptView { + pub(crate) fn composer_gap_has_content( + &self, + width: u16, + hint: Option<&HyperlinkLine>, + now: Instant, + ) -> bool { + if let Some(feedback) = self + .copy_feedback + .as_ref() + .filter(|feedback| feedback.expires_at > now) + { + return feedback.line(width.saturating_sub(/*rhs*/ 1)).width() > 0; + } + self.follow_control_label(width).is_some() + || self.fitting_composer_hint(width, hint).is_some() + } + + fn fitting_composer_hint<'a>( + &self, + width: u16, + hint: Option<&'a HyperlinkLine>, + ) -> Option<&'a HyperlinkLine> { + hint.filter(|hint| { + self.is_following() + && !self.has_active_interaction() + && hint.width() > 0 + && hint.width() + 2 <= usize::from(width) + }) + } + pub(crate) fn show_copy_feedback( &mut self, result: &Result, @@ -33,12 +63,13 @@ impl TranscriptView { area: Option, hint: Option<&HyperlinkLine>, buffer: &mut Buffer, + now: Instant, ) -> Option { self.composer_tip = None; if self .copy_feedback .as_ref() - .is_some_and(|feedback| feedback.expires_at <= Instant::now()) + .is_some_and(|feedback| feedback.expires_at <= now) { self.copy_feedback = None; } @@ -48,9 +79,7 @@ impl TranscriptView { }; if let Some(feedback) = &self.copy_feedback { let line = feedback.line(area.width.saturating_sub(/*rhs*/ 1)); - let delay = feedback - .expires_at - .saturating_duration_since(Instant::now()); + let delay = feedback.expires_at.saturating_duration_since(now); let width = line.width().min(usize::from(area.width)) as u16; let target = Rect::new( area.right().saturating_sub(width + 1).max(area.x), @@ -63,10 +92,7 @@ impl TranscriptView { return Some(delay); } self.render_follow_control(Some(area), buffer); - if self.is_following() - && !self.has_active_interaction() - && let Some(hint) = hint.filter(|hint| hint.width() + 2 <= usize::from(area.width)) - { + if let Some(hint) = self.fitting_composer_hint(area.width, hint) { let width = hint.width() as u16; let target = Rect::new(area.right() - width - 1, area.y, width, /*height*/ 1); HyperlinkParagraph::new(std::slice::from_ref(hint), Style::default()) diff --git a/codex-rs/tui/src/transcript_view/composer_gap_tests.rs b/codex-rs/tui/src/transcript_view/composer_gap_tests.rs index 31b07554ea..5aa6414813 100644 --- a/codex-rs/tui/src/transcript_view/composer_gap_tests.rs +++ b/codex-rs/tui/src/transcript_view/composer_gap_tests.rs @@ -21,11 +21,17 @@ fn copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery() { for width in [80, 32, 18] { let mut view = TranscriptView::default(); view.show_copy_feedback(&result, /*characters*/ 24); + assert!(view.composer_gap_has_content(width, /*hint*/ None, Instant::now())); let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1); let mut buffer = Buffer::empty(area); assert!( - view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer) - .is_some() + view.render_composer_gap( + Some(area), + /*hint*/ None, + &mut buffer, + Instant::now() + ) + .is_some() ); assert_eq!(buffer[(width - 1, 0)].symbol(), " "); snapshots.push(format!("{result:?}, {width} columns\n{}", text(&buffer))); @@ -45,20 +51,24 @@ fn feedback_releases_navigation_targets_and_expiry_restores_them() { /*x*/ 0, /*y*/ 3, /*width*/ 60, /*height*/ 1, ); let mut buffer = Buffer::empty(area); - view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer); + assert!(view.composer_gap_has_content(area.width, /*hint*/ None, Instant::now())); + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now()); assert!(text(&buffer).contains("Back to bottom")); view.show_copy_feedback(&Ok(CopyStatus::Unconfirmed), /*characters*/ 3); buffer.reset(); - view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer); + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now()); assert!(!text(&buffer).contains("Back to bottom")); assert!(!view.is_following()); view.copy_feedback.as_mut().unwrap().expires_at = Instant::now(); buffer.reset(); assert_eq!( - view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer), + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now()), None ); assert!(text(&buffer).contains("Back to bottom")); + view.jump_to_latest(); + render(&mut view, &cells, /*width*/ 60, /*height*/ 3); + assert!(!view.composer_gap_has_content(area.width, /*hint*/ None, Instant::now())); } #[test] @@ -77,7 +87,7 @@ fn tip_links_follow_alignment_and_release_stale_targets() { row: area.y, modifiers: KeyModifiers::CONTROL, }; - view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now()); assert!( buffer[(click.column, click.row)] .symbol() @@ -120,8 +130,13 @@ fn tip_links_follow_alignment_and_release_stale_targets() { } for replacement in [None, Some(HyperlinkLine::from("Other tip"))] { - view.render_composer_gap(Some(area), Some(&tip), &mut buffer); - view.render_composer_gap(Some(area), replacement.as_ref(), &mut buffer); + view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now()); + view.render_composer_gap( + Some(area), + replacement.as_ref(), + &mut buffer, + Instant::now(), + ); assert!(view.handle_mouse(click, &[]).is_none()); } for next_area in [ @@ -136,16 +151,16 @@ fn tip_links_follow_alignment_and_release_stale_targets() { /*x*/ 4, /*y*/ 3, /*width*/ 20, /*height*/ 1, )), ] { - view.render_composer_gap(Some(area), Some(&tip), &mut buffer); - view.render_composer_gap(next_area, Some(&tip), &mut buffer); + view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now()); + view.render_composer_gap(next_area, Some(&tip), &mut buffer, Instant::now()); assert!(view.handle_mouse(click, &[]).is_none()); } - view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now()); view.show_copy_feedback(&Ok(CopyStatus::Confirmed), /*characters*/ 3); - view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now()); assert!(view.handle_mouse(click, &[]).is_none()); view.copy_feedback.as_mut().unwrap().expires_at = Instant::now(); - view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now()); assert!(matches!( view.handle_mouse(click, &[]), Some(ViewAction::OpenLink(_)) diff --git a/codex-rs/tui/src/transcript_view/follow_control.rs b/codex-rs/tui/src/transcript_view/follow_control.rs index 32256d3a88..140aa2d68b 100644 --- a/codex-rs/tui/src/transcript_view/follow_control.rs +++ b/codex-rs/tui/src/transcript_view/follow_control.rs @@ -19,39 +19,11 @@ pub(super) struct FollowControl { impl TranscriptView { pub(crate) fn render_follow_control(&mut self, area: Option, buf: &mut Buffer) { - let area = area.filter(|area| { - !area.is_empty() - && !self.area.is_empty() - && !self.tail_visible - && self.highlight.is_none() - && self.selection.is_none() - && !self.is_search_active() - }); - let Some(area) = area else { + let Some(area) = area.filter(|area| !area.is_empty()) else { self.follow_control = FollowControl::default(); return; }; - let labels = if self.unseen_activity { - [ - " New activity · ↓ Back to bottom · esc ", - " New activity · ↓ Bottom ", - " New · ↓ Bottom ", - " ↓ Bottom ", - " ↓ ", - ] - } else { - [ - " ↓ Back to bottom · esc ", - " ↓ Back to bottom ", - " ↓ Bottom · esc ", - " ↓ Bottom ", - " ↓ ", - ] - }; - let Some(label) = labels - .into_iter() - .find(|label| label.width() <= usize::from(area.width)) - else { + let Some(label) = self.follow_control_label(area.width) else { self.follow_control = FollowControl::default(); return; }; @@ -77,6 +49,37 @@ impl TranscriptView { Paragraph::new(label).style(style).render(target, buf); } + pub(super) fn follow_control_label(&self, width: u16) -> Option<&'static str> { + if self.area.is_empty() + || self.tail_visible + || self.highlight.is_some() + || self.selection.is_some() + || self.is_search_active() + { + return None; + } + let labels = if self.unseen_activity { + [ + " New activity · ↓ Back to bottom · esc ", + " New activity · ↓ Bottom ", + " New · ↓ Bottom ", + " ↓ Bottom ", + " ↓ ", + ] + } else { + [ + " ↓ Back to bottom · esc ", + " ↓ Back to bottom ", + " ↓ Bottom · esc ", + " ↓ Bottom ", + " ↓ ", + ] + }; + labels + .into_iter() + .find(|label| label.width() <= usize::from(width)) + } + pub(super) fn handle_follow_control_mouse(&mut self, event: MouseEvent) -> Option { let control = &mut self.follow_control; let area = control.area?; diff --git a/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs b/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs index 1d97d773aa..a1c7373e68 100644 --- a/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs +++ b/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs @@ -6,6 +6,7 @@ use crate::transcript_view::tests::cell; use crate::transcript_view::tests::render; use crate::transcript_view::tests::text; use pretty_assertions::assert_eq; +use std::time::Instant; #[test] fn right_click_retains_selection_until_confirmed_and_preserves_reading_position() { @@ -62,6 +63,7 @@ fn right_click_retains_selection_until_confirmed_and_preserves_reading_position( )), /*hint*/ None, &mut buffer, + Instant::now(), ); frames.push(format!("{result:?}\n{}", text(&buffer))); }