mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
Integrate the interactive transcript into the alternate-screen TUI (#46733)
## What changed - Render transcript history and live output above the composer when `features.transcript_v2` is enabled and alternate-screen rendering is available. Handle scrolling, selection, copying, links, and older-history pagination in the main view. - Add a clickable return-to-latest control with new-activity hints and temporary clipboard status. Let plain Enter return to latest when the composer is empty. - Reflow retained transcript content during drawing and preserve the launch-selected screen mode across configuration reloads and session transitions, including `--no-alt-screen` restrictions. - Enter the alternate screen with its first synchronized frame, and box CLI startup futures to reduce stack usage during session transitions. ## Testing Add regression coverage for transcript layout, input routing, follow controls, clipboard status, and screen-mode persistence. Add a PTY test for the first alternate-screen frame and clean exit, and extend session-transition coverage through the CLI dispatcher. GitOrigin-RevId: 1daf077826c7744b0f57399d10d159923dc87a97
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
//! Share the existing composer gap between transient copy feedback and reading controls.
|
||||
//! Feedback wins while visible; controls release their pointer targets when replaced.
|
||||
|
||||
use super::*;
|
||||
use crate::clipboard_copy::CopyStatus;
|
||||
use crate::footer_hint::first_fitting_line;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
pub(super) struct CopyFeedback {
|
||||
result: Result<CopyStatus, ()>,
|
||||
characters: usize,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
impl TranscriptView {
|
||||
pub(crate) fn show_copy_feedback(
|
||||
&mut self,
|
||||
result: &Result<CopyStatus, String>,
|
||||
characters: usize,
|
||||
) {
|
||||
self.copy_feedback = Some(CopyFeedback {
|
||||
result: result.as_ref().copied().map_err(|_| ()),
|
||||
characters,
|
||||
expires_at: Instant::now() + Duration::from_secs(/*secs*/ 5),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn render_composer_gap(
|
||||
&mut self,
|
||||
area: Option<Rect>,
|
||||
hint: Option<&Line<'static>>,
|
||||
buffer: &mut Buffer,
|
||||
) -> Option<Duration> {
|
||||
if self
|
||||
.copy_feedback
|
||||
.as_ref()
|
||||
.is_some_and(|feedback| feedback.expires_at <= Instant::now())
|
||||
{
|
||||
self.copy_feedback = None;
|
||||
}
|
||||
let Some(area) = area.filter(|area| !area.is_empty()) else {
|
||||
self.render_follow_control(/*area*/ None, buffer);
|
||||
return None;
|
||||
};
|
||||
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 width = line.width().min(usize::from(area.width)) as u16;
|
||||
let target = Rect::new(
|
||||
area.right().saturating_sub(width + 1).max(area.x),
|
||||
area.y,
|
||||
width,
|
||||
/*height*/ 1,
|
||||
);
|
||||
line.render(target, buffer);
|
||||
self.render_follow_control(/*area*/ None, buffer);
|
||||
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))
|
||||
{
|
||||
let width = hint.width() as u16;
|
||||
hint.render(
|
||||
Rect::new(area.right() - width - 1, area.y, width, /*height*/ 1),
|
||||
buffer,
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl CopyFeedback {
|
||||
fn line(&self, width: u16) -> Line<'static> {
|
||||
let characters = self.characters;
|
||||
let labels = match self.result {
|
||||
Ok(CopyStatus::Confirmed) => [
|
||||
format!("Copied {characters} chars to host clipboard"),
|
||||
format!("Copied {characters} chars"),
|
||||
"Copied".into(),
|
||||
],
|
||||
Ok(CopyStatus::Unconfirmed) => [
|
||||
"Copy sent to terminal · paste to verify".into(),
|
||||
"Copy sent · verify paste".into(),
|
||||
"Copy unconfirmed".into(),
|
||||
],
|
||||
Err(()) => [
|
||||
"Copy failed · /export saves chat".into(),
|
||||
"Copy failed · /export".into(),
|
||||
"Copy failed".into(),
|
||||
],
|
||||
};
|
||||
let line = first_fitting_line(labels.map(Line::from), width);
|
||||
if self.result.is_err() {
|
||||
line.red()
|
||||
} else {
|
||||
line.fg(crate::style::accent_color())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "composer_gap_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Copy outcomes remain truthful at narrow widths and release stale navigation hit targets.
|
||||
|
||||
use super::*;
|
||||
use crate::transcript_view::tests::cell;
|
||||
use crate::transcript_view::tests::render;
|
||||
use crate::transcript_view::tests::text;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery() {
|
||||
let mut snapshots = Vec::new();
|
||||
for result in [
|
||||
Ok(CopyStatus::Confirmed),
|
||||
Ok(CopyStatus::Unconfirmed),
|
||||
Err("unavailable".to_owned()),
|
||||
] {
|
||||
for width in [80, 32, 18] {
|
||||
let mut view = TranscriptView::default();
|
||||
view.show_copy_feedback(&result, /*characters*/ 24);
|
||||
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()
|
||||
);
|
||||
assert_eq!(buffer[(width - 1, 0)].symbol(), " ");
|
||||
snapshots.push(format!("{result:?}, {width} columns\n{}", text(&buffer)));
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!(snapshots.join("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_releases_navigation_targets_and_expiry_restores_them() {
|
||||
let cells = vec![cell("one\ntwo\nthree\nfour\nfive")];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells, /*width*/ 60, /*height*/ 3);
|
||||
view.scroll(&cells, /*rows*/ -2);
|
||||
let area = Rect::new(
|
||||
/*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!(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);
|
||||
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),
|
||||
None
|
||||
);
|
||||
assert!(text(&buffer).contains("Back to bottom"));
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Return-to-latest affordance in the owned transcript's existing composer gap.
|
||||
//! Rendering owns the hit rectangle; hidden controls never intercept pointer input.
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use ratatui::layout::Position as ScreenPosition;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct FollowControl {
|
||||
area: Option<Rect>,
|
||||
pointer: Option<ScreenPosition>,
|
||||
pressed: bool,
|
||||
}
|
||||
|
||||
impl TranscriptView {
|
||||
pub(crate) fn render_follow_control(&mut self, area: Option<Rect>, buf: &mut Buffer) {
|
||||
let area = area.filter(|area| {
|
||||
!area.is_empty()
|
||||
&& self.can_return_to_latest()
|
||||
&& self.highlight.is_none()
|
||||
&& self.selection.is_none()
|
||||
});
|
||||
let Some(area) = area 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 {
|
||||
self.follow_control = FollowControl::default();
|
||||
return;
|
||||
};
|
||||
let width = label.width() as u16;
|
||||
let target = Rect::new(
|
||||
area.x + (area.width - width) / 2,
|
||||
area.y,
|
||||
width,
|
||||
/*height*/ 1,
|
||||
);
|
||||
let hovered = self
|
||||
.follow_control
|
||||
.pointer
|
||||
.is_some_and(|point| target.contains(point));
|
||||
self.follow_control.area = Some(target);
|
||||
let style =
|
||||
crate::style::user_message_style().fg(crate::style::user_message_accent_color());
|
||||
let style = if hovered {
|
||||
style.reversed().bold()
|
||||
} else {
|
||||
style
|
||||
};
|
||||
Paragraph::new(label).style(style).render(target, buf);
|
||||
}
|
||||
|
||||
pub(super) fn handle_follow_control_mouse(&mut self, event: MouseEvent) -> Option<ViewAction> {
|
||||
let control = &mut self.follow_control;
|
||||
let area = control.area?;
|
||||
let point = ScreenPosition::new(event.column, event.row);
|
||||
let was_hovered = control.pointer.is_some_and(|point| area.contains(point));
|
||||
let inside = area.contains(point);
|
||||
control.pointer = Some(point);
|
||||
match event.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) if inside => {
|
||||
control.pressed = true;
|
||||
Some(ViewAction::Changed)
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) if control.pressed => {
|
||||
control.pressed = false;
|
||||
if inside {
|
||||
self.jump_to_latest();
|
||||
}
|
||||
Some(ViewAction::Changed)
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) if control.pressed => Some(ViewAction::Changed),
|
||||
MouseEventKind::Moved if inside != was_hovered => Some(ViewAction::Changed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "follow_control_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Follow gestures and the visible return control share one reading-state contract.
|
||||
|
||||
use super::*;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn cell(text: &str) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(PlainHistoryCell::new(
|
||||
text.lines().map(|line| line.to_owned().into()).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn paint(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>], width: u16) -> Buffer {
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 4);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(
|
||||
Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 3),
|
||||
&mut buf,
|
||||
cells,
|
||||
);
|
||||
view.render_follow_control(
|
||||
Some(Rect::new(
|
||||
/*x*/ 0, /*y*/ 3, width, /*height*/ 1,
|
||||
)),
|
||||
&mut buf,
|
||||
);
|
||||
buf
|
||||
}
|
||||
|
||||
fn mouse(
|
||||
view: &mut TranscriptView,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
kind: MouseEventKind,
|
||||
column: u16,
|
||||
row: u16,
|
||||
) {
|
||||
view.handle_mouse(
|
||||
MouseEvent {
|
||||
kind,
|
||||
column,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
cells,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_click_restores_following_but_not_an_existing_pause() {
|
||||
for following in [true, false] {
|
||||
let mut cells = vec![cell("one\ntwo\nthree\nfour")];
|
||||
let mut view = TranscriptView::default();
|
||||
paint(&mut view, &cells, /*width*/ 40);
|
||||
if !following {
|
||||
view.scroll(&cells, /*rows*/ -1);
|
||||
paint(&mut view, &cells, /*width*/ 40);
|
||||
}
|
||||
mouse(
|
||||
&mut view,
|
||||
&cells,
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
/*column*/ 0,
|
||||
/*row*/ 0,
|
||||
);
|
||||
cells.push(cell("arrived during click"));
|
||||
paint(&mut view, &cells, /*width*/ 40);
|
||||
mouse(
|
||||
&mut view,
|
||||
&cells,
|
||||
MouseEventKind::Up(MouseButton::Left),
|
||||
/*column*/ 0,
|
||||
/*row*/ 0,
|
||||
);
|
||||
assert_eq!(
|
||||
(view.is_following(), view.selected_text(&cells)),
|
||||
(following, None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_scroll_keeps_following_and_real_scroll_pauses() {
|
||||
let cells = vec![cell("one\ntwo")];
|
||||
let mut view = TranscriptView::default();
|
||||
paint(&mut view, &cells, /*width*/ 40);
|
||||
view.scroll(&cells, /*rows*/ -3);
|
||||
assert!(view.is_following());
|
||||
// Scrolling toward an unloaded page is meaningful even when the loaded rows fit.
|
||||
view.history = TranscriptHistoryState::Partial;
|
||||
view.scroll(&cells, /*rows*/ -3);
|
||||
assert!(!view.is_following());
|
||||
assert!(view.needs_history(&cells));
|
||||
view.history = TranscriptHistoryState::Complete;
|
||||
view.jump_to_latest();
|
||||
let cells = vec![cell("one\ntwo\nthree\nfour")];
|
||||
paint(&mut view, &cells, /*width*/ 40);
|
||||
view.scroll(&cells, /*rows*/ -3);
|
||||
assert!(view.can_return_to_latest());
|
||||
view.scroll(&cells, /*rows*/ 3);
|
||||
assert!(view.is_following());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_live_selection_retains_the_displayed_revision() {
|
||||
let mut view = TranscriptView::default();
|
||||
let cells = Vec::new();
|
||||
view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| {
|
||||
Some(vec![HyperlinkLine::from("original live text")])
|
||||
});
|
||||
paint(&mut view, &cells, /*width*/ 40);
|
||||
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
|
||||
let selected = view.selected_text(&cells).unwrap();
|
||||
view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| {
|
||||
Some(vec![HyperlinkLine::from("changed live text")])
|
||||
});
|
||||
view.copy_selected_text_with(&cells, &selected, |_| {
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = paint(&mut view, &cells, /*width*/ 40);
|
||||
assert!(!view.is_following());
|
||||
assert!(format!("{buffer:?}").contains("original live text"));
|
||||
view.jump_to_latest();
|
||||
assert!(format!("{:?}", paint(&mut view, &cells, /*width*/ 40)).contains("changed live text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_tracks_pause_activity_hover_resize_and_click() {
|
||||
let mut cells = vec![cell("one\ntwo\nthree\nfour\nfive")];
|
||||
let mut view = TranscriptView::default();
|
||||
paint(&mut view, &cells, /*width*/ 80);
|
||||
assert!(view.follow_control.area.is_none());
|
||||
view.scroll(&cells, /*rows*/ -2);
|
||||
let mut snapshots = vec![format!(
|
||||
"paused\n{:?}",
|
||||
paint(&mut view, &cells, /*width*/ 80)
|
||||
)];
|
||||
cells.push(cell("new output"));
|
||||
for width in [80, 20] {
|
||||
snapshots.push(format!(
|
||||
"activity at {width}\n{:?}",
|
||||
paint(&mut view, &cells, width)
|
||||
));
|
||||
if width == 80 {
|
||||
let area = view.follow_control.area.unwrap();
|
||||
mouse(&mut view, &cells, MouseEventKind::Moved, area.x, area.y);
|
||||
snapshots.push(format!("hover\n{:?}", paint(&mut view, &cells, width)));
|
||||
}
|
||||
}
|
||||
let area = view.follow_control.area.unwrap();
|
||||
mouse(
|
||||
&mut view,
|
||||
&cells,
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
area.x,
|
||||
area.y,
|
||||
);
|
||||
mouse(
|
||||
&mut view,
|
||||
&cells,
|
||||
MouseEventKind::Up(MouseButton::Left),
|
||||
area.x,
|
||||
area.y,
|
||||
);
|
||||
assert!(view.is_following());
|
||||
paint(&mut view, &cells, /*width*/ 20);
|
||||
assert!(view.follow_control.area.is_none());
|
||||
insta::assert_snapshot!("follow_control", snapshots.join("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_control_does_not_consume_clicks_and_drag_out_cancels() {
|
||||
let cells = vec![cell("one\ntwo\nthree\nfour")];
|
||||
let mut view = TranscriptView::default();
|
||||
paint(&mut view, &cells, /*width*/ 40);
|
||||
view.scroll(&cells, /*rows*/ -1);
|
||||
paint(&mut view, &cells, /*width*/ 40);
|
||||
let area = view.follow_control.area.unwrap();
|
||||
mouse(
|
||||
&mut view,
|
||||
&cells,
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
area.x,
|
||||
area.y,
|
||||
);
|
||||
mouse(
|
||||
&mut view,
|
||||
&cells,
|
||||
MouseEventKind::Up(MouseButton::Left),
|
||||
/*column*/ 0,
|
||||
/*row*/ 0,
|
||||
);
|
||||
assert!(!view.is_following());
|
||||
let mut buf = Buffer::empty(Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4,
|
||||
));
|
||||
view.render_follow_control(/*area*/ None, &mut buf);
|
||||
assert!(
|
||||
view.handle_follow_control_mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: area.x,
|
||||
row: area.y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_changes_backfill_after_empty_click_but_preserve_deliberate_pause() {
|
||||
let rows = (0..60)
|
||||
.map(|row| format!("row {row:02}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let cells = vec![cell(&rows)];
|
||||
for pause in ["none", "scroll", "selection"] {
|
||||
let mut view = TranscriptView::default();
|
||||
let short = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 120, /*height*/ 24,
|
||||
);
|
||||
view.render(short, &mut Buffer::empty(short), &cells);
|
||||
if pause == "scroll" {
|
||||
view.scroll(&cells, /*rows*/ -6);
|
||||
view.render(short, &mut Buffer::empty(short), &cells);
|
||||
}
|
||||
if pause == "selection" {
|
||||
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
|
||||
} else {
|
||||
mouse(
|
||||
&mut view,
|
||||
&cells,
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
/*column*/ 0,
|
||||
/*row*/ 0,
|
||||
);
|
||||
mouse(
|
||||
&mut view,
|
||||
&cells,
|
||||
MouseEventKind::Up(MouseButton::Left),
|
||||
/*column*/ 0,
|
||||
/*row*/ 0,
|
||||
);
|
||||
}
|
||||
let before = (view.visible[0].key, view.visible[0].row);
|
||||
let selected = view.selected_text(&cells);
|
||||
for height in [50, 24, 50] {
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 120, height);
|
||||
view.render(area, &mut Buffer::empty(area), &cells);
|
||||
assert_eq!(view.is_following(), pause == "none");
|
||||
assert_eq!(view.selected_text(&cells), selected);
|
||||
if pause == "none" {
|
||||
assert_eq!(view.visible.len(), usize::from(height));
|
||||
assert_eq!(view.visible.last().unwrap().row, 59);
|
||||
} else {
|
||||
assert_eq!((view.visible[0].key, view.visible[0].row), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ impl TranscriptView {
|
||||
)
|
||||
.into(),
|
||||
cursor_column: None,
|
||||
is_interactive: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ impl TranscriptView {
|
||||
)
|
||||
.into(),
|
||||
cursor_column: None,
|
||||
is_interactive: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,6 +138,9 @@ impl TranscriptView {
|
||||
event: MouseEvent,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
) -> Option<ViewAction> {
|
||||
if let Some(action) = self.handle_follow_control_mouse(event) {
|
||||
return Some(action);
|
||||
}
|
||||
let inside = self
|
||||
.area
|
||||
.contains(ScreenPosition::new(event.column, event.row));
|
||||
|
||||
@@ -55,7 +55,13 @@ impl TranscriptView {
|
||||
range: Range<usize>,
|
||||
replacement: &Arc<dyn HistoryCell>,
|
||||
) {
|
||||
if range.is_empty() || self.snapshot().is_some() {
|
||||
if range.is_empty() {
|
||||
return;
|
||||
}
|
||||
if range.end == cells.len() && self.last_tail == cells.last().map(EntryKey::cell) {
|
||||
self.last_tail = Some(EntryKey::cell(replacement));
|
||||
}
|
||||
if self.snapshot().is_some() {
|
||||
return;
|
||||
}
|
||||
let reading = match self.position {
|
||||
|
||||
@@ -214,6 +214,7 @@ impl TranscriptView {
|
||||
|
||||
/// End the pointer gesture without discarding selected text when input ownership changes.
|
||||
pub(crate) fn end_drag(&mut self) {
|
||||
self.follow_control = Default::default();
|
||||
if !self.has_selection_range()
|
||||
&& self
|
||||
.selection
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
---
|
||||
source: tui/src/transcript_view/composer_gap_tests.rs
|
||||
assertion_line: 30
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
Ok(Confirmed), 80 columns
|
||||
Copied 24 chars to host clipboard
|
||||
|
||||
Ok(Confirmed), 32 columns
|
||||
Copied 24 chars
|
||||
|
||||
Ok(Confirmed), 18 columns
|
||||
Copied 24 chars
|
||||
|
||||
Ok(Unconfirmed), 80 columns
|
||||
Copy sent to terminal · paste to verify
|
||||
|
||||
Ok(Unconfirmed), 32 columns
|
||||
Copy sent · verify paste
|
||||
|
||||
Ok(Unconfirmed), 18 columns
|
||||
Copy unconfirmed
|
||||
|
||||
Err("unavailable"), 80 columns
|
||||
Copy failed · /export saves chat
|
||||
|
||||
Err("unavailable"), 32 columns
|
||||
Copy failed · /export
|
||||
|
||||
Err("unavailable"), 18 columns
|
||||
Copy failed
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
---
|
||||
source: tui/src/transcript_view/follow_control_tests.rs
|
||||
assertion_line: 173
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
paused
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 80, height: 4 },
|
||||
content: [
|
||||
"one ",
|
||||
"two ",
|
||||
"three ",
|
||||
" ↓ Back to bottom · esc ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
|
||||
activity at 80
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 80, height: 4 },
|
||||
content: [
|
||||
"one ",
|
||||
"two ",
|
||||
"three ",
|
||||
" New activity · ↓ Back to bottom · esc ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
|
||||
hover
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 80, height: 4 },
|
||||
content: [
|
||||
"one ",
|
||||
"two ",
|
||||
"three ",
|
||||
" New activity · ↓ Back to bottom · esc ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 20, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED,
|
||||
x: 59, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
|
||||
activity at 20
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 20, height: 4 },
|
||||
content: [
|
||||
"one ",
|
||||
"two ",
|
||||
"three ",
|
||||
" New · ↓ Bottom ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user