diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index deab0242..7730ab74 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -466,6 +466,35 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec Option { + 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 /// row has a click_action assigned (#3028). fn sidebar_click_action(app: &App, mouse: MouseEvent) -> Option { @@ -676,6 +705,15 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec { + 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 => { app.view_stack .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!( + ©.action, + ContextMenuAction::CopyText { text } + if text == "[~] worker doc-checker\nid: agent_123 · 2 step(s)" + )); + } + #[test] fn sidebar_click_outside_section_resolves_to_none() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index e1aaa1f5..1a4ff137 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -81,6 +81,11 @@ pub enum ContextMenuAction { ExecuteCommand { 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)]