feat(tui): add Copy to the sidebar right-click context menu

Removing Paste from the sidebar menu (#3065) left rows with no copy
path at all — sidebar text can't be mouse-selected. Right-clicking a
sidebar row now offers Copy, which writes the row's untruncated text
plus its hover detail to the clipboard via a new
ContextMenuAction::CopyText. Run stays first for clickable rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
CodeWhale Agent
2026-06-12 15:35:20 -07:00
parent ba1104251a
commit e4989c0eae
2 changed files with 81 additions and 0 deletions
+76
View File
@@ -466,6 +466,35 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
Vec::new() Vec::new()
} }
/// Resolve a right-click in the sidebar to the hovered row's full copyable
/// text: the row's untruncated text plus its hover detail when present.
fn sidebar_row_copy_text(app: &App, mouse: MouseEvent) -> Option<String> {
for section in &app.sidebar_hover.sections {
if !mouse_hits_rect(mouse, Some(section.content_area)) {
continue;
}
if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) {
let mut text = row.full_text.clone();
if let Some(detail) = row.detail.as_deref()
&& !detail.trim().is_empty()
{
text.push('\n');
text.push_str(detail);
}
return Some(text).filter(|text| !text.trim().is_empty());
}
let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
if let Some(full) = section.lines.get(line_idx) {
return Some(full.clone()).filter(|text| !text.trim().is_empty());
}
}
None
}
fn first_line(text: &str) -> &str {
text.lines().next().unwrap_or(text)
}
/// Resolve a left-click in the sidebar to a slash command, if the clicked /// Resolve a left-click in the sidebar to a slash command, if the clicked
/// row has a click_action assigned (#3028). /// row has a click_action assigned (#3028).
fn sidebar_click_action(app: &App, mouse: MouseEvent) -> Option<String> { fn sidebar_click_action(app: &App, mouse: MouseEvent) -> Option<String> {
@@ -676,6 +705,15 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<Co
action: ContextMenuAction::ExecuteCommand { command }, action: ContextMenuAction::ExecuteCommand { command },
}); });
} }
// Copy the hovered row's full text (sidebar rows can't be
// mouse-selected, so the menu is the only copy path).
if let Some(text) = sidebar_row_copy_text(app, mouse) {
entries.push(ContextMenuEntry {
label: "Copy".to_string(),
description: truncate_line_to_width(first_line(&text), 28),
action: ContextMenuAction::CopyText { text },
});
}
} else { } else {
// Paste first — the most common action when right-clicking in the // Paste first — the most common action when right-clicking in the
// composer or transcript after copying text from the output area. // composer or transcript after copying text from the output area.
@@ -811,6 +849,13 @@ pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuActio
app.status_message = Some("Command staged in composer".to_string()); app.status_message = Some("Command staged in composer".to_string());
app.needs_redraw = true; app.needs_redraw = true;
} }
ContextMenuAction::CopyText { text } => {
if app.clipboard.write_text(&text).is_ok() {
app.status_message = Some("Copied".to_string());
} else {
app.status_message = Some("Copy failed".to_string());
}
}
ContextMenuAction::OpenCommandPalette => { ContextMenuAction::OpenCommandPalette => {
app.view_stack app.view_stack
.push(CommandPaletteView::new(build_command_palette_entries( .push(CommandPaletteView::new(build_command_palette_entries(
@@ -1211,6 +1256,37 @@ mod tests {
); );
} }
#[test]
fn sidebar_context_menu_offers_copy_of_hovered_row() {
let mut app = create_test_app();
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.sidebar_hover.sections.push(SidebarHoverSection {
content_area: Rect::new(60, 4, 20, 6),
lines: vec!["agent row".to_string()],
rows: vec![SidebarHoverRow {
row_y: 4,
display_text: "[~] worker doc-che…".to_string(),
full_text: "[~] worker doc-checker".to_string(),
detail: Some("id: agent_123 · 2 step(s)".to_string()),
is_truncated: true,
click_action: None,
}],
});
let entries = build_context_menu_entries(&app, right_click(65, 4));
let copy = entries
.iter()
.find(|entry| matches!(entry.action, ContextMenuAction::CopyText { .. }))
.expect("sidebar row should offer Copy");
assert_eq!(copy.label, "Copy");
assert!(matches!(
&copy.action,
ContextMenuAction::CopyText { text }
if text == "[~] worker doc-checker\nid: agent_123 · 2 step(s)"
));
}
#[test] #[test]
fn sidebar_click_outside_section_resolves_to_none() { fn sidebar_click_outside_section_resolves_to_none() {
let mut app = create_test_app(); let mut app = create_test_app();
+5
View File
@@ -81,6 +81,11 @@ pub enum ContextMenuAction {
ExecuteCommand { ExecuteCommand {
command: String, command: String,
}, },
/// Copy a pre-resolved text payload (e.g. a sidebar row's full text)
/// to the clipboard.
CopyText {
text: String,
},
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]