feat(tui): show transcript copy status in footer

This commit is contained in:
Felipe Coury
2026-05-18 15:50:09 -03:00
parent ff19a5286d
commit e5e2425b71
8 changed files with 453 additions and 101 deletions

View File

@@ -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::<crate::history_cell::UserHistoryCell>()
}) 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.

View File

@@ -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";

View File

@@ -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<Option<crate::clipboard_copy::ClipboardLease>, 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<Option<crate::clipboard_copy::ClipboardLease>, 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<Option<crate::clipboard_copy::ClipboardLease>, 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}"
))),
}
}

View File

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

View File

@@ -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<Line<'static>>,
) {
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…");
}
}

View File

@@ -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<KeyBinding>,
toggle_raw_output_keymap: Vec<KeyBinding>,
copy_requested: bool,
footer_status: Option<FooterStatus>,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
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<usize> {
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());

View File

@@ -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

View File

@@ -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