Add keyboard copy selection to the owned transcript (#50434)

## What changed

- Make `/copy` select the latest completed response directly in the owned transcript. Navigate user messages, code blocks, and blockquotes with arrow keys or `j`/`k`, use `g`/`G` to jump to the ends, and press Enter to copy or Escape to close.
- Highlight selected blocks and keep surrounding context visible during navigation and resizing. Preserve source payloads for code and quotes, including rendered Mermaid diagrams and fenced Markdown tables.
- Pause queued submissions until copy mode closes, and report clipboard results in the transcript footer.

## Testing

Add tests for copy routing, keyboard navigation, highlighting and resize behavior, source whitespace and line endings, clipboard completion, and queued-input resumption.

GitOrigin-RevId: bd4f58ec17568f95d0e91014e5ddba33ca8bd57c
This commit is contained in:
Benjamin Carlsson
2026-10-02 21:42:23 +00:00
committed by copyberry
parent 66561d0301
commit c536ffcb18
38 changed files with 1712 additions and 65 deletions
+19
View File
@@ -8,6 +8,7 @@
mod activity;
mod bookmark;
mod composer_gap;
mod copy_mode;
mod disclosure;
mod follow_control;
mod footer;
@@ -103,6 +104,7 @@ pub(crate) struct TranscriptView {
suppressed_prompt_header: Option<prompt_header::SuppressedHeader>,
visible: Vec<VisibleRow>,
selection: Option<Selection>,
copy_mode: Option<copy_mode::CopyMode>,
held_reading: Option<ViewSnapshot>,
search: Search,
detailed: bool,
@@ -138,6 +140,7 @@ impl Default for TranscriptView {
suppressed_prompt_header: None,
visible: Vec::new(),
selection: None,
copy_mode: None,
held_reading: None,
search: Search::default(),
detailed: false,
@@ -177,15 +180,21 @@ impl TranscriptView {
let cells = snapshot.as_deref().unwrap_or(cells);
let suppressed_prompt_header = self.suppressed_prompt_header.take();
Clear.render(area, buf);
let previous_height = self.area.height;
let resized = self.area.width != area.width || self.area.bottom() != area.bottom();
self.prepare_width(area.width);
self.area = area;
self.normalize_selection(cells);
if resized {
self.reveal_copy_context(cells);
}
self.visible.clear();
if area.is_empty() {
self.tail_visible = false;
return;
}
let initial_start = self.start(cells);
let initial_position = self.position;
let mut start = initial_start;
let mut body = area;
let header_position = prompt_header::SuppressedHeader {
@@ -200,7 +209,12 @@ impl TranscriptView {
self.suppressed_prompt_header = Some(header_position);
} else if area.height >= 4 && prompt_header::line(cells, start.0, area.width).is_some() {
body = Rect::new(area.x, area.y + 1, area.width, area.height - 1);
let resized = resized || previous_height != body.height;
self.area = body;
// Copy targets must also leave context after the prompt reserves its row.
if resized {
self.reveal_copy_context(cells);
}
// Following may advance into the next turn after reserving the header row.
start = self.start(cells);
if let Some(header) = prompt_header::line(cells, start.0, area.width) {
@@ -209,6 +223,9 @@ impl TranscriptView {
body = area;
self.area = area;
start = initial_start;
if resized && self.copy_mode.is_some() {
self.position = initial_position;
}
self.suppressed_prompt_header = Some(header_position);
}
}
@@ -336,6 +353,7 @@ impl TranscriptView {
return;
}
self.selection = None;
self.copy_mode = None;
self.release_live_reading();
self.cache.clear();
self.suppressed_prompt_header = None;
@@ -381,6 +399,7 @@ impl TranscriptView {
self.cancel_beginning();
self.position = Position::Latest;
self.selection = None;
self.copy_mode = None;
self.release_live_reading();
self.unseen_activity = false;
self.disclosure.focused = None;