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:
Eric Traut
2026-09-20 00:06:00 +00:00
committed by copyberry
parent d7d9f2b9b5
commit f1feda2299
61 changed files with 2791 additions and 246 deletions
@@ -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
@@ -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
@@ -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,
]
}