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))); }