Compare commits

...

1 Commits

Author SHA1 Message Date
Felipe Coury
7900a092aa feat(tui): add semantic copy picker
Add a full-screen copy picker with semantic targets for the latest
response, code blocks, recent commands, and command output. The picker
previews the selected content directly so users can verify exactly what
will be copied.

Wire mouse capture only while the picker is active and keep existing
`/copy` behavior unchanged.
2026-04-20 19:57:53 -03:00
21 changed files with 1043 additions and 19 deletions

View File

@@ -4324,6 +4324,7 @@ impl App {
let pasted = pasted.replace("\r", "\n");
self.chat_widget.handle_paste(pasted);
}
TuiEvent::Mouse(_) => {}
TuiEvent::Draw => {
if self.backtrack_render_pending {
self.backtrack_render_pending = false;
@@ -4555,6 +4556,9 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::OpenCopyPicker => {
self.open_copy_picker(tui);
}
AppEvent::ResumeSessionByIdOrName(id_or_name) => {
match crate::lookup_session_target_with_app_server(
app_server,

View File

@@ -30,6 +30,7 @@ use std::sync::Arc;
use crate::app::App;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::copy_target::build_copy_targets;
#[cfg(test)]
use crate::history_cell::AgentMessageCell;
use crate::history_cell::SessionInfoCell;
@@ -234,7 +235,26 @@ impl App {
tui.frame_requester().schedule_frame();
}
/// Close transcript overlay and restore normal UI.
/// Open the semantic copy picker (enters alternate screen and enables mouse capture).
pub(crate) fn open_copy_picker(&mut self, tui: &mut tui::Tui) {
let targets = build_copy_targets(
self.chat_widget.last_agent_markdown_text(),
&self.transcript_cells,
);
if targets.is_empty() {
self.chat_widget
.add_error_message("No recent content to copy".to_string());
tui.frame_requester().schedule_frame();
return;
}
let _ = tui.enter_alt_screen();
let _ = tui.enable_mouse_capture();
self.overlay = Some(Overlay::new_copy_picker(targets));
tui.frame_requester().schedule_frame();
}
/// Close the active alternate-screen overlay and restore normal UI.
pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
let _ = tui.leave_alt_screen();
let was_backtrack = self.backtrack.overlay_preview_active;
@@ -392,8 +412,15 @@ impl App {
if let Some(overlay) = &mut self.overlay {
overlay.handle_event(tui, event)?;
let pending_copy = overlay.take_pending_copy();
if overlay.is_done() {
self.close_transcript_overlay(tui);
if let Some(target) = pending_copy {
self.chat_widget.copy_text_to_clipboard(
&target.content,
format!("Copied {} to clipboard", target.title),
);
}
tui.frame_requester().schedule_frame();
}
}

View File

@@ -134,6 +134,9 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session.
OpenResumePicker,
/// Open the semantic copy picker in the alternate screen.
OpenCopyPicker,
/// Resume a thread by UUID or thread name inside the running TUI session.
ResumeSessionByIdOrName(String),

View File

@@ -5258,18 +5258,11 @@ impl ChatWidget {
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) {
match self.last_agent_markdown.clone() {
Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) {
Ok(lease) => {
self.clipboard_lease = lease;
self.add_to_history(history_cell::new_info_event(
"Copied last message to clipboard".into(),
/*hint*/ None,
));
}
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
"Copy failed: {error}"
))),
},
Some(markdown) if !markdown.is_empty() => self.copy_text_to_clipboard_with(
&markdown,
"Copied last message to clipboard",
copy_fn,
),
_ => self.add_to_history(history_cell::new_error_event(
"No agent response to copy".into(),
)),
@@ -5277,11 +5270,39 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
self.last_agent_markdown.as_deref()
}
pub(crate) fn copy_text_to_clipboard(&mut self, text: &str, success_message: String) {
self.copy_text_to_clipboard_with(
text,
success_message,
crate::clipboard_copy::copy_to_clipboard,
);
self.request_redraw();
}
fn copy_text_to_clipboard_with(
&mut self,
text: &str,
success_message: impl Into<String>,
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) {
match copy_fn(text) {
Ok(lease) => {
self.clipboard_lease = lease;
self.add_to_history(history_cell::new_info_event(
success_message.into(),
/*hint*/ None,
));
}
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
"Copy failed: {error}"
))),
}
}
fn show_rename_prompt(&mut self) {
let tx = self.app_event_tx.clone();
let existing_name = self.thread_name.as_deref().filter(|name| !name.is_empty());

View File

@@ -243,6 +243,9 @@ impl ChatWidget {
SlashCommand::Copy => {
self.copy_last_agent_markdown();
}
SlashCommand::CopyPicker => {
self.app_event_tx.send(AppEvent::OpenCopyPicker);
}
SlashCommand::Diff => {
self.add_diff_in_progress();
let tx = self.app_event_tx.clone();

View File

@@ -325,6 +325,15 @@ async fn slash_copy_reports_when_no_agent_response_exists() {
);
}
#[tokio::test]
async fn slash_copy_picker_requests_picker_overlay() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::CopyPicker);
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenCopyPicker));
}
#[tokio::test]
async fn ctrl_o_copy_reports_when_no_agent_response_exists() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -0,0 +1,530 @@
use std::io::Result;
use crate::copy_target::CopyTarget;
use crate::copy_target::CopyTargetKind;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::tui;
use crate::tui::TuiEvent;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use crossterm::event::KeyCode;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
const TARGET_BAR_HEIGHT: u16 = 1;
const FOOTER_HEIGHT: u16 = 2;
const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up);
const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down);
const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left);
const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right);
const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k'));
const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j'));
const KEY_H: KeyBinding = key_hint::plain(KeyCode::Char('h'));
const KEY_L: KeyBinding = key_hint::plain(KeyCode::Char('l'));
const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp);
const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown);
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q'));
const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc);
const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter);
const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c'));
#[derive(Clone, Copy)]
struct CopyPickerAreas {
target_bar: Rect,
preview: Rect,
footer: Rect,
}
pub(crate) struct CopyPickerOverlay {
targets: Vec<CopyTarget>,
selected: usize,
target_scroll_offset: usize,
preview_scroll_offset: usize,
pending_copy: Option<CopyTarget>,
is_done: bool,
}
impl CopyPickerOverlay {
pub(crate) fn new(targets: Vec<CopyTarget>) -> Self {
Self {
targets,
selected: 0,
target_scroll_offset: 0,
preview_scroll_offset: 0,
pending_copy: None,
is_done: false,
}
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => {
self.handle_key_event(tui, key_event);
Ok(())
}
TuiEvent::Mouse(mouse_event) => {
self.handle_mouse_event(tui, mouse_event);
Ok(())
}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {
self.render(frame.area(), frame.buffer);
})?;
Ok(())
}
TuiEvent::Paste(_) => Ok(()),
}
}
pub(crate) fn is_done(&self) -> bool {
self.is_done
}
pub(crate) fn take_pending_copy(&mut self) -> Option<CopyTarget> {
self.pending_copy.take()
}
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: crossterm::event::KeyEvent) {
let areas = self.areas(tui.terminal.viewport_area);
let line_count = self.preview_lines_for_width(areas.preview.width).len();
let page = areas.preview.height.max(1) as usize;
match key_event {
e if KEY_Q.is_press(e) || KEY_ESC.is_press(e) || KEY_CTRL_C.is_press(e) => {
self.is_done = true;
}
e if KEY_ENTER.is_press(e) => {
self.copy_selected();
}
e if KEY_LEFT.is_press(e) || KEY_H.is_press(e) => {
self.select_target(self.selected.saturating_sub(1), areas.target_bar.width);
}
e if KEY_RIGHT.is_press(e) || KEY_L.is_press(e) => {
self.select_target(
(self.selected + 1).min(self.targets.len().saturating_sub(1)),
areas.target_bar.width,
);
}
e if KEY_UP.is_press(e) || KEY_K.is_press(e) => {
self.scroll_preview_up(1);
}
e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => {
self.scroll_preview_down(1, line_count, page);
}
e if KEY_PAGE_UP.is_press(e) => {
self.scroll_preview_up(page);
}
e if KEY_PAGE_DOWN.is_press(e) => {
self.scroll_preview_down(page, line_count, page);
}
e if KEY_HOME.is_press(e) => {
self.preview_scroll_offset = 0;
}
e if KEY_END.is_press(e) => {
self.preview_scroll_offset = max_preview_scroll(line_count, page);
}
_ => return,
}
tui.frame_requester()
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
}
fn handle_mouse_event(&mut self, tui: &mut tui::Tui, mouse_event: MouseEvent) {
let areas = self.areas(tui.terminal.viewport_area);
let line_count = self.preview_lines_for_width(areas.preview.width).len();
let changed = self.handle_mouse_event_in_area(areas, line_count, mouse_event);
if changed {
tui.frame_requester()
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
}
}
fn handle_mouse_event_in_area(
&mut self,
areas: CopyPickerAreas,
line_count: usize,
mouse_event: MouseEvent,
) -> bool {
let page = areas.preview.height.max(1) as usize;
match mouse_event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some(index) =
self.target_index_at(areas.target_bar, mouse_event.column, mouse_event.row)
{
self.select_target(index, areas.target_bar.width);
return true;
}
if point_in_rect(areas.preview, mouse_event.column, mouse_event.row) {
self.copy_selected();
return true;
}
}
MouseEventKind::ScrollUp => {
self.scroll_preview_up(1);
return true;
}
MouseEventKind::ScrollDown => {
self.scroll_preview_down(1, line_count, page);
return true;
}
_ => {}
}
false
}
fn select_target(&mut self, index: usize, target_bar_width: u16) {
if self.targets.is_empty() {
return;
}
self.selected = index.min(self.targets.len() - 1);
self.preview_scroll_offset = 0;
self.ensure_selected_target_visible(target_bar_width);
}
fn scroll_preview_up(&mut self, amount: usize) {
self.preview_scroll_offset = self.preview_scroll_offset.saturating_sub(amount);
}
fn scroll_preview_down(&mut self, amount: usize, line_count: usize, page: usize) {
self.preview_scroll_offset =
(self.preview_scroll_offset + amount).min(max_preview_scroll(line_count, page));
}
fn copy_selected(&mut self) {
if let Some(target) = self.targets.get(self.selected) {
self.pending_copy = Some(target.clone());
self.is_done = true;
}
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let areas = self.areas(area);
self.ensure_selected_target_visible(areas.target_bar.width);
self.render_target_bar(areas.target_bar, buf);
self.render_preview(areas.preview, buf);
self.render_footer(areas.footer, buf);
}
fn render_target_bar(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let selected_style = Style::default().add_modifier(Modifier::REVERSED);
let mut spans = Vec::new();
let mut width = 0usize;
for idx in self.target_scroll_offset..self.targets.len() {
let label = self.target_tab_label(idx);
let label_width = UnicodeWidthStr::width(label.as_str());
let style = if idx == self.selected {
selected_style
} else {
Style::default().dim()
};
spans.push(Span::styled(label, style));
width = width.saturating_add(label_width);
if width >= area.width as usize {
break;
}
}
Paragraph::new(Line::from(spans)).render(area, buf);
}
fn render_preview(&mut self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let lines = self.preview_lines_for_width(area.width);
let page = area.height.max(1) as usize;
self.preview_scroll_offset = self
.preview_scroll_offset
.min(max_preview_scroll(lines.len(), page));
let end = (self.preview_scroll_offset + page).min(lines.len());
let visible = lines[self.preview_scroll_offset..end].to_vec();
Paragraph::new(visible).render(area, buf);
}
fn render_footer(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
if area.height == 1 {
render_key_hints(
area,
buf,
&[
(&[KEY_LEFT, KEY_RIGHT, KEY_H, KEY_L], "target"),
(&[KEY_UP, KEY_DOWN, KEY_K, KEY_J], "scroll"),
(&[KEY_ENTER], "copy"),
(&[KEY_Q, KEY_ESC], "close"),
],
);
return;
}
render_key_hints(
Rect::new(area.x, area.y, area.width, 1),
buf,
&[
(&[KEY_LEFT, KEY_RIGHT, KEY_H, KEY_L], "target"),
(&[KEY_UP, KEY_DOWN, KEY_K, KEY_J], "scroll"),
(&[KEY_PAGE_UP, KEY_PAGE_DOWN], "page"),
],
);
render_key_hints(
Rect::new(area.x, area.y + 1, area.width, 1),
buf,
&[(&[KEY_ENTER], "copy"), (&[KEY_Q, KEY_ESC], "close")],
);
}
fn preview_lines_for_width(&self, width: u16) -> Vec<Line<'static>> {
let Some(target) = self.targets.get(self.selected) else {
return Vec::new();
};
let options = RtOptions::new(width.max(1) as usize);
word_wrap_lines(target.content.split('\n'), options)
}
fn areas(&self, area: Rect) -> CopyPickerAreas {
let target_bar_height = area.height.min(TARGET_BAR_HEIGHT);
let footer_height = area
.height
.saturating_sub(target_bar_height)
.min(FOOTER_HEIGHT);
let preview_height = area
.height
.saturating_sub(target_bar_height)
.saturating_sub(footer_height);
CopyPickerAreas {
target_bar: Rect::new(area.x, area.y, area.width, target_bar_height),
preview: Rect::new(
area.x,
area.y.saturating_add(target_bar_height),
area.width,
preview_height,
),
footer: Rect::new(
area.x,
area.bottom().saturating_sub(footer_height),
area.width,
footer_height,
),
}
}
fn ensure_selected_target_visible(&mut self, target_bar_width: u16) {
if self.targets.is_empty() || target_bar_width == 0 {
return;
}
if self.selected < self.target_scroll_offset {
self.target_scroll_offset = self.selected;
}
while self.target_scroll_offset < self.selected
&& !self.selected_target_starts_in_bar(target_bar_width)
{
self.target_scroll_offset += 1;
}
}
fn selected_target_starts_in_bar(&self, target_bar_width: u16) -> bool {
let mut used = 0usize;
let target_bar_width = target_bar_width as usize;
for idx in self.target_scroll_offset..self.targets.len() {
if idx == self.selected {
return used < target_bar_width;
}
used = used.saturating_add(self.target_tab_width(idx));
if used >= target_bar_width {
return false;
}
}
false
}
fn target_index_at(&self, area: Rect, column: u16, row: u16) -> Option<usize> {
if area.height == 0
|| row != area.y
|| column < area.x
|| column >= area.right()
|| area.width == 0
{
return None;
}
let mut x = area.x;
for idx in self.target_scroll_offset..self.targets.len() {
let width = self.target_tab_width_u16(idx);
let right = x.saturating_add(width);
if column >= x && column < right {
return Some(idx);
}
x = right;
if x >= area.right() {
break;
}
}
None
}
fn target_tab_label(&self, index: usize) -> String {
let Some(target) = self.targets.get(index) else {
return String::new();
};
let label = match &target.kind {
CopyTargetKind::CodeBlock => target.title.as_str(),
kind => kind.label(),
};
format!(" {} {label} ", index + 1)
}
fn target_tab_width(&self, index: usize) -> usize {
UnicodeWidthStr::width(self.target_tab_label(index).as_str())
}
fn target_tab_width_u16(&self, index: usize) -> u16 {
self.target_tab_width(index).min(u16::MAX as usize) as u16
}
}
fn max_preview_scroll(line_count: usize, page: usize) -> usize {
line_count.saturating_sub(page.max(1))
}
fn point_in_rect(area: Rect, column: u16, row: u16) -> bool {
column >= area.x && column < area.right() && row >= area.y && row < area.bottom()
}
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) {
let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true;
for (keys, desc) in pairs {
if !first {
spans.push(" ".into());
}
for (i, key) in keys.iter().enumerate() {
if i > 0 {
spans.push("/".into());
}
spans.push(Span::from(key));
}
if !keys.is_empty() {
spans.push(" ".into());
}
spans.push(Span::from(desc.to_string()));
first = false;
}
Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf);
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
use crate::copy_target::CopyTargetKind;
fn target(kind: CopyTargetKind, title: &str, content: &str) -> CopyTarget {
CopyTarget::new(kind, title, content)
}
#[test]
fn copy_picker_snapshot_basic() {
let mut overlay = CopyPickerOverlay::new(vec![
target(
CopyTargetKind::AssistantResponse,
"Last response",
"first paragraph\n\nsecond paragraph\nthird paragraph",
),
target(CopyTargetKind::Command, "Command", "rg copy target"),
target(CopyTargetKind::Output, "Output", "src/lib.rs\nsrc/app.rs"),
]);
let mut term = Terminal::new(TestBackend::new(64, 12)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(term.backend());
}
#[test]
fn enter_copies_selected_target() {
let mut overlay = CopyPickerOverlay::new(vec![
target(CopyTargetKind::AssistantResponse, "Last response", "hello"),
target(CopyTargetKind::Command, "Command", "rg copy target"),
]);
overlay.selected = 1;
overlay.copy_selected();
assert_eq!(
overlay.take_pending_copy().map(|target| target.content),
Some("rg copy target".to_string())
);
assert!(overlay.is_done());
}
#[test]
fn mouse_click_target_bar_selects_target() {
let mut overlay = CopyPickerOverlay::new(vec![
target(CopyTargetKind::AssistantResponse, "Last response", "hello"),
target(CopyTargetKind::Command, "Command", "rg copy target"),
]);
let areas = overlay.areas(Rect::new(0, 0, 64, 12));
let command_column = overlay.target_tab_width_u16(0) + 1;
overlay.handle_mouse_event_in_area(
areas,
/*line_count*/ 1,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: command_column,
row: areas.target_bar.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
);
assert_eq!(overlay.selected, 1);
assert_eq!(overlay.take_pending_copy(), None);
}
#[test]
fn mouse_click_preview_copies_selected_target() {
let mut overlay = CopyPickerOverlay::new(vec![
target(CopyTargetKind::AssistantResponse, "Last response", "hello"),
target(CopyTargetKind::Command, "Command", "rg copy target"),
]);
let areas = overlay.areas(Rect::new(0, 0, 64, 12));
overlay.handle_mouse_event_in_area(
areas,
/*line_count*/ 1,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: areas.preview.x,
row: areas.preview.y,
modifiers: crossterm::event::KeyModifiers::NONE,
},
);
assert_eq!(
overlay.take_pending_copy().map(|target| target.content),
Some("hello".to_string())
);
}
}

