mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
feat(tui): show transcript copy status in footer
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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…");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user