From e5e2425b71a5253b3201109f33ca8d74a1db537b Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 15:50:09 -0300 Subject: [PATCH] feat(tui): show transcript copy status in footer --- codex-rs/tui/src/app_backtrack.rs | 20 +- codex-rs/tui/src/chatwidget.rs | 18 ++ codex-rs/tui/src/chatwidget/interaction.rs | 73 +++--- .../src/chatwidget/tests/slash_commands.rs | 52 ++-- codex-rs/tui/src/footer_hints.rs | 140 +++++++++++ codex-rs/tui/src/pager_overlay.rs | 229 +++++++++++++++--- ...sts__transcript_overlay_footer_status.snap | 11 + ...anscript_overlay_footer_status_narrow.snap | 11 + 8 files changed, 453 insertions(+), 101 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status_narrow.snap diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 8d66ac2911..4e5369bda0 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -453,10 +453,17 @@ impl App { } else { (None, false, false) }; - if let Some(user_cell_idx) = copy_selection { - self.copy_transcript_turn(user_cell_idx); + let copy_status = if let Some(user_cell_idx) = copy_selection { + Some(self.copy_transcript_turn(user_cell_idx)) } else if copy_latest { - self.chat_widget.copy_last_agent_markdown(); + Some(self.chat_widget.copy_last_agent_markdown_for_overlay()) + } else { + None + }; + if let Some(status) = copy_status + && let Some(Overlay::Transcript(transcript)) = &mut self.overlay + { + transcript.show_copy_status(&status, tui); } if close_overlay { self.close_transcript_overlay(tui); @@ -465,17 +472,16 @@ impl App { Ok(()) } - fn copy_transcript_turn(&mut self, user_cell_idx: usize) { + fn copy_transcript_turn(&mut self, user_cell_idx: usize) -> crate::chatwidget::CopyStatus { let Some(user_cell) = self.transcript_cells.get(user_cell_idx).and_then(|cell| { cell.as_any() .downcast_ref::() }) else { - self.chat_widget.copy_last_agent_markdown(); - return; + return self.chat_widget.copy_last_agent_markdown_for_overlay(); }; let user_turn_count = user_count(&self.transcript_cells[..=user_cell_idx]); self.chat_widget - .copy_agent_turn_markdown(user_turn_count, &user_cell.message); + .copy_agent_turn_markdown_for_overlay(user_turn_count, &user_cell.message) } /// Handle Enter in overlay backtrack preview: confirm selection and reset state. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c24d5dfcec..52381f5d33 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,24 @@ use tracing::debug; use tracing::warn; const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CopyStatus { + Success(String), + Error(String), +} + +impl CopyStatus { + pub(crate) fn message(&self) -> &str { + match self { + Self::Success(message) | Self::Error(message) => message, + } + } + + pub(crate) fn is_success(&self) -> bool { + matches!(self, Self::Success(_)) + } +} const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?"; const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; const MULTI_AGENT_ENABLE_NO: &str = "Not now"; diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index ba5e533432..c84f0a36f8 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -225,15 +225,25 @@ impl ChatWidget { /// Copy the last agent response (raw markdown) to the system clipboard. pub(crate) fn copy_last_agent_markdown(&mut self) { - self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); + let status = self.copy_last_agent_markdown_for_overlay(); + self.record_copy_status(&status); + self.request_redraw(); } - pub(crate) fn copy_agent_turn_markdown(&mut self, user_turn_count: usize, user_prompt: &str) { + pub(crate) fn copy_last_agent_markdown_for_overlay(&mut self) -> CopyStatus { + self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard) + } + + pub(crate) fn copy_agent_turn_markdown_for_overlay( + &mut self, + user_turn_count: usize, + user_prompt: &str, + ) -> CopyStatus { self.copy_agent_turn_markdown_with( user_turn_count, user_prompt, crate::clipboard_copy::copy_to_clipboard, - ); + ) } pub(crate) fn truncate_agent_copy_history_to_user_turn_count( @@ -248,23 +258,16 @@ impl ChatWidget { pub(super) fn copy_last_agent_markdown_with( &mut self, copy_fn: impl FnOnce(&str) -> Result, String>, - ) { + ) -> CopyStatus { match self.transcript.last_agent_markdown.clone() { - Some(markdown) if !markdown.is_empty() => self.copy_markdown_with_status( - &markdown, - "Copied last message to clipboard", - copy_fn, - ), - _ if self.transcript.copy_history_evicted_by_rollback => { - self.add_to_history(history_cell::new_error_event(format!( - "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." - ))); + Some(markdown) if !markdown.is_empty() => { + self.copy_markdown_result(&markdown, "Copied last message to clipboard", copy_fn) } - _ => self.add_to_history(history_cell::new_error_event( - "No agent response to copy".into(), + _ if self.transcript.copy_history_evicted_by_rollback => CopyStatus::Error(format!( + "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." )), + _ => CopyStatus::Error("No agent response to copy".into()), } - self.request_redraw(); } pub(super) fn copy_agent_turn_markdown_with( @@ -272,43 +275,43 @@ impl ChatWidget { user_turn_count: usize, user_prompt: &str, copy_fn: impl FnOnce(&str) -> Result, String>, - ) { + ) -> CopyStatus { match self .transcript .agent_markdown_for_user_turn(user_turn_count) { Some(markdown) if !markdown.is_empty() => { let markdown = format!("## User\n\n{user_prompt}\n\n## Assistant\n\n{markdown}"); - self.copy_markdown_with_status( - &markdown, - "Copied selected turn to clipboard", - copy_fn, - ); + self.copy_markdown_result(&markdown, "Copied selected turn to clipboard", copy_fn) } - _ => self.add_to_history(history_cell::new_error_event( - "No agent response to copy for selected prompt".into(), - )), + _ => CopyStatus::Error("No agent response to copy for selected prompt".into()), } - self.request_redraw(); } - fn copy_markdown_with_status( + fn copy_markdown_result( &mut self, markdown: &str, success_message: &str, copy_fn: impl FnOnce(&str) -> Result, String>, - ) { + ) -> CopyStatus { match copy_fn(markdown) { Ok(lease) => { self.clipboard_lease = lease; - self.add_to_history(history_cell::new_info_event( - success_message.into(), - /*hint*/ None, - )); + CopyStatus::Success(success_message.into()) + } + Err(error) => CopyStatus::Error(format!("Copy failed: {error}")), + } + } + + fn record_copy_status(&mut self, status: &CopyStatus) { + match status { + CopyStatus::Success(message) => self.add_to_history(history_cell::new_info_event( + message.clone(), + /*hint*/ None, + )), + CopyStatus::Error(message) => { + self.add_to_history(history_cell::new_error_event(message.clone())) } - Err(error) => self.add_to_history(history_cell::new_error_event(format!( - "Copy failed: {error}" - ))), } } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 9a278ccbe1..1df1ef0468 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1443,32 +1443,36 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.transcript.last_agent_markdown = Some("copy me".to_string()); - chat.copy_last_agent_markdown_with(|markdown| { + let status = chat.copy_last_agent_markdown_with(|markdown| { assert_eq!(markdown, "copy me"); Ok(Some(crate::clipboard_copy::ClipboardLease::test())) }); + assert_eq!( + status, + crate::chatwidget::CopyStatus::Success("Copied last message to clipboard".into()) + ); assert!(chat.clipboard_lease.is_some()); let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one success message"); - let rendered = lines_to_single_string(&cells[0]); assert!( - rendered.contains("Copied last message to clipboard"), - "expected success message, got {rendered:?}" + cells.is_empty(), + "expected overlay-style helper not to add history" ); - chat.copy_last_agent_markdown_with(|markdown| { + let status = chat.copy_last_agent_markdown_with(|markdown| { assert_eq!(markdown, "copy me"); Err("blocked".into()) }); + assert_eq!( + status, + crate::chatwidget::CopyStatus::Error("Copy failed: blocked".into()) + ); assert!(chat.clipboard_lease.is_some()); let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one failure message"); - let rendered = lines_to_single_string(&cells[0]); assert!( - rendered.contains("Copy failed: blocked"), - "expected failure message, got {rendered:?}" + cells.is_empty(), + "expected overlay-style helper not to add history" ); } @@ -1479,20 +1483,26 @@ async fn transcript_turn_copy_includes_user_prompt_and_agent_markdown() { chat.transcript .record_agent_markdown("first response".to_string()); - chat.copy_agent_turn_markdown_with(/*user_turn_count*/ 1, "first prompt", |markdown| { - assert_eq!( - markdown, - "## User\n\nfirst prompt\n\n## Assistant\n\nfirst response" - ); - Ok(Some(crate::clipboard_copy::ClipboardLease::test())) - }); + let status = chat.copy_agent_turn_markdown_with( + /*user_turn_count*/ 1, + "first prompt", + |markdown| { + assert_eq!( + markdown, + "## User\n\nfirst prompt\n\n## Assistant\n\nfirst response" + ); + Ok(Some(crate::clipboard_copy::ClipboardLease::test())) + }, + ); + assert_eq!( + status, + crate::chatwidget::CopyStatus::Success("Copied selected turn to clipboard".into()) + ); let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one success message"); - let rendered = lines_to_single_string(&cells[0]); assert!( - rendered.contains("Copied selected turn to clipboard"), - "expected success message, got {rendered:?}" + cells.is_empty(), + "expected overlay-style helper not to add history" ); } diff --git a/codex-rs/tui/src/footer_hints.rs b/codex-rs/tui/src/footer_hints.rs index e2ea93c56c..4bfdee3377 100644 --- a/codex-rs/tui/src/footer_hints.rs +++ b/codex-rs/tui/src/footer_hints.rs @@ -1,4 +1,6 @@ use crate::color::is_light; +use crate::line_truncation::line_width; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::terminal_palette::default_bg; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -7,6 +9,7 @@ use ratatui::style::Style; use ratatui::style::Styled as _; use ratatui::style::Stylize as _; use ratatui::text::Line; +use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use unicode_width::UnicodeWidthStr; @@ -96,6 +99,62 @@ pub(crate) fn render_footer_separator(area: Rect, buf: &mut Buffer, label: Strin } } +pub(crate) fn render_footer_line_with_optional_right( + area: Rect, + buf: &mut Buffer, + left: Line<'static>, + right: Option>, +) { + let Some(right) = right else { + left.render(area, buf); + return; + }; + if area.width == 0 { + return; + } + + let right_width = line_width(&right) as u16; + if right_width > area.width { + truncate_line_with_ellipsis_if_overflow(right, area.width as usize).render(area, buf); + return; + } + + let left_width = line_width(&left) as u16; + let gap = u16::from(left_width > 0 && right_width > 0); + let left_area_width = area.width.saturating_sub(right_width).saturating_sub(gap); + if left_area_width == 0 { + right.render( + Rect { + x: area.x + area.width - right_width, + y: area.y, + width: right_width, + height: 1, + }, + buf, + ); + return; + } + + truncate_line_with_ellipsis_if_overflow(left, left_area_width as usize).render( + Rect { + x: area.x, + y: area.y, + width: left_area_width, + height: 1, + }, + buf, + ); + right.render( + Rect { + x: area.x + area.width - right_width, + y: area.y, + width: right_width, + height: 1, + }, + buf, + ); +} + fn fit_footer_hints( hints: &[FooterHint], mode: FooterHintLabelMode, @@ -181,6 +240,19 @@ fn footer_hints_width(hints: &[&FooterHint], mode: FooterHintLabelMode, gap_widt #[cfg(test)] mod tests { use super::*; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + fn buffer_text(buf: &Buffer, area: Rect) -> String { + let mut out = String::new(); + for y in area.y..area.bottom() { + for x in area.x..area.right() { + let symbol = buf[(x, y)].symbol(); + out.push(symbol.chars().next().unwrap_or(' ')); + } + } + out + } fn line_text(line: Line<'static>) -> String { line.spans @@ -232,4 +304,72 @@ mod tests { assert!(rendered.contains('c')); assert!(!rendered.contains('b')); } + + #[test] + fn footer_line_renders_left_and_right_when_both_fit() { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 1, + ); + let mut buf = Buffer::empty(area); + + render_footer_line_with_optional_right( + area, + &mut buf, + Line::from("left"), + Some(Line::from("right")), + ); + + assert_eq!(buffer_text(&buf, area), "left right"); + } + + #[test] + fn footer_line_truncates_left_when_right_fits() { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 16, /*height*/ 1, + ); + let mut buf = Buffer::empty(area); + + render_footer_line_with_optional_right( + area, + &mut buf, + Line::from("long left status"), + Some(Line::from("ok")), + ); + + assert_eq!(buffer_text(&buf, area), "long left st… ok"); + } + + #[test] + fn footer_line_renders_right_only_when_space_is_tight() { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 5, /*height*/ 1, + ); + let mut buf = Buffer::empty(area); + + render_footer_line_with_optional_right( + area, + &mut buf, + Line::from("left"), + Some(Line::from("right")), + ); + + assert_eq!(buffer_text(&buf, area), "right"); + } + + #[test] + fn footer_line_truncates_right_when_it_overflows_area() { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 4, /*height*/ 1, + ); + let mut buf = Buffer::empty(area); + + render_footer_line_with_optional_right( + area, + &mut buf, + Line::from("left"), + Some(Line::from("status")), + ); + + assert_eq!(buffer_text(&buf, area), "sta…"); + } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 6504748f87..82b1e52505 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -17,10 +17,14 @@ use std::io::Result; use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; use crate::chatwidget::ActiveCellTranscriptKey; +use crate::chatwidget::CopyStatus; use crate::footer_hints::FooterHint; 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; use crate::history_cell::HistoryCell; use crate::history_cell::HistoryRenderMode; @@ -514,11 +518,20 @@ pub(crate) struct TranscriptOverlay { copy_keymap: Vec, toggle_raw_output_keymap: Vec, copy_requested: bool, + footer_status: Option, /// Cache key for the render-only live tail appended after committed cells. live_tail_key: Option, is_done: bool, } +const FOOTER_STATUS_TTL: Duration = Duration::from_secs(2); + +#[derive(Clone)] +struct FooterStatus { + line: Line<'static>, + expires_at: Instant, +} + /// Cache key for the active-cell "live tail" appended to the transcript overlay. /// /// Changing any field implies a different rendered tail. @@ -559,6 +572,7 @@ impl TranscriptOverlay { copy_keymap, toggle_raw_output_keymap, copy_requested: false, + footer_status: None, live_tail_key: None, is_done: false, } @@ -788,6 +802,12 @@ impl TranscriptOverlay { std::mem::take(&mut self.copy_requested) } + pub(crate) fn show_copy_status(&mut self, status: &CopyStatus, tui: &mut tui::Tui) { + self.show_copy_status_at(status, Instant::now()); + tui.frame_requester().schedule_frame(); + tui.frame_requester().schedule_frame_in(FOOTER_STATUS_TTL); + } + pub(crate) fn selected_user_cell(&self) -> Option { self.highlight_cell.filter(|idx| { self.cells @@ -901,6 +921,33 @@ impl TranscriptOverlay { renderable } + fn show_copy_status_at(&mut self, status: &CopyStatus, now: Instant) { + let line = if status.is_success() { + Line::from(status.message().to_string().green()) + } else { + Line::from(status.message().to_string().red()) + }; + self.footer_status = Some(FooterStatus { + line, + expires_at: now + FOOTER_STATUS_TTL, + }); + } + + fn clear_footer_status(&mut self) -> bool { + self.footer_status.take().is_some() + } + + fn clear_expired_footer_status_at(&mut self, now: Instant) -> bool { + if self + .footer_status + .as_ref() + .is_some_and(|status| status.expires_at <= now) + { + return self.clear_footer_status(); + } + false + } + 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); @@ -936,7 +983,14 @@ impl TranscriptOverlay { 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); + render_footer_line_with_optional_right( + line1, + buf, + footer_hint_line_for_row(&navigation_hints, area.width), + self.footer_status + .as_ref() + .map(|status| status.line.clone()), + ); let mut action_hints = Vec::new(); action_hints.push(FooterHint::new( @@ -1002,6 +1056,7 @@ impl TranscriptOverlay { } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { + self.clear_expired_footer_status_at(Instant::now()); 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); @@ -1014,37 +1069,40 @@ impl TranscriptOverlay { impl TranscriptOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { - TuiEvent::Key(key_event) => match key_event { - e if self.view.keymap.close.is_pressed(e) - || self.view.keymap.close_transcript.is_pressed(e) => - { - self.is_done = true; - Ok(()) + TuiEvent::Key(key_event) => { + self.clear_footer_status(); + match key_event { + e if self.view.keymap.close.is_pressed(e) + || self.view.keymap.close_transcript.is_pressed(e) => + { + self.is_done = true; + Ok(()) + } + e if self.view.keymap.previous_user_prompt.is_pressed(e) => { + self.move_prompt_selection(PromptSelectionDirection::Previous); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + Ok(()) + } + e if self.view.keymap.next_user_prompt.is_pressed(e) => { + self.move_prompt_selection(PromptSelectionDirection::Next); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + Ok(()) + } + e if self.toggle_raw_output_keymap.is_pressed(e) => { + self.toggle_render_mode(); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + Ok(()) + } + e if self.copy_keymap.is_pressed(e) => { + self.copy_requested = true; + Ok(()) + } + other => self.view.handle_key_event(tui, other), } - e if self.view.keymap.previous_user_prompt.is_pressed(e) => { - self.move_prompt_selection(PromptSelectionDirection::Previous); - tui.frame_requester() - .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); - Ok(()) - } - e if self.view.keymap.next_user_prompt.is_pressed(e) => { - self.move_prompt_selection(PromptSelectionDirection::Next); - tui.frame_requester() - .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); - Ok(()) - } - e if self.toggle_raw_output_keymap.is_pressed(e) => { - self.toggle_render_mode(); - tui.frame_requester() - .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); - Ok(()) - } - e if self.copy_keymap.is_pressed(e) => { - self.copy_requested = true; - Ok(()) - } - other => self.view.handle_key_event(tui, other), - }, + } TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); @@ -1206,6 +1264,7 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; + use std::time::Instant; use crate::diff_model::FileChange; use crate::exec_cell::CommandOutput; @@ -1214,7 +1273,7 @@ mod tests { use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; use codex_protocol::parse_command::ParsedCommand; - use ratatui::Terminal; + use ratatui::Terminal as RatatuiTerminal; use ratatui::backend::TestBackend; use ratatui::style::Modifier; use ratatui::text::Text; @@ -1339,7 +1398,7 @@ mod tests { lines: vec![Line::from("gamma")], }), ]); - let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + let mut term = RatatuiTerminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); @@ -1381,7 +1440,7 @@ mod tests { |_| Some(vec![HyperlinkLine::from("tail")]), ); - let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + let mut term = RatatuiTerminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); @@ -1548,6 +1607,12 @@ mod tests { out } + fn render_snapshot(overlay: &mut TranscriptOverlay, area: Rect) -> String { + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + buffer_to_text(&buf, area) + } + #[test] fn transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() { let cwd = PathBuf::from("/repo"); @@ -1613,6 +1678,94 @@ mod tests { assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot); } + #[test] + fn transcript_overlay_footer_status_snapshot() { + let mut overlay = transcript_overlay(vec![user_cell("prompt")]); + overlay.show_copy_status_at( + &CopyStatus::Success("Copied selected turn to clipboard".into()), + Instant::now(), + ); + + assert_snapshot!( + "transcript_overlay_footer_status", + render_snapshot( + &mut overlay, + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8 + ), + ) + ); + } + + #[test] + fn transcript_overlay_footer_status_snapshot_narrow() { + let mut overlay = transcript_overlay(vec![user_cell("prompt")]); + overlay.show_copy_status_at( + &CopyStatus::Error("No agent response to copy for selected prompt".into()), + Instant::now(), + ); + + assert_snapshot!( + "transcript_overlay_footer_status_narrow", + render_snapshot( + &mut overlay, + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 28, /*height*/ 8 + ), + ) + ); + } + + #[test] + fn transcript_overlay_footer_status_can_be_cleared_immediately() { + let mut overlay = transcript_overlay(vec![user_cell("prompt")]); + overlay.show_copy_status_at( + &CopyStatus::Success("Copied selected turn to clipboard".into()), + Instant::now(), + ); + assert!(overlay.clear_footer_status()); + + assert!(overlay.footer_status.is_none()); + } + + #[test] + fn transcript_overlay_footer_status_clears_after_expiry() { + let mut overlay = transcript_overlay(vec![user_cell("prompt")]); + overlay.show_copy_status_at( + &CopyStatus::Success("Copied selected turn to clipboard".into()), + Instant::now() - FOOTER_STATUS_TTL, + ); + + let _ = render_snapshot( + &mut overlay, + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 8, + ), + ); + + assert!(overlay.footer_status.is_none()); + } + + #[test] + fn transcript_overlay_footer_status_replaces_previous_message() { + let mut overlay = transcript_overlay(vec![user_cell("prompt")]); + overlay.show_copy_status_at( + &CopyStatus::Error("Copy failed: blocked".into()), + Instant::now(), + ); + overlay.show_copy_status_at( + &CopyStatus::Success("Copied selected turn to clipboard".into()), + Instant::now(), + ); + + let status = overlay.footer_status.as_ref().expect("status").line.clone(); + assert_eq!(status.spans.len(), 1); + assert_eq!( + status.spans[0].content.as_ref(), + "Copied selected turn to clipboard" + ); + } + #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { let mut overlay = transcript_overlay( @@ -1624,7 +1777,7 @@ mod tests { }) .collect(), ); - let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); + let mut term = RatatuiTerminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); @@ -1651,7 +1804,7 @@ mod tests { }) .collect(), ); - let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); + let mut term = RatatuiTerminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); @@ -1725,7 +1878,7 @@ mod tests { vec!["one".into(), "two".into(), "three".into()], "S T A T I C", ); - let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + let mut term = RatatuiTerminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); @@ -1831,7 +1984,7 @@ mod tests { vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()], "S T A T I C", ); - let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term"); + let mut term = RatatuiTerminal::new(TestBackend::new(24, 8)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); 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 new file mode 100644 index 0000000000..119c75508d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/pager_overlay.rs +expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 80, 8),)" +--- + Transcript · 1 prompt · 100% ────────────────────────────────────────────────── + +› prompt + +──────────────────────────────────────────────────────────────────────────────── + ↑/↓ 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 new file mode 100644 index 0000000000..3554c6ae0a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_footer_status_narrow.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/pager_overlay.rs +expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 28, 8),)" +--- + Transcript · 1 prompt · 100 + +› prompt + +──────────────────────────── +No agent response to copy f… + q ctrl + o ⌥ + r