View File

@@ -0,0 +1,275 @@
use std::sync::Arc;
use pulldown_cmark::CodeBlockKind;
use pulldown_cmark::Event;
use pulldown_cmark::Options;
use pulldown_cmark::Parser;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
use crate::history_cell::HistoryCell;
const MAX_PREVIEW_CHARS: usize = 96;
const MAX_EXEC_CALLS: usize = 10;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum CopyTargetKind {
AssistantResponse,
CodeBlock,
Command,
Output,
}
impl CopyTargetKind {
pub(crate) fn label(&self) -> &'static str {
match self {
Self::AssistantResponse => "Response",
Self::CodeBlock => "Code",
Self::Command => "Command",
Self::Output => "Output",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CopyTarget {
pub(crate) kind: CopyTargetKind,
pub(crate) title: String,
pub(crate) preview: String,
pub(crate) content: String,
}
impl CopyTarget {
pub(crate) fn new(
kind: CopyTargetKind,
title: impl Into<String>,
content: impl Into<String>,
) -> Self {
let content = content.into();
Self {
kind,
title: title.into(),
preview: preview_for(&content),
content,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CopyTargetGroup {
pub(crate) targets: Vec<CopyTarget>,
}
impl CopyTargetGroup {
pub(crate) fn new(targets: Vec<CopyTarget>) -> Self {
Self { targets }
}
}
pub(crate) fn build_copy_targets(
last_agent_markdown: Option<&str>,
cells: &[Arc<dyn HistoryCell>],
) -> Vec<CopyTarget> {
let mut targets = Vec::new();
if let Some(markdown) = last_agent_markdown.filter(|text| !text.is_empty()) {
targets.push(CopyTarget::new(
CopyTargetKind::AssistantResponse,
"Last response",
markdown.to_string(),
));
for (idx, block) in code_blocks_from_markdown(markdown).into_iter().enumerate() {
let title = match block.language {
Some(language) => format!("Code block {} ({language})", idx + 1),
None => format!("Code block {}", idx + 1),
};
targets.push(CopyTarget::new(
CopyTargetKind::CodeBlock,
title,
block.content,
));
}
}
let mut exec_groups = 0usize;
for cell in cells.iter().rev() {
for group in cell.copy_target_groups() {
if exec_groups >= MAX_EXEC_CALLS {
break;
}
targets.extend(group.targets);
exec_groups += 1;
}
if exec_groups >= MAX_EXEC_CALLS {
break;
}
}
targets
}
pub(crate) fn output_text_for_copy(output: &str) -> String {
trim_trailing_line_endings(strip_ansi_escape_sequences(output))
}
pub(crate) fn trim_trailing_line_endings(mut text: String) -> String {
while text.ends_with('\n') || text.ends_with('\r') {
text.pop();
}
text
}
#[derive(Debug, PartialEq, Eq)]
struct CodeBlock {
language: Option<String>,
content: String,
}
fn code_blocks_from_markdown(markdown: &str) -> Vec<CodeBlock> {
let parser = Parser::new_ext(markdown, Options::all());
let mut blocks = Vec::new();
let mut current: Option<CodeBlock> = None;
for event in parser {
match event {
Event::Start(Tag::CodeBlock(kind)) => {
current = Some(CodeBlock {
language: language_from_code_block_kind(kind),
content: String::new(),
});
}
Event::Text(text) => {
if let Some(block) = &mut current {
block.content.push_str(&text);
}
}
Event::End(TagEnd::CodeBlock) => {
if let Some(mut block) = current.take() {
block.content = trim_trailing_line_endings(block.content);
if !block.content.is_empty() {
blocks.push(block);
}
}
}
_ => {}
}
}
blocks
}
fn language_from_code_block_kind(kind: CodeBlockKind<'_>) -> Option<String> {
match kind {
CodeBlockKind::Fenced(info) => info
.split_whitespace()
.next()
.filter(|language| !language.is_empty())
.map(ToOwned::to_owned),
CodeBlockKind::Indented => None,
}
}
fn preview_for(content: &str) -> String {
let single_line = content
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
truncate_chars(&single_line, MAX_PREVIEW_CHARS)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
let mut chars = text.chars();
let mut out: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
out.push_str("...");
}
out
}
fn strip_ansi_escape_sequences(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
out.push(ch);
continue;
}
match chars.peek().copied() {
Some('[') => {
chars.next();
for c in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
Some(']') | Some('P') | Some('_') | Some('^') => {
chars.next();
let mut saw_esc = false;
for c in chars.by_ref() {
if c == '\u{7}' || (saw_esc && c == '\\') {
break;
}
saw_esc = c == '\u{1b}';
}
}
Some(_) => {
chars.next();
}
None => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn extracts_fenced_code_blocks_without_fences() {
let markdown =
"Intro\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n\n- keep bullet\n";
let blocks = code_blocks_from_markdown(markdown);
assert_eq!(
blocks,
vec![CodeBlock {
language: Some("rust".to_string()),
content: "fn main() {\n println!(\"hi\");\n}".to_string(),
}]
);
}
#[test]
fn output_copy_strips_ansi_and_trailing_line_endings() {
assert_eq!(
output_text_for_copy("\u{1b}[31merror\u{1b}[0m\nnext\n"),
"error\nnext"
);
}
#[test]
fn target_builder_keeps_markdown_bullets_in_response() {
let targets = build_copy_targets(Some("- real bullet\n\ntext"), &[]);
assert_eq!(
targets,
vec![CopyTarget {
kind: CopyTargetKind::AssistantResponse,
title: "Last response".to_string(),
preview: "- real bullet".to_string(),
content: "- real bullet\n\ntext".to_string(),
}]
);
}
}

View File

@@ -97,6 +97,7 @@ pub(crate) async fn run_cwd_selection_prompt(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Mouse(_) => {}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());

View File

@@ -3,6 +3,10 @@ use std::time::Instant;
use super::model::CommandOutput;
use super::model::ExecCall;
use super::model::ExecCell;
use crate::copy_target::CopyTarget;
use crate::copy_target::CopyTargetGroup;
use crate::copy_target::CopyTargetKind;
use crate::copy_target::output_text_for_copy;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryCell;
use crate::render::highlight::highlight_bash_to_lines;
@@ -248,6 +252,33 @@ impl HistoryCell for ExecCell {
}
lines
}
fn copy_target_groups(&self) -> Vec<CopyTargetGroup> {
self.calls
.iter()
.rev()
.filter_map(|call| {
let command = strip_bash_lc_and_escape(&call.command);
if command.trim().is_empty() {
return None;
}
let mut targets =
vec![CopyTarget::new(CopyTargetKind::Command, "Command", command)];
if let Some(output) = call.output.as_ref() {
let output_text = output_text_for_copy(&output.aggregated_output);
if !output_text.trim().is_empty() {
targets.push(CopyTarget::new(
CopyTargetKind::Output,
"Output",
output_text,
));
}
}
Some(CopyTargetGroup::new(targets))
})
.collect()
}
}
impl ExecCell {
@@ -723,6 +754,38 @@ mod tests {
.collect::<String>()
}
#[test]
fn copy_target_groups_use_unwrapped_command_and_plain_output() {
let call = ExecCall {
call_id: "call-id".to_string(),
command: vec![
"bash".into(),
"-lc".into(),
"printf 'hello world\\n'".into(),
],
parsed: Vec::new(),
output: Some(CommandOutput {
exit_code: 0,
aggregated_output: "\u{1b}[32mhello\u{1b}[0m\n".to_string(),
formatted_output: "\u{1b}[32mhello\u{1b}[0m\n".to_string(),
}),
source: ExecCommandSource::UserShell,
start_time: None,
duration: None,
interaction_input: None,
};
let cell = ExecCell::new(call, /*animations_enabled*/ false);
let groups = cell.copy_target_groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].targets.len(), 2);
assert_eq!(groups[0].targets[0].kind, CopyTargetKind::Command);
assert_eq!(groups[0].targets[0].content, "printf 'hello world\\n'");
assert_eq!(groups[0].targets[1].kind, CopyTargetKind::Output);
assert_eq!(groups[0].targets[1].content, "hello");
}
#[test]
fn user_shell_output_is_limited_by_screen_lines() {
let long_url_like = format!(

View File

@@ -117,6 +117,7 @@ pub(crate) async fn run_external_agent_config_migration_prompt(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Mouse(_) => {}
TuiEvent::Draw => {
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());

View File

@@ -182,6 +182,14 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
fn transcript_animation_tick(&self) -> Option<u64> {
None
}
/// Returns semantic copy target groups exposed by this cell.
///
/// Grouping is used by the copy picker to cap recent tool calls while keeping
/// related targets, such as a command and its output, adjacent.
fn copy_target_groups(&self) -> Vec<crate::copy_target::CopyTargetGroup> {
Vec::new()
}
}
impl Renderable for Box<dyn HistoryCell> {

View File

@@ -111,6 +111,8 @@ mod clipboard_copy;
mod clipboard_paste;
mod collaboration_modes;
mod color;
mod copy_picker_overlay;
mod copy_target;
pub(crate) mod custom_terminal;
pub use custom_terminal::Terminal;
mod cwd_prompt;

View File

@@ -153,6 +153,7 @@ pub(crate) async fn run_model_migration_prompt(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Mouse(_) => {}
TuiEvent::Draw => {
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());

View File

@@ -481,6 +481,7 @@ pub(crate) async fn run_onboarding_app(
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}
TuiEvent::Mouse(_) => {}
TuiEvent::Draw => {
if !did_full_clear_after_success
&& onboarding_screen.steps.iter().any(|step| {

View File

@@ -19,6 +19,8 @@ use std::io::Result;
use std::sync::Arc;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::copy_picker_overlay::CopyPickerOverlay;
use crate::copy_target::CopyTarget;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint;
@@ -48,6 +50,7 @@ use ratatui::widgets::Wrap;
pub(crate) enum Overlay {
Transcript(TranscriptOverlay),
Static(StaticOverlay),
CopyPicker(CopyPickerOverlay),
}
impl Overlay {
@@ -66,10 +69,15 @@ impl Overlay {
Self::Static(StaticOverlay::with_renderables(renderables, title))
}
pub(crate) fn new_copy_picker(targets: Vec<CopyTarget>) -> Self {
Self::CopyPicker(CopyPickerOverlay::new(targets))
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match self {
Overlay::Transcript(o) => o.handle_event(tui, event),
Overlay::Static(o) => o.handle_event(tui, event),
Overlay::CopyPicker(o) => o.handle_event(tui, event),
}
}
@@ -77,6 +85,14 @@ impl Overlay {
match self {
Overlay::Transcript(o) => o.is_done(),
Overlay::Static(o) => o.is_done(),
Overlay::CopyPicker(o) => o.is_done(),
}
}
pub(crate) fn take_pending_copy(&mut self) -> Option<CopyTarget> {
match self {
Overlay::CopyPicker(o) => o.take_pending_copy(),
Overlay::Transcript(_) | Overlay::Static(_) => None,
}
}
}

View File

@@ -35,6 +35,7 @@ pub enum SlashCommand {
Agent,
// Undo,
Copy,
CopyPicker,
Diff,
Mention,
Status,
@@ -83,6 +84,7 @@ impl SlashCommand {
// SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::CopyPicker => "open semantic copy picker",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
@@ -165,6 +167,7 @@ impl SlashCommand {
| SlashCommand::MemoryUpdate => false,
SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::CopyPicker
| SlashCommand::Rename
| SlashCommand::Mention
| SlashCommand::Skills
@@ -193,7 +196,7 @@ impl SlashCommand {
fn is_visible(self) -> bool {
match self {
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
SlashCommand::Copy => !cfg!(target_os = "android"),
SlashCommand::Copy | SlashCommand::CopyPicker => !cfg!(target_os = "android"),
SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions),
_ => true,
}

View File

@@ -0,0 +1,17 @@
---
source: tui/src/copy_picker_overlay.rs
assertion_line: 465
expression: term.backend()
---
" 1 Response 2 Command 3 Output "
"first paragraph "
" "
"second paragraph "
"third paragraph "
" "
" "
" "
" "
" "
" ←/→/h/l target ↑/↓/k/j scroll pgup/pgdn page "
" enter copy q/esc close "

View File

@@ -16,10 +16,13 @@ use crossterm::Command;
use crossterm::SynchronizedUpdate;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::DisableFocusChange;
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableBracketedPaste;
use crossterm::event::EnableFocusChange;
use crossterm::event::EnableMouseCapture;
use crossterm::event::KeyEvent;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::MouseEvent;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::terminal::EnterAlternateScreen;
@@ -273,6 +276,7 @@ fn set_panic_hook() {
#[derive(Clone, Debug)]
pub enum TuiEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Paste(String),
Draw,
}
@@ -483,6 +487,7 @@ impl Tui {
if !self.alt_screen_enabled {
return Ok(());
}
let _ = self.disable_mouse_capture();
// Disable alternate scroll when leaving alt-screen
let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll);
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
@@ -493,6 +498,16 @@ impl Tui {
Ok(())
}
pub fn enable_mouse_capture(&mut self) -> Result<()> {
execute!(self.terminal.backend_mut(), EnableMouseCapture)?;
Ok(())
}
pub fn disable_mouse_capture(&mut self) -> Result<()> {
execute!(self.terminal.backend_mut(), DisableMouseCapture)?;
Ok(())
}
pub fn insert_history_lines(&mut self, lines: Vec<Line<'static>>) {
self.pending_history_lines.extend(lines);
self.frame_requester().schedule_frame();

View File

@@ -172,11 +172,11 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
/// Poll the shared crossterm stream for the next mapped `TuiEvent`.
///
/// This skips events we don't use (mouse events, etc.) and keeps polling until it yields
/// This skips events we don't use and keeps polling until it yields
/// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops
/// the underlying stream and returns `Pending` to fully release stdin.
pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll<Option<TuiEvent>> {
// Some crossterm events map to None (e.g. FocusLost, mouse); loop so we keep polling
// Some crossterm events map to None (e.g. FocusLost); loop so we keep polling
// until we return a mapped event, hit Pending, or see EOF/error.
loop {
let poll_result = {
@@ -233,7 +233,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
}
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.).
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use.
fn map_crossterm_event(&mut self, event: Event) -> Option<TuiEvent> {
match event {
Event::Key(key_event) => {
@@ -244,6 +244,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
Some(TuiEvent::Key(key_event))
}
Event::Mouse(mouse_event) => Some(TuiEvent::Mouse(mouse_event)),
Event::Resize(_, _) => Some(TuiEvent::Draw),
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
Event::FocusGained => {
@@ -255,7 +256,6 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
self.terminal_focused.store(false, Ordering::Relaxed);
None
}
_ => None,
}
}
}
@@ -408,6 +408,29 @@ mod tests {
}
}
#[tokio::test(flavor = "current_thread")]
async fn mouse_event_maps_to_tui_event() {
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream(broker, draw_rx, terminal_focused);
let expected_mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 7,
row: 3,
modifiers: KeyModifiers::NONE,
};
handle.send(Ok(Event::Mouse(expected_mouse)));
match stream.next().await.unwrap() {
TuiEvent::Mouse(mouse) => assert_eq!(mouse, expected_mouse),
other => panic!("expected mouse event, got {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn draw_and_key_events_yield_both() {
let (broker, handle, draw_tx, draw_rx, terminal_focused) = setup();

View File

@@ -57,6 +57,7 @@ pub(crate) async fn run_update_prompt_if_needed(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Mouse(_) => {}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());